Spaces:
Paused
Paused
Claude
Claude Sonnet 5
T-012: port the single-player client onto session + loopback
42b40d2 unverified Download input.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 33.6 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/input.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities/input.js
-
curl -L -o input.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/input.js
33.6 kB
| /* ============================================================ | |
| Mouse + keyboard input: left-drag to box-select the player's own | |
| units, right-click to issue a context-sensitive order (attack an | |
| enemy, gather a node, or just move), a build-placement mode the HUD | |
| arms via startBuild(), and camera control (wheel to zoom, WASD/arrows | |
| to pan). | |
| ============================================================ */ | |
| ; | |
| import { game } from "./session.js"; | |
| import { UNITS, BUILDINGS, canBuildCategory } from "./engine/entities.js"; | |
| import { createCamera, zoomAt, panCamera, pinchZoomPan } from "./camera.js"; | |
| import { createInputCommands, CLICK_THRESHOLD } from "./inputCommands.js"; | |
| import * as sound from "./sound.js"; | |
| import { toggleObserverMode, requestExitObserverMode, cycleObserverBase, observerWheelZoom, observerDragStart, observerDragMove, observerDragEnd, tickObserverCamera } from "./observer.js"; | |
| // A second press within this window recenters/cycles instead of repeating β shared by control | |
| // groups (recallGroup, below) and by Space's base-cycling (centerOnBase, below). | |
| const DOUBLE_GROUP_MS = 400; | |
| const UNIT_PICK_RADIUS = 10; | |
| const NODE_PICK_RADIUS = 14; | |
| const ZOOM_STEP = 1.12; | |
| const EDGE_SCROLL_MARGIN = 20; // px from a canvas edge that starts scrolling the camera | |
| // A is deliberately NOT a pan key β it's reserved for attack-move (the genre | |
| // standard). Pan left with the arrow key or edge-scroll. | |
| const PAN_KEYS = { | |
| arrowleft: [-1, 0], | |
| arrowright: [1, 0], d: [1, 0], | |
| arrowup: [0, -1], w: [0, -1], | |
| arrowdown: [0, 1], s: [0, 1], | |
| }; | |
| // `transport` is the ADR-0004 seam (net/transport.js) this controller and every command it | |
| // issues routes through β boot.js hands in either the real session-backed loopback transport | |
| // (an ordinary skirmish/competition/loaded game, T-012) or, for the boot paths T-012 | |
| // deliberately leaves untouched (Odyssey, scenario, a spectated match), its directTransport | |
| // shim, which applies the same WireCommand straight to this `state` with no session behind it. | |
| // Either way this file never calls an engine/commands.js issue* function itself. | |
| export function attachInput(canvas, state, transport, onChange) { | |
| const controller = new AbortController(); | |
| const { signal } = controller; | |
| const camera = createCamera(state.map); | |
| const heldKeys = new Set(); | |
| // The stateless "what is at this point / what does clicking here do" helpers (inputCommands.js). | |
| // Bound once to this controller's collaborators, so every call below reads unchanged. | |
| const { viewport, toWorld, entityAt, nodeAt, currentFormation, aggressiveMove, alivePlayerUnitIds, | |
| selectedUnits, centerCamera, applyBoxSelection, selectSameTypeAt, commandAt } = | |
| createInputCommands({ canvas, state, camera, transport, onChange }); | |
| let dragBox = null; | |
| let rightDragStart = null; // world-space {x,y} where a right-button drag began, or null between drags | |
| let buildMode = null; | |
| let attackMoveArmed = false; // set by the A key; the next left-click issues an attack-move | |
| let lastWorldPos = { x: state.map.width / 2, y: state.map.height / 2 }; | |
| // Control groups live on the shared session, keyed per planet id, so they survive an | |
| // Odyssey jump (which destroys + rebuilds this input controller) and can be shown/tapped in | |
| // the HUD. `{ digit: [ids] }` per world. | |
| const groups = () => (game.groups[state.planetId] ||= {}); | |
| let lastGroupDigit = null, lastGroupAt = -Infinity; // a second press of the same digit re-centers | |
| let idleCycle = 0; // round-robins through idle workers on repeated presses | |
| let idleProducerCycle = 0; // round-robins through idle production buildings on repeated presses | |
| let baseCycle = 0, lastBaseAt = -Infinity; // round-robins through completed CCs on repeated Space presses | |
| let edgePan = [0, 0]; // camera nudge from the cursor sitting at a screen edge | |
| // ---- touch state ---- | |
| // One finger = tap-to-select / tap-to-command / drag-a-box; two fingers = | |
| // pinch-zoom + pan. See the touch handlers below for the full gesture map. | |
| let touchStart = null; // { cx, cy, wx, wy, moved } for the active one-finger drag (client + world) | |
| let pinchPrev = null; // last two-finger snapshot { ax, ay, bx, by } for pinchZoomPan | |
| let gestureActive = false; // a two-finger gesture happened; ignore fingers until all lift | |
| let lastTapAt = 0, lastTapCX = 0, lastTapCY = 0; // for double-tap detection (client coords + time) | |
| // The A key arms attack-move; the crosshair cursor + field tint show it's armed, and the | |
| // HUD's Attack-Move button flips to its ARMED state. onChange() is what makes that button | |
| // rebuild β without it, arming via the A key (or a right-click cancel) changed nothing the | |
| // player could see on the panel until some unrelated HUD tick happened to rebuild it. | |
| function setArmed(v) { | |
| if (attackMoveArmed === v) return; | |
| attackMoveArmed = v; | |
| canvas.classList.toggle("aim-cursor", v); | |
| onChange(); | |
| } | |
| // First real touch flips the app into touch mode: CSS (style.css) grows the | |
| // tap targets and the HUD swaps to the touch legend. Idempotent. | |
| function onTouchActive() { | |
| if (!document.body.classList.contains("touch")) document.body.classList.add("touch"); | |
| } | |
| // Abandon any gesture in progress without issuing it. mousedown/touchstart arm this | |
| // controller's drag state on the CANVAS, but the matching mouseup is a WINDOW listener (so a | |
| // drag that ends off-canvas β e.g. at a screen edge while panning β still completes, by | |
| // design). That means a right-click-drag (or box-select, or an armed attack-move) begun on the | |
| // canvas is still "live" even after a blocking modal opens over it β the eventual mouseup, | |
| // wherever it lands, bubbles to window and resolves against the STALE start point on the world | |
| // behind the modal. Concretely: right-click-drag toward a Spaceport's Jump button, the picker | |
| // opens mid-drag, and releasing over its landing-site chart reads as a move order on the | |
| // current planet instead of a landing pick. Callers that open such a modal (boot.js's | |
| // initiateJump, starmap.js's openStarmap) must call this first so no gesture is left armed to | |
| // resolve later against a canvas the player can no longer see or intend to be clicking on. | |
| function cancelGesture() { | |
| dragBox = null; | |
| rightDragStart = null; | |
| buildMode = null; | |
| setArmed(false); | |
| touchStart = null; | |
| pinchPrev = null; | |
| gestureActive = false; | |
| } | |
| // ---- shared selection / command logic ---- | |
| // Extracted so the mouse and touch paths issue byte-identical orders: a | |
| // finger-tap runs exactly the same routing as a right-click, a drag-box the | |
| // same as a mouse box, and a double-tap the same as a double-click. | |
| canvas.addEventListener("mousedown", e => { | |
| // Observer Mode: the canvas shows a possibly-different world than `state` (see | |
| // observer.js's observedState) β every one of this handler's hit-tests below reads | |
| // `state`/the real `camera`, so they'd resolve against the wrong world's coordinates. | |
| // Left-drag just pans the observer camera instead; every other button is a no-op. | |
| if (game.observerMode) { if (e.button === 0) observerDragStart(e.clientX, e.clientY); return; } | |
| if (e.button === 2) { | |
| // The actual command (or a build/attack-move cancel) fires on mouseup below, once we | |
| // know whether this stayed a click or became a drag β just mark where it started. | |
| rightDragStart = toWorld(e.clientX, e.clientY); | |
| return; | |
| } | |
| if (e.button !== 0) return; | |
| const p = toWorld(e.clientX, e.clientY); | |
| if (buildMode) { placeBuildingAt(p); return; } | |
| // Armed attack-move: this click commits the order instead of starting a | |
| // selection drag. Consumes the arm, whether or not anything is selected. | |
| if (attackMoveArmed) { | |
| setArmed(false); | |
| const selected = selectedUnits(); | |
| if (selected.length) { aggressiveMove(selected, p.x, p.y); sound.playOrder(); onChange(); } | |
| return; | |
| } | |
| dragBox = { x1: p.x, y1: p.y, x2: p.x, y2: p.y }; | |
| }, { signal }); | |
| canvas.addEventListener("mousemove", e => { | |
| if (game.observerMode) { observerDragMove(e.clientX, e.clientY); return; } | |
| lastWorldPos = toWorld(e.clientX, e.clientY); // tracked continuously for the build-placement ghost | |
| // Edge scroll: cursor within a margin of a canvas edge nudges the camera | |
| // that way, so you can drag the view without touching the keyboard. | |
| const rect = canvas.getBoundingClientRect(); | |
| const lx = e.clientX - rect.left, ly = e.clientY - rect.top; | |
| edgePan = [ | |
| lx < EDGE_SCROLL_MARGIN ? -1 : lx > rect.width - EDGE_SCROLL_MARGIN ? 1 : 0, | |
| ly < EDGE_SCROLL_MARGIN ? -1 : ly > rect.height - EDGE_SCROLL_MARGIN ? 1 : 0, | |
| ]; | |
| if (!dragBox) return; | |
| dragBox.x2 = lastWorldPos.x; | |
| dragBox.y2 = lastWorldPos.y; | |
| }, { signal }); | |
| canvas.addEventListener("mouseleave", () => { edgePan = [0, 0]; }, { signal }); | |
| window.addEventListener("mouseup", e => { | |
| if (game.observerMode) { observerDragEnd(); return; } | |
| if (e.button === 2) { | |
| const start = rightDragStart; | |
| rightDragStart = null; | |
| if (!start) return; // the button went down somewhere this listener never saw (e.g. off-canvas) | |
| if (buildMode) { buildMode = null; onChange(); return; } | |
| if (attackMoveArmed) { setArmed(false); return; } // right-click cancels a pending attack-move | |
| const end = toWorld(e.clientX, e.clientY); | |
| const dx = end.x - start.x, dy = end.y - start.y; | |
| // A plain click carries no heading (the shape/facing fall back to the group's own travel | |
| // direction, exactly as before); a real drag's direction becomes the explicit heading β | |
| // see currentFormation/applyFacing. Holding Ctrl queues the order as a waypoint instead of | |
| // replacing what the units are doing, so a sequence of Ctrl+right-drags lays down a | |
| // multi-leg, individually-aimed path. | |
| const heading = Math.hypot(dx, dy) >= CLICK_THRESHOLD ? { x: dx, y: dy } : null; | |
| commandAt(start, e.ctrlKey, heading); | |
| return; | |
| } | |
| if (e.button !== 0 || !dragBox) return; | |
| const box = dragBox; | |
| dragBox = null; | |
| // Ctrl (the game's modifier β see the waypoint note below) adds to the current selection | |
| // instead of replacing it, so you can pull several groups together; Alt instead SUBTRACTS | |
| // the box's catch from the current selection (pulling specific units back out of a big | |
| // blob). An empty additive/subtractive click leaves the selection untouched. | |
| applyBoxSelection(box, e.ctrlKey, e.altKey); | |
| }, { signal }); | |
| // Double-click a unit to grab every same-type unit of yours currently on screen β the standard | |
| // "select all of this type" gesture. Ctrl+double-click extends that to the whole map. | |
| canvas.addEventListener("dblclick", e => { | |
| if (game.observerMode) return; | |
| // Mid-build-placement or with attack-move armed, each of the two clicks that make up this | |
| // dblclick already went through mousedown's own buildMode/attackMoveArmed handling (placed | |
| // the building / committed the attack-move); the reselect-fleet-of-this-type gesture below | |
| // doesn't apply on top of that, same as every other handler that checks these flags first. | |
| if (buildMode || attackMoveArmed) return; | |
| selectSameTypeAt(toWorld(e.clientX, e.clientY), e.ctrlKey); | |
| }, { signal }); | |
| // The actual right-click handling (build/attack-move cancel, or commandAt with whatever | |
| // heading the drag implies) lives on mouseup above, which fires before this β contextmenu's | |
| // only remaining job is suppressing the browser's native menu. NOT Shift+right-click: Firefox | |
| // force-shows the native context menu on it and bypasses preventDefault, so a Shift-modified | |
| // right-drag could never be captured β Ctrl is the queue modifier here instead, for exactly | |
| // that reason. | |
| canvas.addEventListener("contextmenu", e => { e.preventDefault(); }, { signal }); | |
| canvas.addEventListener("wheel", e => { | |
| e.preventDefault(); | |
| const rect = canvas.getBoundingClientRect(); | |
| const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; | |
| if (game.observerMode) { observerWheelZoom(e.clientX, e.clientY, rect, factor); return; } | |
| const { vw, vh } = viewport(); | |
| zoomAt(camera, state.map, vw, vh, e.clientX - rect.left, e.clientY - rect.top, factor); | |
| }, { signal, passive: false }); | |
| /* ---------- touch (phones / tablets) ---------- | |
| A one-button, no-hover parallel to the mouse controls, so the game is fully | |
| playable by finger: | |
| β’ tap your unit/building ....... select it (double-tap = all of that type on screen) | |
| β’ tap elsewhere ................ command the selection (move / attack a foe / gather a | |
| node), or set a selected production building's rally | |
| β’ one-finger drag .............. box-select your units | |
| β’ two-finger drag .............. pan Β· pinch ...... zoom (about the pinch) | |
| β’ Build mode / Attack-move ..... tap places the building / commits the attack-move | |
| Runs the SAME applyBoxSelection / commandAt / selectSameTypeAt / placeBuildingAt as the | |
| mouse, so touch and desktop issue identical orders. `touch-action: none` on the canvas | |
| (style.css) stops the browser hijacking these as page scroll / zoom. */ | |
| const TAP_MOVE_TOL = 14; // client px a finger may travel and still count as a tap, not a drag | |
| const DOUBLE_TAP_MS = 300, DOUBLE_TAP_DIST = 30; | |
| function twoSnapshot(t0, t1) { | |
| const rect = canvas.getBoundingClientRect(); | |
| return { ax: t0.clientX - rect.left, ay: t0.clientY - rect.top, | |
| bx: t1.clientX - rect.left, by: t1.clientY - rect.top }; | |
| } | |
| canvas.addEventListener("touchstart", e => { | |
| e.preventDefault(); | |
| onTouchActive(); | |
| if (e.touches.length >= 2) { | |
| // Second finger down: abandon any one-finger drag/box and start pinch-pan. | |
| touchStart = null; dragBox = null; gestureActive = true; | |
| pinchPrev = twoSnapshot(e.touches[0], e.touches[1]); | |
| return; | |
| } | |
| if (e.touches.length === 1 && !gestureActive) { | |
| const t = e.touches[0]; | |
| const w = toWorld(t.clientX, t.clientY); | |
| touchStart = { cx: t.clientX, cy: t.clientY, wx: w.x, wy: w.y, moved: false }; | |
| lastWorldPos = w; // so the build ghost sits under the finger from the first touch | |
| } | |
| }, { signal, passive: false }); | |
| canvas.addEventListener("touchmove", e => { | |
| e.preventDefault(); | |
| const { vw, vh } = viewport(); | |
| if (e.touches.length >= 2 && pinchPrev) { | |
| const cur = twoSnapshot(e.touches[0], e.touches[1]); | |
| pinchZoomPan(camera, state.map, vw, vh, pinchPrev, cur); | |
| pinchPrev = cur; | |
| return; | |
| } | |
| if (e.touches.length === 1 && touchStart && !gestureActive) { | |
| const t = e.touches[0]; | |
| const w = toWorld(t.clientX, t.clientY); | |
| lastWorldPos = w; | |
| if (!touchStart.moved && Math.hypot(t.clientX - touchStart.cx, t.clientY - touchStart.cy) > TAP_MOVE_TOL) { | |
| touchStart.moved = true; | |
| // A drag that isn't placing a building becomes a selection box. | |
| if (!buildMode) dragBox = { x1: touchStart.wx, y1: touchStart.wy, x2: w.x, y2: w.y }; | |
| } | |
| if (dragBox) { dragBox.x2 = w.x; dragBox.y2 = w.y; } | |
| } | |
| }, { signal, passive: false }); | |
| function endTouch(e) { | |
| e.preventDefault(); | |
| if (e.touches.length >= 2) { pinchPrev = twoSnapshot(e.touches[0], e.touches[1]); return; } | |
| if (e.touches.length === 1) { | |
| // Dropped from two fingers to one: don't let the leftover finger act β wait | |
| // for a clean, all-up release so a lifted pinch never fires a stray tap. | |
| pinchPrev = null; | |
| return; | |
| } | |
| // All fingers up. | |
| if (gestureActive) { gestureActive = false; touchStart = null; dragBox = null; return; } | |
| if (!touchStart) return; | |
| const start = touchStart; touchStart = null; | |
| if (start.moved && dragBox) { // a drag: finish the selection box | |
| const box = dragBox; dragBox = null; | |
| applyBoxSelection(box, false); | |
| return; | |
| } | |
| dragBox = null; | |
| const p = { x: start.wx, y: start.wy }; | |
| // In build mode, place at where the finger LIFTED (lastWorldPos tracks the | |
| // move) so a drag-to-position works; for a plain tap that's the tap point. | |
| if (buildMode) { placeBuildingAt(lastWorldPos); return; } | |
| if (attackMoveArmed) { | |
| setArmed(false); | |
| const sel = selectedUnits(); | |
| if (sel.length) { aggressiveMove(sel, p.x, p.y); sound.playOrder(); onChange(); } | |
| return; | |
| } | |
| // A quick second tap near the first upgrades to select-all-of-type. | |
| const now = performance.now(); // UI-only timing; the deterministic sim never reads a clock | |
| const isDouble = now - lastTapAt < DOUBLE_TAP_MS | |
| && Math.hypot(start.cx - lastTapCX, start.cy - lastTapCY) < DOUBLE_TAP_DIST; | |
| lastTapAt = now; lastTapCX = start.cx; lastTapCY = start.cy; | |
| if (isDouble && selectSameTypeAt(p)) return; | |
| // Single tap: your own entity selects; anything else is a command to the | |
| // current selection (mirrors left-click select vs right-click order). | |
| const hit = entityAt(p.x, p.y); | |
| if (hit && hit.owner === "player") { | |
| state.selection = [hit.id]; | |
| sound.playSelect(); | |
| onChange(); | |
| } else { | |
| commandAt(p, false); | |
| } | |
| } | |
| canvas.addEventListener("touchend", endTouch, { signal, passive: false }); | |
| canvas.addEventListener("touchcancel", endTouch, { signal, passive: false }); | |
| function assignGroup(digit) { | |
| groups()[digit] = [...state.selection]; | |
| } | |
| // Recall a bound group; a SECOND press of the same digit within DOUBLE_GROUP_MS also | |
| // recenters the camera on the group's centroid (the standard "tap to select, tap again to | |
| // jump to them"). Selecting a live-only subset (dead ids filtered) as before. | |
| function recallGroup(digit) { | |
| const ids = alivePlayerUnitIds(groups()[digit] || []); | |
| if (!ids.length) return; | |
| state.selection = ids; | |
| const now = performance.now(); | |
| if (digit === lastGroupDigit && now - lastGroupAt < DOUBLE_GROUP_MS) { | |
| const us = ids.map(id => state.units.get(id)).filter(Boolean); | |
| if (us.length) centerCamera(us.reduce((s, u) => s + u.x, 0) / us.length, us.reduce((s, u) => s + u.y, 0) / us.length); | |
| } | |
| lastGroupDigit = digit; lastGroupAt = now; | |
| } | |
| function stopSelected() { | |
| const ids = selectedUnits().map(u => u.id); | |
| if (ids.length) transport.submitCommand({ t: "stop", ids }); | |
| } | |
| // Send every selected Ranger off to chart the map on its own (see scout.js). | |
| // A no-op if nothing scout-role is selected. | |
| function scoutSelected() { | |
| const ids = selectedUnits().map(u => u.id); | |
| if (ids.length) transport.submitCommand({ t: "scout", ids }); | |
| } | |
| // Put selected combat units into the Hold-position stance. | |
| function holdSelected() { | |
| const ids = selectedUnits().map(u => u.id); | |
| if (ids.length) transport.submitCommand({ t: "hold", ids }); | |
| } | |
| // R: convert the selection's EXISTING move/attack-move waypoint chain into a looping patrol | |
| // (engine/commands.js issuePatrol) β the same points already laid down by right-click / | |
| // Ctrl+right-click keep going instead of running out at the last stop. Reads each unit's own | |
| // order + orderQueue independently (a mixed selection can be at a different point in its own | |
| // chain), so this is a conversion of what's already there, not a fresh command with its own | |
| // destination. Only move/attack-move legs carry a map point to patrol between β anything else | |
| // queued (an attack on a specific target, a gather, β¦) isn't a waypoint and is skipped, so a | |
| // unit with none of those queued just keeps doing whatever it's doing: nothing to loop. | |
| // issuePatrol itself re-applies the combat/scout role gate, so a worker caught in the | |
| // selection is silently skipped here too. | |
| function patrolSelected() { | |
| for (const u of selectedUnits()) { | |
| const points = [u.order, ...(u.orderQueue || [])] | |
| .filter(o => o && (o.type === "move" || o.type === "attack-move")) | |
| .map(o => ({ x: o.x, y: o.y })); | |
| if (points.length) transport.submitCommand({ t: "patrol", ids: [u.id], pts: points }); | |
| } | |
| } | |
| // Form up right where the selection stands, in the player's chosen shape, and hold there β | |
| // the "protect a formation" stance (engine/commands.js issueHoldFormation), the group-scale | |
| // sibling of Escort (which protects one external ship instead). | |
| function formSelected() { | |
| const ids = selectedUnits().map(u => u.id); | |
| if (ids.length) transport.submitCommand({ t: "holdFormation", ids, s: game.formation.shape, l: game.formation.leaderPos }); | |
| } | |
| function selectAllArmy() { | |
| state.selection = [...state.units.values()] | |
| .filter(u => u.owner === "player" && UNITS[u.type].role === "combat") | |
| .map(u => u.id); | |
| } | |
| // Cycle to the next worker of yours that's sitting idle (no order, no queued | |
| // waypoints) β selecting it and centering the camera on it, so a stalled | |
| // gatherer on a big map is one keypress away instead of a manual hunt. | |
| function focusIdleWorker() { | |
| const idle = [...state.units.values()].filter(u => | |
| u.owner === "player" && UNITS[u.type]?.role === "worker" && !u.order && (!u.orderQueue || !u.orderQueue.length)); | |
| if (!idle.length) return; | |
| const w = idle[idleCycle % idle.length]; | |
| idleCycle++; | |
| state.selection = [w.id]; | |
| centerCamera(w.x, w.y); | |
| onChange(); | |
| } | |
| // The building-scale sibling of focusIdleWorker above: cycle to the next completed player | |
| // production building (BUILDINGS[type].produces) sitting idle β an empty queue, not | |
| // constructing β selecting it and centering the camera on it. Multiple Barracks are normal | |
| // mid-game, and an empty queue on one you're not currently looking at is otherwise invisible. | |
| // Wired to the topbar's "N idle" production chip (main.js) the same way focusIdleWorker is | |
| // wired to the idle-workers chip. | |
| function focusIdleProducer() { | |
| const idle = [...state.buildings.values()].filter(b => | |
| b.owner === "player" && !b.constructing && BUILDINGS[b.type]?.produces && b.queue.length === 0); | |
| if (!idle.length) return; | |
| const b = idle[idleProducerCycle % idle.length]; | |
| idleProducerCycle++; | |
| state.selection = [b.id]; | |
| centerCamera(b.x, b.y); | |
| onChange(); | |
| } | |
| // Snap the camera to a base β a completed Command Center, else the landing zone. The macro | |
| // "get me home" key (Space), so a raid on your economy is one press away. A .find() used to | |
| // always land on the SAME (first-found) Command Center once an expansion existed; repeated | |
| // presses within DOUBLE_GROUP_MS (the same double-press timing recallGroup already uses for | |
| // its own centroid re-center) now cycle through every completed CC in turn, sorted by id for a | |
| // stable order β matching engine/galaxy.js's own id-sorted playerSpaceports precedent. | |
| function centerOnBase() { | |
| const ccs = [...state.buildings.values()] | |
| .filter(b => b.owner === "player" && b.type === "command" && !b.constructing) | |
| .sort((a, b) => (a.id < b.id ? -1 : 1)); | |
| const now = performance.now(); | |
| const cycling = ccs.length > 0 && now - lastBaseAt < DOUBLE_GROUP_MS; | |
| lastBaseAt = now; | |
| if (!ccs.length) { | |
| baseCycle = 0; | |
| const at = state.map.bases && state.map.bases.player; | |
| if (at) { centerCamera(at.x, at.y); onChange(); } | |
| return; | |
| } | |
| baseCycle = cycling ? (baseCycle + 1) % ccs.length : 0; | |
| const at = ccs[baseCycle]; | |
| centerCamera(at.x, at.y); | |
| onChange(); | |
| } | |
| // Backspace: the standard RTS "jump to last alert" β recenter on wherever the most recent | |
| // under-attack ping fired (game.lastAttackAt, session.js β written by boot.js's frame-event | |
| // pump next to supplyBlockedUntil). A no-op before the game's first alert. | |
| function jumpToLastAttack() { | |
| if (!game.lastAttackAt) return; | |
| centerCamera(game.lastAttackAt.x, game.lastAttackAt.y); | |
| onChange(); | |
| } | |
| window.addEventListener("keydown", e => { | |
| const k = e.key.toLowerCase(); | |
| heldKeys.add(k); // still feeds the WASD/arrow camera pan (kept above the focus guard) | |
| if (e.repeat) return; | |
| // Don't hijack keys meant for a FOCUSED control (a HUD button, the Home-confirm modal's | |
| // buttons, a text input): Space would otherwise e.preventDefault() the button's own | |
| // activation, and a letter would fire a game command instead of reaching the control. | |
| // During normal play focus sits on <body>/the canvas, so every hotkey still works. | |
| const t = e.target; | |
| if (t && t !== document.body && t !== canvas && typeof t.closest === "function" | |
| && t.closest("button, input, textarea, select, [tabindex]")) return; | |
| // O toggles Observer Mode from either side (a no-op outside an Odyssey or a WATCHED AI-vs-AI | |
| // match β see observer.js's enterObserverMode, which refuses an ordinary skirmish because | |
| // revealing its fog would just be a cheat), so it must be checked before the "swallow | |
| // everything else while observing" branch right below, or there'd be no way back out via the | |
| // keyboard. | |
| if (k === "o") { toggleObserverMode(); onChange(); return; } | |
| if (game.observerMode) { | |
| // Space repurposes to cycling every base on the spectated world (any owner) instead of | |
| // just the player's own; every other order/build/group hotkey below assumes `state` is | |
| // what's on screen, which isn't true while spectating a different world β swallow them. | |
| if (k === " ") { e.preventDefault(); cycleObserverBase(); onChange(); return; } | |
| // requestExitObserverMode, not the raw exit: a WATCHED match refuses it (see that function's | |
| // own comment β there's no seat to hand back to), the Odyssey behaves exactly as before. | |
| if (k === "escape") { requestExitObserverMode(); onChange(); return; } | |
| return; | |
| } | |
| // Match on e.code, not e.key: with Shift held the number row's e.key becomes | |
| // "!@#β¦", so only the physical Digit1β9 code is reliable for the bind case. | |
| const digit = /^Digit([1-9])$/.exec(e.code); | |
| if (digit) { | |
| // Shift+digit binds the current selection to a control group; a plain | |
| // digit recalls it. (Ctrl+digit is the browser's own tab-switch shortcut | |
| // and can't be reliably suppressed, so Shift is the bind modifier here.) | |
| if (e.shiftKey) assignGroup(digit[1]); | |
| else recallGroup(digit[1]); | |
| onChange(); | |
| return; | |
| } | |
| if (k === "x") { stopSelected(); onChange(); return; } | |
| if (k === "q") { selectAllArmy(); onChange(); return; } | |
| if (k === "e") { scoutSelected(); onChange(); return; } // send selected Rangers to auto-scout | |
| if (k === "a") { setArmed(true); return; } // arm attack-move; next left-click commits it | |
| if (k === "h") { holdSelected(); onChange(); return; } // hold position | |
| if (k === "r") { patrolSelected(); onChange(); return; } // convert the current waypoint chain into a looping patrol | |
| if (k === "f") { formSelected(); onChange(); return; } // form up in place and hold the shape | |
| if (k === "escape") { setArmed(false); buildMode = null; onChange(); return; } // bail out of a pending action | |
| if (k === "`") { focusIdleWorker(); return; } // it calls onChange itself | |
| if (k === " ") { e.preventDefault(); centerOnBase(); return; } // Space β jump the camera to your base (repeat: cycle bases) | |
| if (k === "backspace") { e.preventDefault(); jumpToLastAttack(); return; } // Backspace β jump to the last under-attack alert | |
| // Positional production/build hotkeys (Z/C/V/B/N): fire the Nth produce/build button the | |
| // HUD is currently showing (game.hotkeyActions, set by hud.js). click() replays the real | |
| // button handler, so orders flow through the same queueProduction/startBuild path β the | |
| // skirmish sim/AI is byte-identical, this is a pure UI shortcut. | |
| const action = game.hotkeyActions && game.hotkeyActions.find(a => a.key === k); | |
| if (action) { action.click(); return; } | |
| }, { signal }); | |
| window.addEventListener("keyup", e => heldKeys.delete(e.key.toLowerCase()), { signal }); | |
| // Browsers don't reliably deliver keyup to a backgrounded tab, so alt-tabbing away while | |
| // holding a pan key would otherwise leave it stuck "held" -- the camera keeps drifting after | |
| // you tab back in until you tap that key again. Same hazard the touchcancel handler guards | |
| // against for touch; this is the keyboard equivalent. | |
| window.addEventListener("blur", () => heldKeys.clear(), { signal }); | |
| // Only exits build mode on an actual successful placement -- an | |
| // invalid spot (see engine/colliders.js) or no eligible worker just | |
| // leaves the ghost up so the player can click again without having to | |
| // re-open the build menu. The ghost itself (drawBuildGhost in | |
| // render.js) already shows red/green before they even click. | |
| // | |
| // The one call site in this file that genuinely needs the CommandResult, not just fire-and- | |
| // forget (TASKS.md T-013's own flag): buildMode must stay armed on a rejection so the player | |
| // can click again, so it can only be cleared once transport.submitCommand's promise actually | |
| // resolves ok β never optimistically before that. Under loopback/directTransport the | |
| // mutation (and so the result) is ready before the promise's .then() microtask even runs, so | |
| // this resolves within the same frame exactly as the old synchronous check did; a real network | |
| // transport (Phase 3) just makes that gap real, which is exactly what this await-shaped code | |
| // already tolerates and T-013 will exercise on purpose. | |
| function placeBuildingAt(p) { | |
| const buildingType = buildMode.buildingType; | |
| const worker = state.selection.map(id => state.units.get(id)) | |
| .find(u => u && canBuildCategory(u.type, BUILDINGS[buildingType]?.category)); | |
| if (!worker) { sound.playProductionBlocked(); onChange(); return; } | |
| transport.submitCommand({ t: "build", worker: worker.id, b: buildingType, x: p.x, y: p.y }).then(result => { | |
| if (result.ok) buildMode = null; | |
| else sound.playProductionBlocked(); // rejected (invalid spot) β audibly denied, not silent | |
| onChange(); | |
| }); | |
| } | |
| return { | |
| getDragBox: () => dragBox, | |
| startBuild(buildingType) { buildMode = { buildingType }; }, | |
| get building() { return buildMode; }, | |
| getBuildGhost() { return buildMode ? { buildingType: buildMode.buildingType, x: lastWorldPos.x, y: lastWorldPos.y } : null; }, | |
| getCamera: () => camera, | |
| focusIdleWorker, | |
| // The topbar's "N idle" production chip (main.js) clicks this, mirroring idleWorkersEl's own | |
| // wiring to focusIdleWorker above. | |
| focusIdleProducer, | |
| selectAllArmy: () => { selectAllArmy(); onChange(); }, | |
| // Narrow the current selection to one unit type β the HUD's aggregated type rows | |
| // are clickable (hud.js), so clicking "12Γ Skiff" keeps only the Skiffs. | |
| selectType(type) { | |
| const ids = state.selection | |
| .map(id => state.units.get(id)).filter(u => u && u.type === type).map(u => u.id); | |
| if (ids.length) { state.selection = ids; sound.playSelect(); onChange(); } | |
| }, | |
| stopSelected: () => { stopSelected(); onChange(); }, | |
| scoutSelected: () => { scoutSelected(); onChange(); }, | |
| holdSelected: () => { holdSelected(); onChange(); }, | |
| patrolSelected: () => { patrolSelected(); onChange(); }, | |
| formSelected: () => { formSelected(); onChange(); }, | |
| // Recall a group from the HUD chip (touch has no number row); same double-press-recenter | |
| // as the keyboard path. | |
| recallGroup: digit => { recallGroup(digit); onChange(); }, | |
| // Live per-group counts for the HUD chip row: [{ digit, count }] over bound groups with | |
| // at least one surviving unit, in digit order. | |
| groupCounts() { | |
| const g = groups(); | |
| return Object.keys(g).sort() | |
| .map(d => ({ digit: d, count: alivePlayerUnitIds(g[d]).length })) | |
| .filter(e => e.count > 0); | |
| }, | |
| // Attack-move as a HUD button (touch has no A key): toggle it, cancel it, and | |
| // read the armed state so the button can show as active. | |
| toggleAttackMove: () => { setArmed(!attackMoveArmed); onChange(); }, | |
| get attackArmed() { return attackMoveArmed; }, | |
| tickCamera(dt) { | |
| // Observer Mode pans its OWN camera off the same held-key set (see observer.js's | |
| // tickObserverCamera) β the real camera/edge-pan below must stay frozen meanwhile, or | |
| // resuming normal play would find the view had silently drifted while you were spectating. | |
| if (game.observerMode) { tickObserverCamera(dt, heldKeys); return; } | |
| let dx = edgePan[0], dy = edgePan[1]; | |
| for (const key of heldKeys) { | |
| const dir = PAN_KEYS[key]; | |
| if (dir) { dx += dir[0]; dy += dir[1]; } | |
| } | |
| const { vw, vh } = viewport(); | |
| panCamera(camera, state.map, vw, vh, dx, dy, dt); | |
| }, | |
| // See cancelGesture above: call this before showing any modal that pauses the loop and | |
| // covers the canvas (a landing pick, the starmap), so a drag armed before it opened can't | |
| // resolve later against the world hidden behind it. | |
| cancelGesture, | |
| // Removes every listener this call added β without this, starting a | |
| // new game (map picker -> startGame again) would stack a second, | |
| // third, ... full set of handlers on the same canvas/window, each | |
| // reacting to one real click with a growing pile of stale ones. | |
| destroy() { controller.abort(); }, | |
| }; | |
| } | |