Claude Claude Fable 5 commited on
Commit
4e300be
·
unverified ·
1 Parent(s): 6d7e8fa

Tier 7: bring controls & UX up to RTS standard

Browse files

Controls (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

Files changed (5) hide show
  1. engine/commands.js +8 -0
  2. input.js +104 -6
  3. main.js +77 -14
  4. render.js +11 -5
  5. style.css +9 -0
engine/commands.js CHANGED
@@ -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
input.js CHANGED
@@ -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
- state.selection = hit && hit.owner === "player" ? [hit.id] : [];
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
- state.selection = [...state.units.values()]
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
- window.addEventListener("keydown", e => heldKeys.add(e.key.toLowerCase()), { signal });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = 0;
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]; }
main.js CHANGED
@@ -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)`, () => queueProduction(state, cc.id, "worker")));
 
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
- panelEl.appendChild(makeButton(`Produce Skiff (${UNITS.skiff.cost.ore} ore)`, () => queueProduction(state, barracks.id, "skiff")));
483
- panelEl.appendChild(makeButton(`Produce Bastion (${UNITS.bastion.cost.ore} ore)`, () => queueProduction(state, barracks.id, "bastion")));
484
- panelEl.appendChild(makeButton(`Produce Lancer (${UNITS.lancer.cost.ore} ore)`, () => queueProduction(state, barracks.id, "lancer")));
485
- panelEl.appendChild(makeButton(`Produce Breacher (${costText(UNITS.breacher.cost)})`, () => queueProduction(state, barracks.id, "breacher")));
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)})`, () => researchUpgrade(state, refinery.id, u.id)));
 
500
  }
501
  });
502
  }
503
 
504
  const worker = sel.find(e => e.kind === "unit" && e.type === "worker");
505
  if (worker && !input.building) {
506
- panelEl.appendChild(makeButton(`Build Barracks (${BUILDINGS.barracks.cost.ore} ore)`, () => input.startBuild("barracks")));
507
- panelEl.appendChild(makeButton(`Build Refinery (${BUILDINGS.refinery.cost.ore} ore)`, () => input.startBuild("refinery")));
508
- panelEl.appendChild(makeButton(`Build Turret (${costText(BUILDINGS.turret.cost)})`, () => input.startBuild("turret")));
509
- panelEl.appendChild(makeButton(`Build Habitat (${BUILDINGS.habitat.cost.ore} ore)`, () => input.startBuild("habitat")));
510
- panelEl.appendChild(makeButton(`Build Command Center (${BUILDINGS.command.cost.ore} ore)`, () => input.startBuild("command")));
 
 
 
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
- function makeButton(label, onClick) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  const btn = document.createElement("button");
539
  btn.className = "btn";
540
  btn.textContent = label;
541
- btn.addEventListener("click", () => { onClick(); renderHUD(); });
 
 
 
 
 
 
 
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
 
render.js CHANGED
@@ -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
- function drawHealthBar(ctx, cx, y, w, hp, maxHp) {
744
- if (hp >= maxHp) return;
 
 
 
 
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.4 ? "#4ade80" : "#f87171";
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
 
style.css CHANGED
@@ -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;