Spaces:
Paused
Tier 7: bring controls & UX up to RTS standard
Browse filesControls (input.js / commands.js):
- Control groups: Shift+1–9 bind, 1–9 recall (Ctrl+digit is the browser's own
tab-switch shortcut, so Shift is the conflict-free bind modifier; matched on
physical key code so it works with Shift held).
- Stop (X) drops orders + queued waypoints; Select Army (Q); Idle Worker (`)
cycles to and centres on the next idle gatherer.
- Ctrl+drag/click adds to the current selection; double-click selects every
on-screen unit of that type.
- Edge scroll: cursor at a canvas edge pans the camera.
- Right-click the minimap to order the selection there (move / attack-move)
without scrolling the main view first.
HUD & readability (main.js / render.js / style.css):
- Unaffordable produce/build/research buttons grey out and play the denied
buzz on click instead of failing silently; buttons carry stat tooltips.
- Selected units/buildings keep their health bar even at full HP, and the bar
now has three colour bands (green/amber/red) rather than a green/red flip.
- A controls legend and Idle Worker / Select Army buttons in the empty panel.
Verified in-browser: full control-key sweep throws nothing, and group
bind/recall round-trips a selection. 189 tests still pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEQUVetLk31Jj7niDQAsXL
- engine/commands.js +8 -0
- input.js +104 -6
- main.js +77 -14
- render.js +11 -5
- style.css +9 -0
|
@@ -88,6 +88,14 @@ export function issueAssistBuild(units, buildingId, queue = false) {
|
|
| 88 |
units.forEach(u => { if (u.cargo) dispatch(u, { type: "build", buildingId }, queue); });
|
| 89 |
}
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
// Every unit the building produces from now on walks to this point
|
| 92 |
// instead of wherever the old rally was — production.js reads
|
| 93 |
// building.rally fresh at spawn time, so this takes effect immediately
|
|
|
|
| 88 |
units.forEach(u => { if (u.cargo) dispatch(u, { type: "build", buildingId }, queue); });
|
| 89 |
}
|
| 90 |
|
| 91 |
+
// Halt: drop the active order and any queued waypoints so the unit stops
|
| 92 |
+
// where it stands. A combat unit still defends itself (it re-auto-acquires an
|
| 93 |
+
// enemy that wanders into range next tick), but it won't chase or resume a
|
| 94 |
+
// path — the standard RTS "stop" that pulls a unit out of a move or a chain.
|
| 95 |
+
export function issueStop(units) {
|
| 96 |
+
units.forEach(u => { u.order = null; u.orderQueue = []; });
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
// Every unit the building produces from now on walks to this point
|
| 100 |
// instead of wherever the old rally was — production.js reads
|
| 101 |
// building.rally fresh at spawn time, so this takes effect immediately
|
|
@@ -8,15 +8,16 @@
|
|
| 8 |
|
| 9 |
"use strict";
|
| 10 |
|
| 11 |
-
import { issueMove, issueGather, issueAttack, issueAttackMove, issueBuild, issueAssistBuild, issueSetRally } from "./engine/commands.js";
|
| 12 |
import { UNITS, BUILDINGS } from "./engine/entities.js";
|
| 13 |
import { isVisibleAt, isNodeDiscovered } from "./engine/fog.js";
|
| 14 |
-
import { createCamera, screenToWorld, zoomAt, panCamera } from "./camera.js";
|
| 15 |
|
| 16 |
const CLICK_THRESHOLD = 4;
|
| 17 |
const UNIT_PICK_RADIUS = 10;
|
| 18 |
const NODE_PICK_RADIUS = 14;
|
| 19 |
const ZOOM_STEP = 1.12;
|
|
|
|
| 20 |
const PAN_KEYS = {
|
| 21 |
arrowleft: [-1, 0], a: [-1, 0],
|
| 22 |
arrowright: [1, 0], d: [1, 0],
|
|
@@ -33,6 +34,9 @@ export function attachInput(canvas, state, onChange) {
|
|
| 33 |
let dragBox = null;
|
| 34 |
let buildMode = null;
|
| 35 |
let lastWorldPos = { x: state.map.width / 2, y: state.map.height / 2 };
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
function viewport() {
|
| 38 |
return { vw: canvas.clientWidth, vh: canvas.clientHeight };
|
|
@@ -70,26 +74,61 @@ export function attachInput(canvas, state, onChange) {
|
|
| 70 |
|
| 71 |
canvas.addEventListener("mousemove", e => {
|
| 72 |
lastWorldPos = toWorld(e.clientX, e.clientY); // tracked continuously for the build-placement ghost
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
if (!dragBox) return;
|
| 74 |
dragBox.x2 = lastWorldPos.x;
|
| 75 |
dragBox.y2 = lastWorldPos.y;
|
| 76 |
}, { signal });
|
|
|
|
| 77 |
|
| 78 |
window.addEventListener("mouseup", e => {
|
| 79 |
if (e.button !== 0 || !dragBox) return;
|
| 80 |
const box = dragBox;
|
| 81 |
dragBox = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
const dx = Math.abs(box.x2 - box.x1), dy = Math.abs(box.y2 - box.y1);
|
|
|
|
| 83 |
if (dx < CLICK_THRESHOLD && dy < CLICK_THRESHOLD) {
|
| 84 |
const hit = entityAt(box.x1, box.y1);
|
| 85 |
-
|
| 86 |
} else {
|
| 87 |
const x1 = Math.min(box.x1, box.x2), x2 = Math.max(box.x1, box.x2);
|
| 88 |
const y1 = Math.min(box.y1, box.y2), y2 = Math.max(box.y1, box.y2);
|
| 89 |
-
|
| 90 |
.filter(u => u.owner === "player" && u.x >= x1 && u.x <= x2 && u.y >= y1 && u.y <= y2)
|
| 91 |
.map(u => u.id);
|
| 92 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
onChange();
|
| 94 |
}, { signal });
|
| 95 |
|
|
@@ -164,7 +203,63 @@ export function attachInput(canvas, state, onChange) {
|
|
| 164 |
zoomAt(camera, state.map, vw, vh, e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP);
|
| 165 |
}, { signal, passive: false });
|
| 166 |
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
window.addEventListener("keyup", e => heldKeys.delete(e.key.toLowerCase()), { signal });
|
| 169 |
|
| 170 |
// Only exits build mode on an actual successful placement -- an
|
|
@@ -186,8 +281,11 @@ export function attachInput(canvas, state, onChange) {
|
|
| 186 |
get building() { return buildMode; },
|
| 187 |
getBuildGhost() { return buildMode ? { buildingType: buildMode.buildingType, x: lastWorldPos.x, y: lastWorldPos.y } : null; },
|
| 188 |
getCamera: () => camera,
|
|
|
|
|
|
|
|
|
|
| 189 |
tickCamera(dt) {
|
| 190 |
-
let dx = 0, dy =
|
| 191 |
for (const key of heldKeys) {
|
| 192 |
const dir = PAN_KEYS[key];
|
| 193 |
if (dir) { dx += dir[0]; dy += dir[1]; }
|
|
|
|
| 8 |
|
| 9 |
"use strict";
|
| 10 |
|
| 11 |
+
import { issueMove, issueGather, issueAttack, issueAttackMove, issueBuild, issueAssistBuild, issueSetRally, issueStop } from "./engine/commands.js";
|
| 12 |
import { UNITS, BUILDINGS } from "./engine/entities.js";
|
| 13 |
import { isVisibleAt, isNodeDiscovered } from "./engine/fog.js";
|
| 14 |
+
import { createCamera, screenToWorld, zoomAt, panCamera, clampCamera } from "./camera.js";
|
| 15 |
|
| 16 |
const CLICK_THRESHOLD = 4;
|
| 17 |
const UNIT_PICK_RADIUS = 10;
|
| 18 |
const NODE_PICK_RADIUS = 14;
|
| 19 |
const ZOOM_STEP = 1.12;
|
| 20 |
+
const EDGE_SCROLL_MARGIN = 20; // px from a canvas edge that starts scrolling the camera
|
| 21 |
const PAN_KEYS = {
|
| 22 |
arrowleft: [-1, 0], a: [-1, 0],
|
| 23 |
arrowright: [1, 0], d: [1, 0],
|
|
|
|
| 34 |
let dragBox = null;
|
| 35 |
let buildMode = null;
|
| 36 |
let lastWorldPos = { x: state.map.width / 2, y: state.map.height / 2 };
|
| 37 |
+
const groups = new Map(); // control groups: digit -> [unit ids]
|
| 38 |
+
let idleCycle = 0; // round-robins through idle workers on repeated presses
|
| 39 |
+
let edgePan = [0, 0]; // camera nudge from the cursor sitting at a screen edge
|
| 40 |
|
| 41 |
function viewport() {
|
| 42 |
return { vw: canvas.clientWidth, vh: canvas.clientHeight };
|
|
|
|
| 74 |
|
| 75 |
canvas.addEventListener("mousemove", e => {
|
| 76 |
lastWorldPos = toWorld(e.clientX, e.clientY); // tracked continuously for the build-placement ghost
|
| 77 |
+
// Edge scroll: cursor within a margin of a canvas edge nudges the camera
|
| 78 |
+
// that way, so you can drag the view without touching the keyboard.
|
| 79 |
+
const rect = canvas.getBoundingClientRect();
|
| 80 |
+
const lx = e.clientX - rect.left, ly = e.clientY - rect.top;
|
| 81 |
+
edgePan = [
|
| 82 |
+
lx < EDGE_SCROLL_MARGIN ? -1 : lx > rect.width - EDGE_SCROLL_MARGIN ? 1 : 0,
|
| 83 |
+
ly < EDGE_SCROLL_MARGIN ? -1 : ly > rect.height - EDGE_SCROLL_MARGIN ? 1 : 0,
|
| 84 |
+
];
|
| 85 |
if (!dragBox) return;
|
| 86 |
dragBox.x2 = lastWorldPos.x;
|
| 87 |
dragBox.y2 = lastWorldPos.y;
|
| 88 |
}, { signal });
|
| 89 |
+
canvas.addEventListener("mouseleave", () => { edgePan = [0, 0]; }, { signal });
|
| 90 |
|
| 91 |
window.addEventListener("mouseup", e => {
|
| 92 |
if (e.button !== 0 || !dragBox) return;
|
| 93 |
const box = dragBox;
|
| 94 |
dragBox = null;
|
| 95 |
+
// Ctrl (the game's modifier — see the waypoint note below) adds to the
|
| 96 |
+
// current selection instead of replacing it, so you can pull several groups
|
| 97 |
+
// together. An empty additive click leaves the selection untouched.
|
| 98 |
+
const additive = e.ctrlKey;
|
| 99 |
const dx = Math.abs(box.x2 - box.x1), dy = Math.abs(box.y2 - box.y1);
|
| 100 |
+
let picks;
|
| 101 |
if (dx < CLICK_THRESHOLD && dy < CLICK_THRESHOLD) {
|
| 102 |
const hit = entityAt(box.x1, box.y1);
|
| 103 |
+
picks = hit && hit.owner === "player" ? [hit.id] : [];
|
| 104 |
} else {
|
| 105 |
const x1 = Math.min(box.x1, box.x2), x2 = Math.max(box.x1, box.x2);
|
| 106 |
const y1 = Math.min(box.y1, box.y2), y2 = Math.max(box.y1, box.y2);
|
| 107 |
+
picks = [...state.units.values()]
|
| 108 |
.filter(u => u.owner === "player" && u.x >= x1 && u.x <= x2 && u.y >= y1 && u.y <= y2)
|
| 109 |
.map(u => u.id);
|
| 110 |
}
|
| 111 |
+
if (additive) {
|
| 112 |
+
if (picks.length) state.selection = [...new Set([...state.selection, ...picks])];
|
| 113 |
+
} else {
|
| 114 |
+
state.selection = picks;
|
| 115 |
+
}
|
| 116 |
+
onChange();
|
| 117 |
+
}, { signal });
|
| 118 |
+
|
| 119 |
+
// Double-click a unit to grab every same-type unit of yours currently on
|
| 120 |
+
// screen — the standard "select all of this type" gesture.
|
| 121 |
+
canvas.addEventListener("dblclick", e => {
|
| 122 |
+
const p = toWorld(e.clientX, e.clientY);
|
| 123 |
+
const hit = entityAt(p.x, p.y);
|
| 124 |
+
if (!hit || hit.owner !== "player" || hit.kind !== "unit") return;
|
| 125 |
+
const { vw, vh } = viewport();
|
| 126 |
+
const tl = screenToWorld(camera, vw, vh, 0, 0);
|
| 127 |
+
const br = screenToWorld(camera, vw, vh, vw, vh);
|
| 128 |
+
state.selection = [...state.units.values()]
|
| 129 |
+
.filter(u => u.owner === "player" && u.type === hit.type
|
| 130 |
+
&& u.x >= tl.x && u.x <= br.x && u.y >= tl.y && u.y <= br.y)
|
| 131 |
+
.map(u => u.id);
|
| 132 |
onChange();
|
| 133 |
}, { signal });
|
| 134 |
|
|
|
|
| 203 |
zoomAt(camera, state.map, vw, vh, e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP);
|
| 204 |
}, { signal, passive: false });
|
| 205 |
|
| 206 |
+
function alivePlayerUnitIds(ids) {
|
| 207 |
+
return ids.filter(id => { const u = state.units.get(id); return u && u.owner === "player"; });
|
| 208 |
+
}
|
| 209 |
+
function selectedUnits() {
|
| 210 |
+
return state.selection.map(id => state.units.get(id)).filter(Boolean);
|
| 211 |
+
}
|
| 212 |
+
function centerCamera(x, y) {
|
| 213 |
+
const { vw, vh } = viewport();
|
| 214 |
+
camera.x = x; camera.y = y;
|
| 215 |
+
clampCamera(camera, state.map, vw, vh);
|
| 216 |
+
}
|
| 217 |
+
function recallGroup(digit) {
|
| 218 |
+
const ids = alivePlayerUnitIds(groups.get(digit) || []);
|
| 219 |
+
if (ids.length) state.selection = ids;
|
| 220 |
+
}
|
| 221 |
+
function stopSelected() {
|
| 222 |
+
issueStop(selectedUnits());
|
| 223 |
+
}
|
| 224 |
+
function selectAllArmy() {
|
| 225 |
+
state.selection = [...state.units.values()]
|
| 226 |
+
.filter(u => u.owner === "player" && UNITS[u.type].role === "combat")
|
| 227 |
+
.map(u => u.id);
|
| 228 |
+
}
|
| 229 |
+
// Cycle to the next worker of yours that's sitting idle (no order, no queued
|
| 230 |
+
// waypoints) — selecting it and centering the camera on it, so a stalled
|
| 231 |
+
// gatherer on a big map is one keypress away instead of a manual hunt.
|
| 232 |
+
function focusIdleWorker() {
|
| 233 |
+
const idle = [...state.units.values()].filter(u =>
|
| 234 |
+
u.owner === "player" && u.type === "worker" && !u.order && (!u.orderQueue || !u.orderQueue.length));
|
| 235 |
+
if (!idle.length) return;
|
| 236 |
+
const w = idle[idleCycle % idle.length];
|
| 237 |
+
idleCycle++;
|
| 238 |
+
state.selection = [w.id];
|
| 239 |
+
centerCamera(w.x, w.y);
|
| 240 |
+
onChange();
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
window.addEventListener("keydown", e => {
|
| 244 |
+
const k = e.key.toLowerCase();
|
| 245 |
+
heldKeys.add(k); // still feeds the WASD/arrow camera pan
|
| 246 |
+
if (e.repeat) return;
|
| 247 |
+
// Match on e.code, not e.key: with Shift held the number row's e.key becomes
|
| 248 |
+
// "!@#…", so only the physical Digit1–9 code is reliable for the bind case.
|
| 249 |
+
const digit = /^Digit([1-9])$/.exec(e.code);
|
| 250 |
+
if (digit) {
|
| 251 |
+
// Shift+digit binds the current selection to a control group; a plain
|
| 252 |
+
// digit recalls it. (Ctrl+digit is the browser's own tab-switch shortcut
|
| 253 |
+
// and can't be reliably suppressed, so Shift is the bind modifier here.)
|
| 254 |
+
if (e.shiftKey) groups.set(digit[1], [...state.selection]);
|
| 255 |
+
else recallGroup(digit[1]);
|
| 256 |
+
onChange();
|
| 257 |
+
return;
|
| 258 |
+
}
|
| 259 |
+
if (k === "x") { stopSelected(); onChange(); return; }
|
| 260 |
+
if (k === "q") { selectAllArmy(); onChange(); return; }
|
| 261 |
+
if (k === "`") { focusIdleWorker(); return; } // it calls onChange itself
|
| 262 |
+
}, { signal });
|
| 263 |
window.addEventListener("keyup", e => heldKeys.delete(e.key.toLowerCase()), { signal });
|
| 264 |
|
| 265 |
// Only exits build mode on an actual successful placement -- an
|
|
|
|
| 281 |
get building() { return buildMode; },
|
| 282 |
getBuildGhost() { return buildMode ? { buildingType: buildMode.buildingType, x: lastWorldPos.x, y: lastWorldPos.y } : null; },
|
| 283 |
getCamera: () => camera,
|
| 284 |
+
focusIdleWorker,
|
| 285 |
+
selectAllArmy: () => { selectAllArmy(); onChange(); },
|
| 286 |
+
stopSelected: () => { stopSelected(); onChange(); },
|
| 287 |
tickCamera(dt) {
|
| 288 |
+
let dx = edgePan[0], dy = edgePan[1];
|
| 289 |
for (const key of heldKeys) {
|
| 290 |
const dir = PAN_KEYS[key];
|
| 291 |
if (dir) { dx += dir[0]; dy += dir[1]; }
|
|
@@ -10,8 +10,9 @@ import { createGameState } from "./engine/state.js";
|
|
| 10 |
import { createLoop } from "./engine/loop.js";
|
| 11 |
import { tick } from "./engine/sim.js";
|
| 12 |
import { queueProduction, cancelProduction, researchUpgrade } from "./engine/production.js";
|
|
|
|
| 13 |
import { supplyUsed, supplyCap } from "./engine/supply.js";
|
| 14 |
-
import { BUILDINGS, UNITS, UPGRADES } from "./engine/entities.js";
|
| 15 |
import { archetypeFor, PLANET_ARCHETYPE } from "./engine/aiArchetypes.js";
|
| 16 |
import { PLANET_MODIFIERS } from "./engine/map.js";
|
| 17 |
import { PLANETS } from "./data.js";
|
|
@@ -93,6 +94,22 @@ minimapCanvas.addEventListener("click", e => {
|
|
| 93 |
clampCamera(camera, state.map, canvas.clientWidth, canvas.clientHeight);
|
| 94 |
});
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
// Splash-screen game setup, carried across "choose another battlefield"
|
| 97 |
// restarts. sizeMult scales the map (map.js); resourceMult scales every
|
| 98 |
// deposit's amount; aiApm caps the opponent's actions per minute (ai.js).
|
|
@@ -449,6 +466,9 @@ function rebuildSelectionPanel(sel) {
|
|
| 449 |
hint.className = "hint";
|
| 450 |
hint.textContent = "Drag-select units, or click a building.";
|
| 451 |
panelEl.appendChild(hint);
|
|
|
|
|
|
|
|
|
|
| 452 |
return;
|
| 453 |
}
|
| 454 |
|
|
@@ -473,16 +493,17 @@ function rebuildSelectionPanel(sel) {
|
|
| 473 |
|
| 474 |
const cc = sel.find(e => e.kind === "building" && e.type === "command" && !e.constructing);
|
| 475 |
if (cc) {
|
| 476 |
-
panelEl.appendChild(makeButton(`Produce Worker (${UNITS.worker.cost.ore} ore)`,
|
|
|
|
| 477 |
if (cc.queue.length) renderQueueRows(cc);
|
| 478 |
}
|
| 479 |
|
| 480 |
const barracks = sel.find(e => e.kind === "building" && e.type === "barracks" && !e.constructing);
|
| 481 |
if (barracks) {
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
if (barracks.queue.length) renderQueueRows(barracks);
|
| 487 |
}
|
| 488 |
|
|
@@ -496,18 +517,22 @@ function rebuildSelectionPanel(sel) {
|
|
| 496 |
row.textContent = `${u.name} — researched`;
|
| 497 |
panelEl.appendChild(row);
|
| 498 |
} else {
|
| 499 |
-
panelEl.appendChild(makeButton(`Research ${u.name} (${costText(u.cost)})`,
|
|
|
|
| 500 |
}
|
| 501 |
});
|
| 502 |
}
|
| 503 |
|
| 504 |
const worker = sel.find(e => e.kind === "unit" && e.type === "worker");
|
| 505 |
if (worker && !input.building) {
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
|
|
|
|
|
|
|
|
|
| 511 |
}
|
| 512 |
|
| 513 |
if (input.building) {
|
|
@@ -534,14 +559,52 @@ function costText(cost) {
|
|
| 534 |
return Object.entries(cost).map(([com, qty]) => `${qty} ${com}`).join(", ");
|
| 535 |
}
|
| 536 |
|
| 537 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
const btn = document.createElement("button");
|
| 539 |
btn.className = "btn";
|
| 540 |
btn.textContent = label;
|
| 541 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
return btn;
|
| 543 |
}
|
| 544 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
function showGameOver(winner) {
|
| 546 |
if (winner === "player") sound.playVictory(); else sound.playDefeat();
|
| 547 |
|
|
|
|
| 10 |
import { createLoop } from "./engine/loop.js";
|
| 11 |
import { tick } from "./engine/sim.js";
|
| 12 |
import { queueProduction, cancelProduction, researchUpgrade } from "./engine/production.js";
|
| 13 |
+
import { issueMove, issueAttackMove } from "./engine/commands.js";
|
| 14 |
import { supplyUsed, supplyCap } from "./engine/supply.js";
|
| 15 |
+
import { BUILDINGS, UNITS, UPGRADES, canAfford } from "./engine/entities.js";
|
| 16 |
import { archetypeFor, PLANET_ARCHETYPE } from "./engine/aiArchetypes.js";
|
| 17 |
import { PLANET_MODIFIERS } from "./engine/map.js";
|
| 18 |
import { PLANETS } from "./data.js";
|
|
|
|
| 94 |
clampCamera(camera, state.map, canvas.clientWidth, canvas.clientHeight);
|
| 95 |
});
|
| 96 |
|
| 97 |
+
// Right-click the minimap to command the current selection to that spot without
|
| 98 |
+
// scrolling the main view first — move for workers, attack-move for combat, so
|
| 99 |
+
// you can respond to a raid on a far flank straight from the map.
|
| 100 |
+
minimapCanvas.addEventListener("contextmenu", e => {
|
| 101 |
+
e.preventDefault();
|
| 102 |
+
if (!state || !input) return;
|
| 103 |
+
const rect = minimapCanvas.getBoundingClientRect();
|
| 104 |
+
const world = minimapToWorld(state.map, MINIMAP_W, MINIMAP_H, e.clientX - rect.left, e.clientY - rect.top);
|
| 105 |
+
const selected = state.selection.map(id => state.units.get(id)).filter(Boolean);
|
| 106 |
+
if (!selected.length) return;
|
| 107 |
+
const combatants = selected.filter(u => UNITS[u.type].role === "combat");
|
| 108 |
+
const others = selected.filter(u => UNITS[u.type].role !== "combat");
|
| 109 |
+
if (combatants.length) issueAttackMove(combatants, world.x, world.y);
|
| 110 |
+
if (others.length) issueMove(others, world.x, world.y);
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
// Splash-screen game setup, carried across "choose another battlefield"
|
| 114 |
// restarts. sizeMult scales the map (map.js); resourceMult scales every
|
| 115 |
// deposit's amount; aiApm caps the opponent's actions per minute (ai.js).
|
|
|
|
| 466 |
hint.className = "hint";
|
| 467 |
hint.textContent = "Drag-select units, or click a building.";
|
| 468 |
panelEl.appendChild(hint);
|
| 469 |
+
panelEl.appendChild(makeButton("Idle Worker ( ` )", () => input.focusIdleWorker()));
|
| 470 |
+
panelEl.appendChild(makeButton("Select Army ( Q )", () => input.selectAllArmy()));
|
| 471 |
+
panelEl.appendChild(controlsLegend());
|
| 472 |
return;
|
| 473 |
}
|
| 474 |
|
|
|
|
| 493 |
|
| 494 |
const cc = sel.find(e => e.kind === "building" && e.type === "command" && !e.constructing);
|
| 495 |
if (cc) {
|
| 496 |
+
panelEl.appendChild(makeButton(`Produce Worker (${UNITS.worker.cost.ore} ore)`,
|
| 497 |
+
() => queueProduction(state, cc.id, "worker"), { cost: UNITS.worker.cost, tip: unitTip(UNITS.worker) }));
|
| 498 |
if (cc.queue.length) renderQueueRows(cc);
|
| 499 |
}
|
| 500 |
|
| 501 |
const barracks = sel.find(e => e.kind === "building" && e.type === "barracks" && !e.constructing);
|
| 502 |
if (barracks) {
|
| 503 |
+
for (const t of ["skiff", "bastion", "lancer", "breacher"]) {
|
| 504 |
+
panelEl.appendChild(makeButton(`Produce ${UNITS[t].name} (${costText(UNITS[t].cost)})`,
|
| 505 |
+
() => queueProduction(state, barracks.id, t), { cost: UNITS[t].cost, tip: unitTip(UNITS[t]) }));
|
| 506 |
+
}
|
| 507 |
if (barracks.queue.length) renderQueueRows(barracks);
|
| 508 |
}
|
| 509 |
|
|
|
|
| 517 |
row.textContent = `${u.name} — researched`;
|
| 518 |
panelEl.appendChild(row);
|
| 519 |
} else {
|
| 520 |
+
panelEl.appendChild(makeButton(`Research ${u.name} (${costText(u.cost)})`,
|
| 521 |
+
() => researchUpgrade(state, refinery.id, u.id), { cost: u.cost, tip: u.desc }));
|
| 522 |
}
|
| 523 |
});
|
| 524 |
}
|
| 525 |
|
| 526 |
const worker = sel.find(e => e.kind === "unit" && e.type === "worker");
|
| 527 |
if (worker && !input.building) {
|
| 528 |
+
for (const t of ["barracks", "refinery", "turret", "habitat", "command"]) {
|
| 529 |
+
panelEl.appendChild(makeButton(`Build ${BUILDINGS[t].name} (${costText(BUILDINGS[t].cost)})`,
|
| 530 |
+
() => input.startBuild(t), { cost: BUILDINGS[t].cost, tip: unitTip(BUILDINGS[t]) }));
|
| 531 |
+
}
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
if (sel.some(e => e.kind === "unit")) {
|
| 535 |
+
panelEl.appendChild(makeButton("Stop ( X )", () => input.stopSelected()));
|
| 536 |
}
|
| 537 |
|
| 538 |
if (input.building) {
|
|
|
|
| 559 |
return Object.entries(cost).map(([com, qty]) => `${qty} ${com}`).join(", ");
|
| 560 |
}
|
| 561 |
|
| 562 |
+
// A compact keyboard/mouse reference, shown when nothing is selected so the
|
| 563 |
+
// controls aren't hidden knowledge.
|
| 564 |
+
function controlsLegend() {
|
| 565 |
+
const box = document.createElement("div");
|
| 566 |
+
box.className = "legend";
|
| 567 |
+
box.innerHTML = [
|
| 568 |
+
["Left-drag", "select · Ctrl+drag adds"],
|
| 569 |
+
["Right-click", "move / attack / gather"],
|
| 570 |
+
["Ctrl+right", "queue a waypoint"],
|
| 571 |
+
["Shift+1–9", "set group · 1–9 recall"],
|
| 572 |
+
["Double-click", "select all of that type"],
|
| 573 |
+
["Q", "select army · X stop · ` idle worker"],
|
| 574 |
+
["Minimap", "left jumps · right orders"],
|
| 575 |
+
["Wheel", "zoom · WASD / edge scroll to pan"],
|
| 576 |
+
].map(([k, v]) => `<div class="legend-row"><span class="legend-key">${k}</span><span>${v}</span></div>`).join("");
|
| 577 |
+
return box;
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
// A build/produce/research button. When `cost` is given and the player can't
|
| 581 |
+
// afford it, the button greys out and a click just plays the denied buzz — so a
|
| 582 |
+
// broke click gives feedback instead of silently doing nothing (previously the
|
| 583 |
+
// only "can't" feedback was the supply block). `tip` becomes a hover tooltip.
|
| 584 |
+
function makeButton(label, onClick, { cost = null, tip = null } = {}) {
|
| 585 |
const btn = document.createElement("button");
|
| 586 |
btn.className = "btn";
|
| 587 |
btn.textContent = label;
|
| 588 |
+
if (tip) btn.title = tip;
|
| 589 |
+
const affordable = !cost || canAfford(state.players.player.resources, cost);
|
| 590 |
+
if (!affordable) {
|
| 591 |
+
btn.classList.add("disabled");
|
| 592 |
+
btn.addEventListener("click", () => sound.playProductionBlocked());
|
| 593 |
+
} else {
|
| 594 |
+
btn.addEventListener("click", () => { onClick(); renderHUD(); });
|
| 595 |
+
}
|
| 596 |
return btn;
|
| 597 |
}
|
| 598 |
|
| 599 |
+
// A compact stat line for a unit/building button tooltip.
|
| 600 |
+
function unitTip(def) {
|
| 601 |
+
const bits = [`${def.hp} hp`];
|
| 602 |
+
if (def.attack) bits.push(`${def.attack} dmg`, `rng ${def.range}`);
|
| 603 |
+
if (def.speed) bits.push(`spd ${def.speed}`);
|
| 604 |
+
if (def.supplyCost) bits.push(`${def.supplyCost} supply`);
|
| 605 |
+
return bits.join(" · ");
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
function showGameOver(winner) {
|
| 609 |
if (winner === "player") sound.playVictory(); else sound.playDefeat();
|
| 610 |
|
|
@@ -266,6 +266,7 @@ function drawGasNode(ctx, n, r) {
|
|
| 266 |
/* ---------- buildings ---------- */
|
| 267 |
|
| 268 |
function drawBuildings(ctx, state, view) {
|
|
|
|
| 269 |
for (const b of state.buildings.values()) {
|
| 270 |
if (view && !inView(view, b.x, b.y, b.radius + 12)) continue; // off-screen (pad for the hp bar above it)
|
| 271 |
if (b.owner !== "player" && !isVisibleAt(state.fog, b.x, b.y)) continue;
|
|
@@ -279,7 +280,7 @@ function drawBuildings(ctx, state, view) {
|
|
| 279 |
else if (b.type === "habitat") drawHabitat(ctx, b, color);
|
| 280 |
|
| 281 |
ctx.globalAlpha = 1;
|
| 282 |
-
drawHealthBar(ctx, b.x, b.y - b.radius - 8, b.radius * 2, b.hp, b.maxHp);
|
| 283 |
}
|
| 284 |
}
|
| 285 |
|
|
@@ -451,6 +452,7 @@ function turretFacing(state, b) {
|
|
| 451 |
/* ---------- units ---------- */
|
| 452 |
|
| 453 |
function drawUnits(ctx, state, view) {
|
|
|
|
| 454 |
for (const u of state.units.values()) {
|
| 455 |
if (view && !inView(view, u.x, u.y, 16)) continue; // off-screen unit
|
| 456 |
if (u.owner !== "player" && !isVisibleAt(state.fog, u.x, u.y)) continue;
|
|
@@ -477,7 +479,7 @@ function drawUnits(ctx, state, view) {
|
|
| 477 |
ctx.fillStyle = "#ffd166";
|
| 478 |
ctx.fill();
|
| 479 |
}
|
| 480 |
-
drawHealthBar(ctx, u.x, u.y - def.radius - 9, 16, u.hp, u.maxHp);
|
| 481 |
}
|
| 482 |
}
|
| 483 |
|
|
@@ -740,12 +742,16 @@ function drawBuildGhost(ctx, state, ghost) {
|
|
| 740 |
ctx.strokeRect(ghost.x - def.radius, ghost.y - def.radius, def.radius * 2, def.radius * 2);
|
| 741 |
}
|
| 742 |
|
| 743 |
-
|
| 744 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 745 |
const pct = Math.max(0, hp / maxHp);
|
| 746 |
ctx.fillStyle = "#243162";
|
| 747 |
ctx.fillRect(cx - w / 2, y, w, 3);
|
| 748 |
-
ctx.fillStyle = pct > 0.
|
| 749 |
ctx.fillRect(cx - w / 2, y, w * pct, 3);
|
| 750 |
}
|
| 751 |
|
|
|
|
| 266 |
/* ---------- buildings ---------- */
|
| 267 |
|
| 268 |
function drawBuildings(ctx, state, view) {
|
| 269 |
+
const selSet = new Set(state.selection);
|
| 270 |
for (const b of state.buildings.values()) {
|
| 271 |
if (view && !inView(view, b.x, b.y, b.radius + 12)) continue; // off-screen (pad for the hp bar above it)
|
| 272 |
if (b.owner !== "player" && !isVisibleAt(state.fog, b.x, b.y)) continue;
|
|
|
|
| 280 |
else if (b.type === "habitat") drawHabitat(ctx, b, color);
|
| 281 |
|
| 282 |
ctx.globalAlpha = 1;
|
| 283 |
+
drawHealthBar(ctx, b.x, b.y - b.radius - 8, b.radius * 2, b.hp, b.maxHp, selSet.has(b.id));
|
| 284 |
}
|
| 285 |
}
|
| 286 |
|
|
|
|
| 452 |
/* ---------- units ---------- */
|
| 453 |
|
| 454 |
function drawUnits(ctx, state, view) {
|
| 455 |
+
const selSet = new Set(state.selection);
|
| 456 |
for (const u of state.units.values()) {
|
| 457 |
if (view && !inView(view, u.x, u.y, 16)) continue; // off-screen unit
|
| 458 |
if (u.owner !== "player" && !isVisibleAt(state.fog, u.x, u.y)) continue;
|
|
|
|
| 479 |
ctx.fillStyle = "#ffd166";
|
| 480 |
ctx.fill();
|
| 481 |
}
|
| 482 |
+
drawHealthBar(ctx, u.x, u.y - def.radius - 9, 16, u.hp, u.maxHp, selSet.has(u.id));
|
| 483 |
}
|
| 484 |
}
|
| 485 |
|
|
|
|
| 742 |
ctx.strokeRect(ghost.x - def.radius, ghost.y - def.radius, def.radius * 2, def.radius * 2);
|
| 743 |
}
|
| 744 |
|
| 745 |
+
// `force` keeps the bar visible at full health for a selected entity, so you
|
| 746 |
+
// can confirm your army's condition at a glance instead of it vanishing the
|
| 747 |
+
// moment it's topped off. Three colour bands (green / amber / red) so the
|
| 748 |
+
// health tier reads without relying on the green-vs-red distinction alone.
|
| 749 |
+
function drawHealthBar(ctx, cx, y, w, hp, maxHp, force = false) {
|
| 750 |
+
if (hp >= maxHp && !force) return;
|
| 751 |
const pct = Math.max(0, hp / maxHp);
|
| 752 |
ctx.fillStyle = "#243162";
|
| 753 |
ctx.fillRect(cx - w / 2, y, w, 3);
|
| 754 |
+
ctx.fillStyle = pct > 0.6 ? "#4ade80" : pct > 0.3 ? "#fbbf24" : "#f87171";
|
| 755 |
ctx.fillRect(cx - w / 2, y, w * pct, 3);
|
| 756 |
}
|
| 757 |
|
|
@@ -194,6 +194,15 @@ html, body {
|
|
| 194 |
cursor: pointer;
|
| 195 |
}
|
| 196 |
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
.overlay {
|
| 199 |
position: absolute;
|
|
|
|
| 194 |
cursor: pointer;
|
| 195 |
}
|
| 196 |
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
| 197 |
+
/* Unaffordable build/produce/research buttons: dimmed and unhoverable-looking,
|
| 198 |
+
but still clickable so the click can play the "denied" cue (see main.js). */
|
| 199 |
+
.btn.disabled { opacity: 0.45; cursor: not-allowed; }
|
| 200 |
+
.btn.disabled:hover { border-color: var(--border); color: var(--text); }
|
| 201 |
+
|
| 202 |
+
/* Controls reference shown in the empty selection panel. */
|
| 203 |
+
.legend { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 10px; }
|
| 204 |
+
.legend-row { display: flex; gap: 8px; font-size: 0.74rem; color: var(--muted); padding: 2px 0; }
|
| 205 |
+
.legend-key { flex: 0 0 88px; color: var(--accent); }
|
| 206 |
|
| 207 |
.overlay {
|
| 208 |
position: absolute;
|