glytch/fktry/src/ui/index.ts
type-two c18309f464 [ui] build bar, top strip, inspector, commission fax, toasts
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>
2026-07-17 16:15:50 +10:00

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());
},
};
}