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>
131 lines
4.6 KiB
TypeScript
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);
|
|
},
|
|
};
|
|
}
|