diff --git a/fktry/src/ui/buildbar.ts b/fktry/src/ui/buildbar.ts new file mode 100644 index 0000000..d5e9dc5 --- /dev/null +++ b/fktry/src/ui/buildbar.ts @@ -0,0 +1,75 @@ +/** + * LANE-UI — build bar. One button per machine in GameData.machines, hotkeys 1-9. + * Click or hotkey selects for placement; main.ts does the ghost and the actual place. + */ +import type { GameData } from '../contracts'; +import { attrs, cls, el, panel, text } from './dom'; +import { machineColor } from './palette'; +import type { BuildSelection } from './selection'; +import { COPY, DIR_NAME } from './voice'; + +export interface BuildBar { + el: HTMLElement; + /** Select the machine in slot `n` (1-based, as printed on the button). */ + selectSlot(n: number): void; + sync(): void; +} + +export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar { + const root = panel(); + root.id = 'fk-build'; + + const row = el('div', 'fk-build-row'); + const hint = el('div'); + hint.id = 'fk-build-hint'; + + const buttons: Array<{ def: string; btn: HTMLButtonElement }> = []; + + data.machines.forEach((m, i) => { + const btn = el('button', 'fk-build-btn'); + attrs(btn, { type: 'button', title: `${m.name} — ${m.kind}` }); + + const ico = el('div', 'fk-build-ico'); + ico.style.background = machineColor(m); + btn.append(ico); + + // Only the first nine get a printed hotkey; the rest are click-only until the + // bar grows tabs/pages (a later round's problem). + if (i < 9) { + const key = el('span', 'fk-build-key'); + key.textContent = String(i + 1); + btn.append(key); + } + + btn.append(el('span', 'fk-build-name', [m.name])); + btn.addEventListener('click', () => sel.select(m.id)); + row.append(btn); + buttons.push({ def: m.id, btn }); + }); + + root.append(row, hint); + + function sync() { + const cur = sel.get(); + for (const b of buttons) cls(b.btn, 'is-sel', cur?.def === b.def); + if (cur) { + const def = data.machines.find((m) => m.id === cur.def); + text(hint, `${def?.name ?? cur.def} · FACING ${DIR_NAME[cur.dir]} · ${COPY.placingHint}`); + } else { + text(hint, COPY.idleHint); + } + cls(hint, 'is-placing', !!cur); + } + + sel.onChange(sync); + sync(); + + return { + el: root, + selectSlot(n) { + const b = buttons[n - 1]; + if (b) sel.select(b.def); + }, + sync, + }; +} diff --git a/fktry/src/ui/fax.ts b/fktry/src/ui/fax.ts new file mode 100644 index 0000000..6b286d0 --- /dev/null +++ b/fktry/src/ui/fax.ts @@ -0,0 +1,70 @@ +/** + * LANE-UI — the commission fax. The fax machine is sacred, do not ask why it works + * (lore §6). Sits under THE SCREEN, top right. + * + * Progress comes from `snap.commissionProgress` (itemId -> count delivered), measured + * against the active CommissionDef's `wants`. + */ +import type { CommissionDef, GameData, ItemDef, SimSnapshot } from '../contracts'; +import { createChipRow, type ChipRow } from './chips'; +import { cls, el, panel, text } from './dom'; +import { COPY } from './voice'; + +export interface Fax { + el: HTMLElement; + update(snap: SimSnapshot): void; + /** Play the stamp. Driven by the `commissionDone` event, not by progress math. */ + stamp(): void; +} + +const STAMP_MS = 1800; // must match the fk-stamp keyframes in style.ts + +export function createFax(data: GameData): Fax { + const commissions = new Map(data.commissions.map((c) => [c.id, c])); + const items = new Map(data.items.map((i) => [i.id, i])); + + const root = panel(); + root.id = 'fk-fax'; + + const head = el('div', 'fk-h fk-fax-h', [COPY.faxHeader]); + const sub = el('div', 'fk-fax-sub', [COPY.faxSubheader]); + const flavor = el('div', 'fk-fax-flavor'); + const chips: ChipRow = createChipRow(items); + const reward = el('div', 'fk-fax-sub'); + + const stampEl = el('div', 'fk-fax-stamp', [el('span', undefined, [COPY.faxStamp])]); + + root.append(head, sub, flavor, el('div', 'fk-rule'), chips.el, reward, stampEl); + + let stampTimer: number | undefined; + + return { + el: root, + update(snap) { + const c = snap.activeCommission ? commissions.get(snap.activeCommission) : null; + cls(root, 'is-open', !!c); + if (!c) return; + + text(flavor, c.flavor); + chips.update( + Object.entries(c.wants).map(([item, need]) => { + const have = snap.commissionProgress[item] ?? 0; + return { + item, + count: `${Math.min(have, need)}/${need}`, + state: have >= need ? ('met' as const) : ('plain' as const), + }; + }), + ); + text(reward, `ON RECEIPT: +${c.rewardBandwidth} BANDWIDTH`); + }, + stamp() { + // Restart cleanly if two commissions complete back to back. + cls(stampEl, 'is-on', false); + void stampEl.offsetWidth; // force reflow so the animation re-runs + cls(stampEl, 'is-on', true); + clearTimeout(stampTimer); + stampTimer = setTimeout(() => cls(stampEl, 'is-on', false), STAMP_MS); + }, + }; +} diff --git a/fktry/src/ui/index.ts b/fktry/src/ui/index.ts index f5bd503..7cd4147 100644 --- a/fktry/src/ui/index.ts +++ b/fktry/src/ui/index.ts @@ -1,17 +1,144 @@ -/** LANE-UI territory. Stub — replace per lanes/LANE-UI.md. */ -import type { GameData, SimEvent, SimSnapshot, UI, UIBus } from '../contracts'; +/** + * 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 tickEl: HTMLElement; + 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) { - tickEl = document.createElement('div'); - tickEl.style.cssText = - 'position:fixed;bottom:8px;left:8px;color:#4a6b4a;font-size:12px;pointer-events:none'; - root.appendChild(tickEl); + 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[]) { - tickEl.textContent = `TICK ${snap.tick}`; + + 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()); }, }; } diff --git a/fktry/src/ui/inspector.ts b/fktry/src/ui/inspector.ts new file mode 100644 index 0000000..6ce8258 --- /dev/null +++ b/fktry/src/ui/inspector.ts @@ -0,0 +1,130 @@ +/** + * LANE-UI — inspector panel. Shows one entity: what it is, what the codex says about + * it, what it's chewing, how far along, and why it has stopped. + * + * Holds an entity *id*, never an entity object — CONTRACTS says the sim may reuse + * snapshot buffers between frames, so we re-look-up from the live snapshot each update + * and close ourselves if the unit is gone. + */ +import type { EntityState, GameData, ItemDef, MachineDef, RecipeDef, SimSnapshot } from '../contracts'; +import { createChipRow, type ChipRow, type ChipSpec } from './chips'; +import { attrs, cls, el, panel, style, text } from './dom'; +import { COPY, machineFlavor } from './voice'; + +export interface Inspector { + el: HTMLElement; + open(entityId: number): void; + close(): void; + isOpen(): boolean; + update(snap: SimSnapshot): void; +} + +export function createInspector(data: GameData): Inspector { + const machines = new Map(data.machines.map((m) => [m.id, m])); + const recipes = new Map(data.recipes.map((r) => [r.id, r])); + const items = new Map(data.items.map((i) => [i.id, i])); + + const root = panel(); + root.id = 'fk-inspect'; + + const name = el('div', 'fk-ins-name'); + const closeBtn = el('button', 'fk-ins-x', [COPY.inspectorClose]); + attrs(closeBtn, { type: 'button', title: 'CLOSE (ESC)' }); + const head = el('div', 'fk-ins-head', [name, closeBtn]); + + const flavor = el('div', 'fk-ins-flavor'); + + const noRecipe = el('div', 'fk-ins-empty', [COPY.inspectorEmptyRecipe]); + + const inHead = el('div', 'fk-h', [COPY.inspectorInput]); + const inChips: ChipRow = createChipRow(items); + const outHead = el('div', 'fk-h', [COPY.inspectorOutput]); + const outChips: ChipRow = createChipRow(items); + + const progHead = el('div', 'fk-h', [COPY.inspectorProgress]); + const progFill = el('div', 'fk-bar-f'); + const progBar = el('div', 'fk-bar', [progFill]); + + const heatRow = el('div', 'fk-stat', [el('span', undefined, ['HEAT']), el('span', 'fk-stat-v')]); + const heatVal = heatRow.lastElementChild as HTMLElement; + + const jam = el('div', 'fk-ins-jam'); + + const body = el('div', undefined, [ + noRecipe, inHead, inChips.el, outHead, outChips.el, progHead, progBar, heatRow, + ]); + + root.append(head, flavor, el('div', 'fk-rule'), body, jam); + + let openId: number | null = null; + + function close() { + openId = null; + cls(root, 'is-open', false); + } + closeBtn.addEventListener('click', close); + + function paint(e: EntityState) { + const def = machines.get(e.def); + text(name, def?.name ?? e.def.toUpperCase()); + text(flavor, machineFlavor(e.def)); + + const recipe = e.recipe ? recipes.get(e.recipe) : null; + const hasProcess = !!recipe; + + style(noRecipe, 'display', hasProcess ? 'none' : 'block'); + for (const n of [outHead, outChips.el, progHead, progBar]) { + style(n, 'display', hasProcess ? '' : 'none'); + } + + // Extractors take nothing from the world but the world itself — no INTAKE header + // for a machine that has no intake. + const hasInputs = !!recipe && Object.keys(recipe.inputs).length > 0; + for (const n of [inHead, inChips.el]) style(n, 'display', hasInputs ? '' : 'none'); + + if (recipe) { + const ins: ChipSpec[] = Object.entries(recipe.inputs).map(([item, need]) => { + const have = e.inputBuf[item] ?? 0; + return { item, count: `${have}/${need}`, state: have >= need ? 'met' : 'short' }; + }); + inChips.update(ins); + + // Recipe outputs first, then anything else the buffer is holding (a jam is often + // an item the recipe never mentions). + const outIds = new Set(Object.keys(recipe.outputs)); + for (const id of Object.keys(e.outputBuf)) outIds.add(id); + outChips.update( + [...outIds].map((item) => ({ item, count: String(e.outputBuf[item] ?? 0) })), + ); + + style(progFill, 'width', `${Math.max(0, Math.min(1, e.progress)) * 100}%`); + } + + const hot = e.heat > 0.01; + style(heatRow, 'display', hot ? '' : 'none'); + if (hot) { + text(heatVal, `${(e.heat * 100).toFixed(0)}%${e.heat > 0.7 ? ' · THROTTLING' : ''}`); + style(heatVal, 'color', e.heat > 0.7 ? 'var(--fk-red)' : 'var(--fk-amber)'); + } + + const jammed = !!e.jammed; + cls(jam, 'is-on', jammed); + if (jammed) text(jam, COPY.inspectorJamPrefix + e.jammed!.toUpperCase()); + } + + return { + el: root, + open(entityId) { + openId = entityId; + cls(root, 'is-open', true); + }, + close, + isOpen: () => openId !== null, + update(snap) { + if (openId === null) return; + const e = snap.entities.find((x) => x.id === openId); + if (!e) return close(); // unit was removed out from under us + paint(e); + }, + }; +} diff --git a/fktry/src/ui/toasts.ts b/fktry/src/ui/toasts.ts new file mode 100644 index 0000000..b39b525 --- /dev/null +++ b/fktry/src/ui/toasts.ts @@ -0,0 +1,49 @@ +/** + * LANE-UI — event toasts, bottom right. Max 4 on screen, 6s each, oldest evicted. + * + * Wall-clock timing is fine here: toasts are chrome, not sim. The determinism rule in + * MASTERPLAN binds `src/sim/**`, and nothing in this file feeds back into it. + */ +import type { SimEvent } from '../contracts'; +import { cls, el } from './dom'; +import { eventToast } from './voice'; + +export interface Toasts { + el: HTMLElement; + push(ev: SimEvent, machineName: (entityId: number) => string): void; + /** Called once per frame to expire old lines. */ + update(nowMs: number): void; +} + +const TTL_MS = 6000; +const FADE_MS = 400; // must match the .fk-toast.is-out transition +const MAX = 4; + +export function createToasts(): Toasts { + const root = el('div'); + root.id = 'fk-toasts'; + + const live: Array<{ node: HTMLElement; dieAt: number }> = []; + + function retire(entry: { node: HTMLElement; dieAt: number }) { + cls(entry.node, 'is-out', true); + setTimeout(() => entry.node.remove(), FADE_MS); + } + + return { + el: root, + push(ev, machineName) { + const msg = eventToast(ev, machineName); + if (!msg) return; + + const node = el('div', `fk-toast k-${ev.kind}`, [msg]); + root.append(node); + live.push({ node, dieAt: performance.now() + TTL_MS }); + + while (live.length > MAX) retire(live.shift()!); + }, + update(nowMs) { + while (live.length && live[0].dieAt <= nowMs) retire(live.shift()!); + }, + }; +} diff --git a/fktry/src/ui/topstrip.ts b/fktry/src/ui/topstrip.ts new file mode 100644 index 0000000..e72ee86 --- /dev/null +++ b/fktry/src/ui/topstrip.ts @@ -0,0 +1,84 @@ +/** + * LANE-UI — top strip: bandwidth meter, shipped ticker, run state. + * + * The brownout has to be legible from this panel alone — it is the one failure the + * player must read across the room, so it gets the border, the color, and the flash. + */ +import type { GameData, ItemDef, SimSnapshot } from '../contracts'; +import { createChipRow, type ChipRow } from './chips'; +import { cls, el, panel, style, text } from './dom'; +import { COPY } from './voice'; + +export interface TopStrip { + el: HTMLElement; + update(snap: SimSnapshot): void; +} + +/** Draw above this fraction of generation is "tight" — amber, before it's a fault. */ +const TIGHT_AT = 0.85; + +function stat(label: string, valueCls?: string): { row: HTMLElement; v: HTMLElement } { + const v = el('span', valueCls ? `fk-stat-v ${valueCls}` : 'fk-stat-v'); + const row = el('div', 'fk-stat', [el('span', undefined, [label]), v]); + return { row, v }; +} + +export function createTopStrip(data: GameData): TopStrip { + const root = panel(); + root.id = 'fk-top'; + + const head = el('div', 'fk-h', [COPY.bandwidth]); + + const drawEl = el('span', 'fk-bw-draw'); + const genEl = el('span'); + const nums = el('div', 'fk-bw-nums', [drawEl, genEl]); + + const fill = el('div', 'fk-bar-f'); + const bar = el('div', 'fk-bar', [fill]); + + const brownout = el('div'); + brownout.id = 'fk-brownout'; + brownout.textContent = COPY.brownout; + + const buffer = stat('BUFFER', 'fk-buf'); + const shipped = stat(COPY.shipped); + const run = stat(COPY.tick); + run.v.id = 'fk-run'; + + const items = new Map(data.items.map((i) => [i.id, i])); + const chips: ChipRow = createChipRow(items); + + root.append(head, nums, bar, brownout, el('div', 'fk-rule'), + buffer.row, shipped.row, run.row, chips.el); + + return { + el: root, + update(snap) { + const { gen, draw, stored, brownout: out } = snap.bandwidth; + + text(drawEl, `${draw.toFixed(0)} DRAW`); + text(genEl, `${gen.toFixed(0)} GEN`); + + // With no generators at all, any draw is already over budget. + const ratio = gen > 0 ? draw / gen : draw > 0 ? Infinity : 0; + style(fill, 'width', `${Math.min(1, ratio) * 100}%`); + cls(root, 'is-tight', !out && ratio >= TIGHT_AT); + cls(root, 'is-brownout', out || ratio > 1); + + text(buffer.v, stored.toFixed(0)); + + let total = 0; + for (const n of Object.values(snap.shippedTotal)) total += n; + text(shipped.v, String(total)); + + text(run.v, snap.paused ? COPY.paused : `${snap.tick}`); + cls(run.v, 'is-paused', snap.paused); + + chips.update( + Object.entries(snap.shippedTotal) + .filter(([, n]) => n > 0) + .map(([item, n]) => ({ item, count: String(n), state: 'met' as const })), + ); + }, + }; +}