Claude Claude Opus 4.8 commited on
Commit
332cd22
Β·
unverified Β·
1 Parent(s): d495aed

Odyssey/RTS UX: pausable factories, box-select prefers fighters, sub-select by type

Browse files

Three requested quality-of-life changes.

Pause producers (engine/industry.js + hud.js): a Smelter (or any factory) can eat ore
faster than you want. A selected factory now has a Pause/Resume button; a paused factory
consumes no inputs, banks no output, and frees its reserved Power for the rest of the
grid (or a charging Gate). The status line reads "Paused". The flag rides the building
object, so it persists across save/load for free.

Box-select prioritises the army (input.js): a drag-box that catches any fighter now drops
the workers from the selection, so sweeping across your base to grab your army no longer
drags the miners along. A box with no fighters still selects workers as before.

Sub-select by type (input.js + hud.js): when a mixed group is selected, the aggregated
type rows ("3Γ— Bastion", "12Γ— Skiff") are now buttons β€” click one to narrow the selection
to just that type. Single-type selections render as before (nothing to sub-select).

Tests: +1 industry (a paused factory consumes/produces/draws nothing, resumes cleanly);
462 pass. Browser-verified all three end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEQUVetLk31Jj7niDQAsXL

Files changed (5) hide show
  1. engine/industry.js +2 -1
  2. hud.js +27 -5
  3. input.js +15 -3
  4. style.css +10 -0
  5. test/industry.test.js +13 -0
