The five M1 surfaces, replacing the stub: - buildbar.ts: one button per machine, hotkeys 1-9, R rotates, Esc or right-click clears. LANE-DATA shipped 21 machines, so the bar wraps rather than running off both edges of the viewport; slots 10-21 are click-only for now (paging proposed in NOTES). - topstrip.ts: bandwidth meter going amber at 85% and red + flashing BROWNOUT over 100%, buffer, shipped ticker + per-item chips, tick or HALTED. The brownout is legible from this panel alone, which is the one failure the player has to read across the room. - inspector.ts: name, codex flavor, intake/output chips with live buffers, process bar, heat, jam reason. Holds an entity id and re-looks-up each frame, so a removed unit closes itself. - fax.ts: the commission card with wants-as-chips and a FAX SENT stamp on commissionDone. - toasts.ts: max 4, 6s TTL, colored per event kind. Click-to-inspect is not wired: the UI cannot reach renderer.pickTile (UIBus exposes only dispatch/selectedBuild, and ui.init never sees the Renderer). TAB cycles the unit manifest as a stopgap so the inspector is reachable without a console. Contract request filed in NOTES. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
145 lines
5.2 KiB
TypeScript
145 lines
5.2 KiB
TypeScript
/**
|
|
* LANE-UI — HUD root. Everything DOM: build bar, top strip, inspector, fax, toasts.
|
|
*
|
|
* Rules of the house (CONTRACTS + LANE-UI.md):
|
|
* - `#ui` is pointer-events:none; every interactive child re-enables it, so clicks fall
|
|
* through to the world everywhere we haven't drawn a panel.
|
|
* - We never mutate the snapshot and never hold a reference to it across frames.
|
|
* - All mutation goes out as Commands on the bus.
|
|
*/
|
|
import type { GameData, MachineDef, SimEvent, SimSnapshot, UI, UIBus } from '../contracts';
|
|
import { createBuildBar } from './buildbar';
|
|
import { createFax } from './fax';
|
|
import { createFixture, fixtureEnabled, type Fixture } from './fixture';
|
|
import { createHotkeys } from './hotkeys';
|
|
import { createInspector } from './inspector';
|
|
import { createRoster, entityAt, resolvePickTile, syncRoster, type PickTile } from './pick';
|
|
import { createSelection } from './selection';
|
|
import { injectStyle } from './style';
|
|
import { createTopStrip } from './topstrip';
|
|
import { createToasts } from './toasts';
|
|
|
|
export function createUI(): UI {
|
|
let machines = new Map<string, MachineDef>();
|
|
let top: ReturnType<typeof createTopStrip>;
|
|
let build: ReturnType<typeof createBuildBar>;
|
|
let inspector: ReturnType<typeof createInspector>;
|
|
let fax: ReturnType<typeof createFax>;
|
|
let toasts: ReturnType<typeof createToasts>;
|
|
|
|
let pickTile: PickTile | null = null;
|
|
let fixture: Fixture | null = null;
|
|
|
|
/** Our own copy of the entity list; safe to read between frames. See pick.ts. */
|
|
const roster = createRoster();
|
|
let paused = false;
|
|
let manifestIndex = -1;
|
|
|
|
function rosterIndexOf(id: number): number {
|
|
for (let i = 0; i < roster.length; i++) if (roster.entries[i].id === id) return i;
|
|
return -1;
|
|
}
|
|
|
|
function machineName(entityId: number): string {
|
|
const i = rosterIndexOf(entityId);
|
|
if (i < 0) return `UNIT ${entityId}`;
|
|
return machines.get(roster.entries[i].def)?.name ?? `UNIT ${entityId}`;
|
|
}
|
|
|
|
/**
|
|
* TEMPORARY: nothing reachable from the UI can turn a click into a tile (see pick.ts),
|
|
* so TAB walks the unit manifest instead. That keeps the inspector reachable with no
|
|
* console, which is the round's definition of done. Delete once the CONTRACT REQUEST
|
|
* for `UIBus.pickTile` lands and clicking a machine works.
|
|
*/
|
|
function cycleManifest() {
|
|
if (roster.length === 0) return;
|
|
manifestIndex = (manifestIndex + 1) % roster.length;
|
|
inspector.open(roster.entries[manifestIndex].id);
|
|
}
|
|
|
|
return {
|
|
init(root: HTMLElement, data: GameData, bus: UIBus) {
|
|
injectStyle();
|
|
machines = new Map(data.machines.map((m) => [m.id, m]));
|
|
|
|
const sel = createSelection(bus);
|
|
top = createTopStrip(data);
|
|
build = createBuildBar(data, sel);
|
|
inspector = createInspector(data);
|
|
fax = createFax(data);
|
|
toasts = createToasts();
|
|
|
|
root.append(top.el, build.el, inspector.el, fax.el, toasts.el);
|
|
|
|
if (fixtureEnabled()) {
|
|
fixture = createFixture(data);
|
|
console.info('[ui] fixture mode: the HUD is being driven by a fake sim (?uifixture)');
|
|
}
|
|
|
|
// ------------------------------------------------------------------ keyboard
|
|
const keys = createHotkeys();
|
|
for (let n = 1; n <= 9; n++) keys.bind(String(n), () => build.selectSlot(n));
|
|
keys.bind('r', () => sel.rotate());
|
|
keys.bind('escape', () => {
|
|
// One key, two jobs, in the order the player expects: drop the tool first.
|
|
if (sel.get()) sel.clear();
|
|
else inspector.close();
|
|
});
|
|
keys.bind(' ', () => bus.dispatch({ kind: 'setPaused', paused: !paused }));
|
|
keys.bind('tab', cycleManifest);
|
|
keys.attach();
|
|
|
|
// ------------------------------------------------------------------- pointer
|
|
// main.ts owns click-to-place on #game. We handle only the two cases it doesn't:
|
|
// right-click cancels the pending build, and a plain left click with nothing
|
|
// selected inspects whatever unit is under the cursor.
|
|
const game = document.getElementById('game');
|
|
const inWorld = (t: EventTarget | null) => t instanceof Node && !!game?.contains(t);
|
|
|
|
addEventListener('contextmenu', (e) => {
|
|
if (!inWorld(e.target)) return;
|
|
e.preventDefault();
|
|
sel.clear();
|
|
});
|
|
|
|
addEventListener('pointerdown', (e) => {
|
|
if (e.button !== 0 || !inWorld(e.target)) return;
|
|
if (sel.get()) return; // placing, not inspecting — that click is main.ts's
|
|
|
|
pickTile ??= resolvePickTile(); // renderer may register late
|
|
const tile = pickTile?.(e.clientX, e.clientY);
|
|
if (!tile) return;
|
|
|
|
const hit = entityAt(roster, machines, tile);
|
|
if (hit === null) {
|
|
inspector.close();
|
|
} else {
|
|
inspector.open(hit);
|
|
manifestIndex = rosterIndexOf(hit);
|
|
}
|
|
});
|
|
},
|
|
|
|
update(snap: SimSnapshot, events: SimEvent[]) {
|
|
if (fixture) {
|
|
snap = fixture.snapshot(snap.paused);
|
|
events = fixture.drain();
|
|
}
|
|
|
|
paused = snap.paused;
|
|
syncRoster(roster, snap);
|
|
|
|
top.update(snap);
|
|
inspector.update(snap);
|
|
fax.update(snap);
|
|
|
|
for (const ev of events) {
|
|
toasts.push(ev, machineName);
|
|
if (ev.kind === 'commissionDone') fax.stamp();
|
|
}
|
|
toasts.update(performance.now());
|
|
},
|
|
};
|
|
}
|