glytch/fktry/src/ui/inspector.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

131 lines
4.6 KiB
TypeScript

/**
* 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<string, MachineDef>(data.machines.map((m) => [m.id, m]));
const recipes = new Map<string, RecipeDef>(data.recipes.map((r) => [r.id, r]));
const items = new Map<string, ItemDef>(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);
},
};
}