SpaceCities / input.js
Claude
Claude Sonnet 5
T-012: port the single-player client onto session + loopback
42b40d2 unverified
Raw History Blame Contribute Delete
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).
============================================================ */
"use strict";
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(); },
};
}