engine/industry.js CHANGED
@@ -66,7 +66,7 @@ export function powerDraw(state, owner) {
66
  if (b.owner !== owner || b.constructing) continue;
67
  const def = BUILDINGS[b.type];
68
  const r = def.recipe ? RECIPE_BY_ID[def.recipe] : null;
69
- if (r) draw += (r.in.energy || 0) * (def.prodRate || 1);
70
  // A wonder still charging loads the grid too, so the Antimatter Gate competes
71
  // with the factories for Reactor Power (engine/wonder.js) β€” making the finale a
72
  // real "feed the factories vs charge the Gate" call, and Fusion Containment worth it.
@@ -95,6 +95,7 @@ export function updateProduction(state, building, dt) {
95
  if (building.constructing) return;
96
  const recipe = recipeOf(building);
97
  if (!recipe) return;
 
98
  const throttle = powerThrottle(state, building.owner);
99
  if (throttle <= 0) return;
100
  const player = state.players[building.owner];
 
66
  if (b.owner !== owner || b.constructing) continue;
67
  const def = BUILDINGS[b.type];
68
  const r = def.recipe ? RECIPE_BY_ID[def.recipe] : null;
69
+ if (r && !b.paused) draw += (r.in.energy || 0) * (def.prodRate || 1); // a paused factory frees its reserved Power
70
  // A wonder still charging loads the grid too, so the Antimatter Gate competes
71
  // with the factories for Reactor Power (engine/wonder.js) β€” making the finale a
72
  // real "feed the factories vs charge the Gate" call, and Fusion Containment worth it.
 
95
  if (building.constructing) return;
96
  const recipe = recipeOf(building);
97
  if (!recipe) return;
98
+ if (building.paused) return; // player-paused to conserve its inputs β€” banks nothing, draws nothing (see hud.js)
99
  const throttle = powerThrottle(state, building.owner);
100
  if (throttle <= 0) return;
101
  const player = state.players[building.owner];
hud.js CHANGED
@@ -392,6 +392,7 @@ function renderMarket(state) {
392
  // applies, in priority order: no Power at all, then a missing input, then a Power
393
  // shortfall throttling everything, else running (with the live output rate).
394
  function factoryStatus(state, b, recipe) {
 
395
  const throttle = powerThrottle(state, b.owner);
396
  if (throttle <= 0) return { cls: "bad", text: "Stalled β€” no Power" };
397
 
@@ -449,13 +450,27 @@ function rebuildSelectionPanel(sel) {
449
  }
450
 
451
  if (sel.length > 1 && sel.every(e => e.kind === "unit")) {
452
- for (const [type, entry] of countByType(sel)) {
 
 
453
  const def = UNITS[type];
454
- const row = document.createElement("div");
455
- row.className = "sel-row";
456
  const pct = Math.round((entry.hp / entry.maxHp) * 100);
457
- row.textContent = `${entry.count}Γ— ${def.name} β€” ${pct}% hp`;
458
- panelEl.appendChild(row);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  }
460
  } else {
461
  sel.forEach(e => {
@@ -606,6 +621,13 @@ function rebuildSelectionPanel(sel) {
606
  stRow.className = "sel-note " + st.cls;
607
  stRow.textContent = st.text;
608
  panelEl.appendChild(stRow);
 
 
 
 
 
 
 
609
  }
610
 
611
  // Reactor (Odyssey): grants Power to the grid rather than running a recipe, so
 
392
  // applies, in priority order: no Power at all, then a missing input, then a Power
393
  // shortfall throttling everything, else running (with the live output rate).
394
  function factoryStatus(state, b, recipe) {
395
+ if (b.paused) return { cls: "paused", text: "Paused β€” banking its inputs" };
396
  const throttle = powerThrottle(state, b.owner);
397
  if (throttle <= 0) return { cls: "bad", text: "Stalled β€” no Power" };
398
 
 
450
  }
451
 
452
  if (sel.length > 1 && sel.every(e => e.kind === "unit")) {
453
+ const counts = countByType(sel);
454
+ const multiType = counts.size > 1; // only worth sub-selecting when the mix has 2+ types
455
+ for (const [type, entry] of counts) {
456
  const def = UNITS[type];
 
 
457
  const pct = Math.round((entry.hp / entry.maxHp) * 100);
458
+ const label = `${entry.count}Γ— ${def.name} β€” ${pct}% hp`;
459
+ // With several types selected, each row is a button that narrows the selection
460
+ // to just that type (input.selectType) β€” click "3Γ— Bastion" to keep only them.
461
+ if (multiType) {
462
+ const btn = document.createElement("button");
463
+ btn.className = "sel-row type-row";
464
+ btn.textContent = label;
465
+ btn.title = `Select only your ${def.name}s`;
466
+ btn.addEventListener("click", () => input.selectType(type));
467
+ panelEl.appendChild(btn);
468
+ } else {
469
+ const row = document.createElement("div");
470
+ row.className = "sel-row";
471
+ row.textContent = label;
472
+ panelEl.appendChild(row);
473
+ }
474
  }
475
  } else {
476
  sel.forEach(e => {
 
621
  stRow.className = "sel-note " + st.cls;
622
  stRow.textContent = st.text;
623
  panelEl.appendChild(stRow);
624
+
625
+ // Pause toggle: stop this factory drawing down its inputs (and Power) β€” the way to
626
+ // keep a hungry Smelter from eating all your ore, or to free the grid for the Gate.
627
+ panelEl.appendChild(makeButton(factory.paused ? "β–Ά Resume production" : "⏸ Pause production",
628
+ () => { factory.paused = !factory.paused; },
629
+ { tip: factory.paused ? "Resume converting inputs into goods"
630
+ : "Stop consuming inputs β€” banks and draws nothing until resumed" }));
631
  }
632
 
633
  // Reactor (Odyssey): grants Power to the grid rather than running a recipe, so
input.js CHANGED
@@ -115,9 +115,14 @@ export function attachInput(canvas, state, onChange) {
115
  } else {
116
  const x1 = Math.min(box.x1, box.x2), x2 = Math.max(box.x1, box.x2);
117
  const y1 = Math.min(box.y1, box.y2), y2 = Math.max(box.y1, box.y2);
118
- picks = [...state.units.values()]
119
- .filter(u => u.owner === "player" && u.x >= x1 && u.x <= x2 && u.y >= y1 && u.y <= y2)
120
- .map(u => u.id);
 
 
 
 
 
121
  }
122
  if (additive) {
123
  if (picks.length) state.selection = [...new Set([...state.selection, ...picks])];
@@ -460,6 +465,13 @@ export function attachInput(canvas, state, onChange) {
460
  getCamera: () => camera,
461
  focusIdleWorker,
462
  selectAllArmy: () => { selectAllArmy(); onChange(); },
 
 
 
 
 
 
 
463
  stopSelected: () => { stopSelected(); onChange(); },
464
  scoutSelected: () => { scoutSelected(); onChange(); },
465
  holdSelected: () => { holdSelected(); onChange(); },
 
115
  } else {
116
  const x1 = Math.min(box.x1, box.x2), x2 = Math.max(box.x1, box.x2);
117
  const y1 = Math.min(box.y1, box.y2), y2 = Math.max(box.y1, box.y2);
118
+ let inBox = [...state.units.values()]
119
+ .filter(u => u.owner === "player" && u.x >= x1 && u.x <= x2 && u.y >= y1 && u.y <= y2);
120
+ // Prioritise the army: a box that catches any fighter drops the workers, so
121
+ // sweeping across your base to grab your army doesn't drag the miners along
122
+ // (standard RTS). A box with no fighters still selects the workers as before.
123
+ if (inBox.some(u => UNITS[u.type].role === "combat"))
124
+ inBox = inBox.filter(u => u.type !== "worker");
125
+ picks = inBox.map(u => u.id);
126
  }
127
  if (additive) {
128
  if (picks.length) state.selection = [...new Set([...state.selection, ...picks])];
 
465
  getCamera: () => camera,
466
  focusIdleWorker,
467
  selectAllArmy: () => { selectAllArmy(); onChange(); },
468
+ // Narrow the current selection to one unit type β€” the HUD's aggregated type rows
469
+ // are clickable (hud.js), so clicking "12Γ— Skiff" keeps only the Skiffs.
470
+ selectType(type) {
471
+ const ids = state.selection
472
+ .map(id => state.units.get(id)).filter(u => u && u.type === type).map(u => u.id);
473
+ if (ids.length) { state.selection = ids; sound.playSelect(); onChange(); }
474
+ },
475
  stopSelected: () => { stopSelected(); onChange(); },
476
  scoutSelected: () => { scoutSelected(); onChange(); },
477
  holdSelected: () => { holdSelected(); onChange(); },
style.css CHANGED
@@ -260,6 +260,16 @@ html, body {
260
  .sel-note.good { color: var(--good, #4ade80); }
261
  .sel-note.warn { color: var(--warn); }
262
  .sel-note.bad { color: var(--bad); }
 
 
 
 
 
 
 
 
 
 
263
 
264
  /* Section header in the Odyssey build menu (Economy / Military / …) β€” a quiet
265
  divider so the ~18-building list is scannable instead of a flat wall. */
 
260
  .sel-note.good { color: var(--good, #4ade80); }
261
  .sel-note.warn { color: var(--warn); }
262
  .sel-note.bad { color: var(--bad); }
263
+ .sel-note.paused { color: var(--muted); }
264
+
265
+ /* Aggregated type row as a button (hud.js) β€” click "3Γ— Bastion" to sub-select that
266
+ type. Strips the default button chrome, keeps the .sel-row look, adds a hover cue. */
267
+ .type-row {
268
+ display: block; width: 100%; text-align: left;
269
+ background: none; border: none; border-bottom: 1px solid var(--border);
270
+ color: var(--text); font-family: inherit; cursor: pointer;
271
+ }
272
+ .type-row:hover { color: var(--accent); background: rgba(255, 255, 255, 0.03); }
273
 
274
  /* Section header in the Odyssey build menu (Economy / Military / …) β€” a quiet
275
  divider so the ~18-building list is scannable instead of a flat wall. */
test/industry.test.js CHANGED
@@ -73,6 +73,19 @@ test("an unpowered factory produces nothing", () => {
73
  assert.equal(s.players.player.resources.ore, 1000, "…and no inputs consumed");
74
  });
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  test("updateProduction is a no-op for a building with no recipe (e.g. a Command Center)", () => {
77
  const s = stub([{ type: "command" }], { ore: 500 });
78
  const cc = [...s.buildings.values()][0];
 
73
  assert.equal(s.players.player.resources.ore, 1000, "…and no inputs consumed");
74
  });
75
 
76
+ test("a paused factory consumes no inputs, banks no output, and frees its Power", () => {
77
+ const s = stub([reactor(), smelter({ paused: true })], { ore: 1000 });
78
+ const sm = [...s.buildings.values()].find(b => b.type === "smelter");
79
+ assert.equal(powerDraw(s, "player"), 0, "a paused factory reserves no Power (frees the grid for others)");
80
+ updateProduction(s, sm, 0.1);
81
+ assert.equal(s.players.player.resources.ore, 1000, "paused β†’ no ore consumed");
82
+ assert.equal(s.players.player.resources.metals || 0, 0, "paused β†’ no metals banked");
83
+ sm.paused = false; // resume
84
+ assert.equal(powerDraw(s, "player"), 4, "resumed β†’ it reserves its draw again");
85
+ updateProduction(s, sm, 0.1);
86
+ assert.ok(s.players.player.resources.metals > 0, "resumed β†’ it refines again");
87
+ });
88
+
89
  test("updateProduction is a no-op for a building with no recipe (e.g. a Command Center)", () => {
90
  const s = stub([{ type: "command" }], { ore: 500 });
91
  const cc = [...s.buildings.values()][0];