/** * 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(); let top: ReturnType; let build: ReturnType; let inspector: ReturnType; let fax: ReturnType; let toasts: ReturnType; 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()); }, }; }