[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>
This commit is contained in:
parent
d017e710ad
commit
c18309f464
75
fktry/src/ui/buildbar.ts
Normal file
75
fktry/src/ui/buildbar.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
70
fktry/src/ui/fax.ts
Normal file
70
fktry/src/ui/fax.ts
Normal file
@ -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<string, CommissionDef>(data.commissions.map((c) => [c.id, c]));
|
||||
const items = new Map<string, ItemDef>(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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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<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) {
|
||||
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());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
130
fktry/src/ui/inspector.ts
Normal file
130
fktry/src/ui/inspector.ts
Normal file
@ -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<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);
|
||||
},
|
||||
};
|
||||
}
|
||||
49
fktry/src/ui/toasts.ts
Normal file
49
fktry/src/ui/toasts.ts
Normal file
@ -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()!);
|
||||
},
|
||||
};
|
||||
}
|
||||
84
fktry/src/ui/topstrip.ts
Normal file
84
fktry/src/ui/topstrip.ts
Normal file
@ -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<string, ItemDef>(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 })),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user