Spaces:
Paused
Odyssey/RTS UX: pausable factories, box-select prefers fighters, sub-select by type
Browse filesThree 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
- engine/industry.js +2 -1
- hud.js +27 -5
- input.js +15 -3
- style.css +10 -0
- test/industry.test.js +13 -0
|
@@ -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];
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 458 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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 |
-
|
| 119 |
-
.filter(u => u.owner === "player" && u.x >= x1 && u.x <= x2 && u.y >= y1 && u.y <= y2)
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(); },
|
|
@@ -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. */
|
|
@@ -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];
|