[ui] HUD foundation: voice, style, chips, hotkeys, selection

Shared plumbing for the DOM HUD, no framework:

- voice.ts: every player-facing string, so the OSHA-poster deadpan
  register lives in one place instead of being re-invented per panel.
- style.ts: injected terminal-industrial stylesheet. Panels re-enable
  pointer-events; #ui itself stays transparent to the mouse.
- chips.ts: the item chip (swatch + name + count), keyed off GameData
  so new items from LANE-DATA light up with no code change.
- hotkeys.ts: the whole keymap in one file, per lane orders.
- selection.ts: pending build selection, written through to bus._sel.
- pick.ts: tile -> entity hit-testing plus the flattened roster the UI
  keeps instead of holding a snapshot across frames (CONTRACTS says sim
  may reuse those buffers, and input handlers fire between frames).
- palette.ts: machine accent colors. Stopgap — MachineDef has no color
  field; contract request filed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 16:15:38 +10:00
parent 5a85360ef9
commit d017e710ad
8 changed files with 694 additions and 0 deletions

73
fktry/src/ui/chips.ts Normal file
View File

@ -0,0 +1,73 @@
/**
* LANE-UI item chips. A chip is a color swatch + item name + a count, and it is the
* only way items are ever shown in the HUD (inspector buffers, recipe I/O, fax wants).
*
* Colors and names come from GameData.items nothing here hard-codes an item id, so
* new items from LANE-DATA light up with zero code (CONTRACTS "IDs are data-driven").
*/
import type { ItemDef } from '../contracts';
import { attrs, el, text } from './dom';
export type ChipState = 'plain' | 'short' | 'met';
export interface ChipSpec {
item: string;
/** Right-hand count text, e.g. "3" or "3/40". */
count: string;
state?: ChipState;
}
export interface ChipRow {
el: HTMLElement;
update(specs: ChipSpec[]): void;
}
/** Items the codex hasn't taught us about yet still get a chip, just a drab one. */
const UNKNOWN: Pick<ItemDef, 'name' | 'color'> = { name: '???', color: '#6a8a6a' };
export function createChipRow(items: Map<string, ItemDef>): ChipRow {
const root = el('div', 'fk-chips');
const live = new Map<string, { chip: HTMLElement; sw: HTMLElement; n: HTMLElement }>();
return {
el: root,
update(specs) {
const seen = new Set<string>();
for (const spec of specs) {
seen.add(spec.item);
let entry = live.get(spec.item);
if (!entry) {
const def = items.get(spec.item) ?? UNKNOWN;
const sw = el('span', 'fk-chip-sw');
sw.style.background = def.color;
const n = el('span', 'fk-chip-n');
const chip = el('span', 'fk-chip', [sw, def.name, n]);
attrs(chip, { title: spec.item });
entry = { chip, sw, n };
live.set(spec.item, entry);
root.appendChild(chip);
}
text(entry.n, spec.count);
// One class per state; className assignment is cheap and avoids stale flags.
const state = spec.state ?? 'plain';
const want = state === 'plain' ? 'fk-chip' : `fk-chip is-${state}`;
if (entry.chip.className !== want) entry.chip.className = want;
}
for (const [id, entry] of live) {
if (seen.has(id)) continue;
entry.chip.remove();
live.delete(id);
}
// Keep DOM order matching spec order (chips are few; this is cheap).
for (const spec of specs) {
const entry = live.get(spec.item);
if (entry) root.appendChild(entry.chip);
}
},
};
}

40
fktry/src/ui/dom.ts Normal file
View File

@ -0,0 +1,40 @@
/** LANE-UI — tiny DOM helpers. No framework, no dependencies, no apologies. */
type Attrs = Record<string, string>;
/** Create an element. `cls` is space-separated; children may be nodes or strings. */
export function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
children?: Array<Node | string>,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (cls) node.className = cls;
if (children) for (const c of children) node.append(c);
return node;
}
export function attrs<T extends HTMLElement>(node: T, a: Attrs): T {
for (const [k, v] of Object.entries(a)) node.setAttribute(k, v);
return node;
}
/** Set textContent only when it changed — update() runs every frame. */
export function text(node: HTMLElement, s: string): void {
if (node.textContent !== s) node.textContent = s;
}
/** Toggle a class only when it changed. */
export function cls(node: HTMLElement, name: string, on: boolean): void {
if (node.classList.contains(name) !== on) node.classList.toggle(name, on);
}
/** Set an inline style property only when it changed. */
export function style(node: HTMLElement, prop: string, value: string): void {
if (node.style.getPropertyValue(prop) !== value) node.style.setProperty(prop, value);
}
/** A panel: the standard bordered industrial box everything lives in. */
export function panel(cls?: string): HTMLDivElement {
return el('div', cls ? `fk-panel ${cls}` : 'fk-panel');
}

60
fktry/src/ui/hotkeys.ts Normal file
View File

@ -0,0 +1,60 @@
/**
* LANE-UI the keyboard map. ALL bindings live here; this file will grow.
*
* Round 1 map:
* 1..9 select build slot N (build bar)
* R rotate pending build dir
* ESC clear selection / close inspector
* SPACE toggle HALT
* TAB cycle the unit manifest (temporary see NOTES: no pickTile in UIBus)
*
* WASD and the middle-drag pan belong to LANE-RENDER's camera; nothing here binds them.
* Keys are matched on `event.key`, lowercased, so layout-shifted digits still work.
*/
export type HotkeyHandler = (ev: KeyboardEvent) => void;
export interface Hotkeys {
/** `key` is a lowercased `event.key`, e.g. "r", "escape", " ", "tab", "1". */
bind(key: string, handler: HotkeyHandler): void;
attach(): void;
detach(): void;
}
/** Don't steal keys while the player is typing into something. */
function isTypingTarget(t: EventTarget | null): boolean {
if (!(t instanceof HTMLElement)) return false;
return t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName);
}
/** Keys we always swallow so the browser doesn't scroll/focus-walk the page. */
const SWALLOW = new Set([' ', 'tab']);
export function createHotkeys(): Hotkeys {
const map = new Map<string, HotkeyHandler>();
const onKeyDown = (ev: KeyboardEvent) => {
if (isTypingTarget(ev.target)) return;
// Let the browser keep its own shortcuts (⌘R, ctrl-tab, …).
if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
const key = ev.key.toLowerCase();
const handler = map.get(key);
if (!handler) return;
if (SWALLOW.has(key)) ev.preventDefault();
handler(ev);
};
return {
bind(key, handler) {
map.set(key.toLowerCase(), handler);
},
attach() {
window.addEventListener('keydown', onKeyDown);
},
detach() {
window.removeEventListener('keydown', onKeyDown);
},
};
}

38
fktry/src/ui/palette.ts Normal file
View File

@ -0,0 +1,38 @@
/**
* LANE-UI machine accent colors for build-bar icons.
*
* `ItemDef` carries a `color`; `MachineDef` does not, so the build bar has nothing
* data-driven to tint with. These are transcribed from docs/FKTRY_LORE.md §4 (each
* machine's ASSET block names its impossible element) as a stopgap.
*
* CONTRACT REQUEST filed in NOTES: add `MachineDef.color` LANE-RENDER needs the same
* value for its placeholder box tint, and two lanes hand-maintaining the same table is
* how the build bar and the world end up disagreeing about what a quantizer looks like.
* Until then: id override, else kind fallback, else house green. Never crashes on a
* machine we've never heard of.
*/
import type { MachineDef, MachineKind } from '../contracts';
const BY_ID: Record<string, string> = {
'seam-extractor': '#c8a03f', // grimy yellow chassis
belt: '#5a7a5a',
demuxer: '#ff3fd4', // the magenta output chute
quantizer: '#e8e0ff', // HF dust leaking from the seams
'mosh-reactor': '#ff7a3f', // melt, seen through the porthole
'decode-asic': '#3fffe0', // serene LED face
shipper: '#d8ffd8', // THE SCREEN's own light
};
const BY_KIND: Record<MachineKind, string> = {
extractor: '#c8a03f',
belt: '#5a7a5a',
crafter: '#3fffe0',
splitter: '#7fb0ff',
power: '#3fffe0',
buffer: '#7fb0ff',
shipper: '#d8ffd8',
};
export function machineColor(def: MachineDef): string {
return BY_ID[def.id] ?? BY_KIND[def.kind] ?? '#d8ffd8';
}

96
fktry/src/ui/pick.ts Normal file
View File

@ -0,0 +1,96 @@
/**
* LANE-UI figuring out which unit the player clicked.
*
* PROBLEM: `UIBus` gives us `dispatch` + `selectedBuild`, and `ui.init(root, data, bus)`
* never sees the Renderer so the UI has no access to `renderer.pickTile`, which is the
* only thing that knows the camera. CONTRACT REQUEST filed in NOTES (add `pickTile` to
* UIBus, or pass the renderer to `ui.init`).
*
* Until then: we opportunistically use `window.__fktryRenderer.pickTile` if LANE-RENDER
* happens to expose one, and the inspector otherwise falls back to TAB-cycling the unit
* manifest (see index.ts). Tileentity resolution below is ours either way.
*/
import type { Dir, MachineDef, SimSnapshot, Vec2 } from '../contracts';
export type PickTile = (clientX: number, clientY: number) => Vec2 | null;
export function resolvePickTile(): PickTile | null {
const r = (globalThis as any).__fktryRenderer;
if (r && typeof r.pickTile === 'function') {
return (x, y) => r.pickTile(x, y) ?? null;
}
return null;
}
/**
* Flattened copy of the entities in a snapshot: exactly the fields the UI needs between
* frames (hit-testing a click, naming a jammed unit in a toast).
*
* CONTRACTS: "Sim may reuse internal buffers between snapshots; consumers must not hold
* references across frames." Pointer and key handlers fire *between* frames, so the UI
* keeps this copy instead of the snapshot. The backing array is reused a 500-entity
* factory must not allocate 500 objects every frame just to be polite.
*/
export interface RosterEntry {
id: number;
def: string;
x: number;
y: number;
dir: Dir;
}
export interface Roster {
entries: RosterEntry[];
length: number;
}
export function createRoster(): Roster {
return { entries: [], length: 0 };
}
export function syncRoster(roster: Roster, snap: SimSnapshot): void {
const n = snap.entities.length;
for (let i = 0; i < n; i++) {
const e = snap.entities[i];
let r = roster.entries[i];
if (!r) {
r = { id: 0, def: '', x: 0, y: 0, dir: 0 };
roster.entries[i] = r;
}
r.id = e.id;
r.def = e.def;
r.x = e.pos.x;
r.y = e.pos.y;
r.dir = e.dir;
}
roster.length = n;
}
/**
* Footprint in world tiles after rotation. CONTRACTS: `Dir` 0..3 = N,E,S,W clockwise,
* `pos` is the origin corner (min x, min y), so E/W rotations swap width and height.
*/
export function footprintOf(def: MachineDef, dir: Dir): Vec2 {
return dir === 1 || dir === 3
? { x: def.footprint.y, y: def.footprint.x }
: { x: def.footprint.x, y: def.footprint.y };
}
/** Entity id under `tile`, or null. */
export function entityAt(
roster: Roster,
machines: Map<string, MachineDef>,
tile: Vec2,
): number | null {
// Reverse order: later-placed units sit on top, which is what the eye expects.
for (let i = roster.length - 1; i >= 0; i--) {
const r = roster.entries[i];
const def = machines.get(r.def);
if (!def) continue;
const fp = footprintOf(def, r.dir);
if (tile.x >= r.x && tile.x < r.x + fp.x && tile.y >= r.y && tile.y < r.y + fp.y) {
return r.id;
}
}
return null;
}

48
fktry/src/ui/selection.ts Normal file
View File

@ -0,0 +1,48 @@
/**
* LANE-UI the pending build selection.
*
* main.ts reads this through `bus.selectedBuild()` for its ghost + click-to-place glue,
* and the hook it documents is the `_sel` field on the bus object. UI is the only writer;
* we write through to `_sel` on every change and treat our own copy as the truth.
*/
import type { Dir, UIBus } from '../contracts';
export interface BuildSelection {
get(): { def: string; dir: Dir } | null;
/** Select a machine; selecting the one already selected clears it (toggle). */
select(def: string): void;
rotate(): void;
clear(): void;
onChange(cb: () => void): void;
}
type BusWithSel = UIBus & { _sel: { def: string; dir: Dir } | null };
export function createSelection(bus: UIBus): BuildSelection {
const hook = bus as BusWithSel;
let sel: { def: string; dir: Dir } | null = null;
const listeners: Array<() => void> = [];
function commit(next: { def: string; dir: Dir } | null) {
sel = next;
hook._sel = next;
for (const cb of listeners) cb();
}
return {
get: () => sel,
select(def) {
if (sel?.def === def) commit(null);
else commit({ def, dir: sel?.dir ?? 0 }); // keep the dir across swaps
},
rotate() {
if (sel) commit({ def: sel.def, dir: ((sel.dir + 1) % 4) as Dir });
},
clear() {
if (sel) commit(null);
},
onChange(cb) {
listeners.push(cb);
},
};
}

242
fktry/src/ui/style.ts Normal file
View File

@ -0,0 +1,242 @@
/**
* LANE-UI injected stylesheet. Terminal-industrial: monospace, phosphor green on
* near-black, hot accents, hard corners. This is machinery, not a startup.
*
* Every interactive element re-enables pointer-events; `#ui` itself stays transparent
* to the mouse so clicks fall through to the world.
*/
const CSS = `
#ui {
--fk-fg: #d8ffd8;
--fk-dim: #6a8a6a;
--fk-faint: #3d523d;
--fk-bg: #0d0b16;
--fk-bg-solid: #0a0812;
--fk-line: #2a2440;
--fk-hot: #ff3fd4;
--fk-cool: #3fffe0;
--fk-amber: #ffb03f;
--fk-red: #ff3f4a;
font-family: "Courier New", ui-monospace, monospace;
font-size: 12px;
line-height: 1.35;
color: var(--fk-fg);
text-transform: uppercase;
letter-spacing: 0.04em;
user-select: none;
}
/* ---------------------------------------------------------------- primitives */
.fk-panel {
position: fixed;
background: var(--fk-bg);
border: 1px solid var(--fk-line);
padding: 6px 8px;
pointer-events: auto;
}
.fk-h {
color: var(--fk-dim);
font-size: 10px;
letter-spacing: 0.18em;
margin-bottom: 4px;
}
.fk-rule { height: 1px; background: var(--fk-line); margin: 6px 0; }
.fk-chip {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid var(--fk-line);
padding: 1px 4px 1px 2px;
margin: 2px 3px 2px 0;
font-size: 11px;
white-space: nowrap;
}
/* MDAT ore is near-black by design; without the outline its chip reads as empty. */
.fk-chip-sw {
width: 8px; height: 8px; flex: 0 0 8px;
outline: 1px solid rgba(216, 255, 216, 0.18);
outline-offset: -1px;
}
.fk-chip-n { color: var(--fk-dim); }
.fk-chip.is-short .fk-chip-n { color: var(--fk-amber); }
.fk-chip.is-met .fk-chip-n { color: var(--fk-cool); }
.fk-bar { height: 6px; background: var(--fk-bg-solid); border: 1px solid var(--fk-line); }
.fk-bar-f { height: 100%; width: 0%; background: var(--fk-cool); transition: width 90ms linear; }
/* ---------------------------------------------------------------- top strip */
#fk-top {
top: 10px; left: 10px;
width: 300px;
z-index: 11;
}
.fk-bw-nums { display: flex; justify-content: space-between; font-size: 11px; }
.fk-bw-draw { color: var(--fk-fg); }
#fk-top.is-tight .fk-bar-f { background: var(--fk-amber); }
#fk-top.is-brownout { border-color: var(--fk-red); animation: fk-flash 0.45s steps(2) infinite; }
#fk-top.is-brownout .fk-bar-f { background: var(--fk-red); }
#fk-brownout {
display: none;
color: var(--fk-red);
font-weight: bold;
letter-spacing: 0.2em;
margin-top: 3px;
}
#fk-top.is-brownout #fk-brownout { display: block; }
@keyframes fk-flash { 0% { background: var(--fk-bg); } 50% { background: #2a0d12; } }
.fk-stat { display: flex; justify-content: space-between; font-size: 11px; }
.fk-stat-v { color: var(--fk-cool); }
#fk-run.is-paused { color: var(--fk-amber); }
/* ---------------------------------------------------------------- build bar */
#fk-build {
bottom: 10px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center;
padding: 6px;
max-width: calc(100vw - 20px);
}
/* LANE-DATA shipped 21 machines; the bar wraps rather than running off the viewport.
Paging/tabs proposed for a later round see NOTES. */
.fk-build-row {
display: flex; gap: 4px;
flex-wrap: wrap;
justify-content: center;
}
.fk-build-btn {
position: relative;
width: 58px;
background: var(--fk-bg-solid);
border: 1px solid var(--fk-line);
color: var(--fk-dim);
font: inherit;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.02em;
padding: 4px 2px 3px;
cursor: pointer;
pointer-events: auto;
}
.fk-build-btn:hover { border-color: var(--fk-cool); color: var(--fk-fg); }
.fk-build-btn.is-sel { border-color: var(--fk-hot); color: var(--fk-fg); background: #1c0a20; }
.fk-build-btn.is-sel .fk-build-ico { box-shadow: 0 0 6px var(--fk-hot); }
.fk-build-ico { width: 100%; height: 18px; margin-bottom: 3px; }
.fk-build-key {
position: absolute; top: 1px; right: 2px;
font-size: 8px; color: var(--fk-faint);
}
.fk-build-btn.is-sel .fk-build-key { color: var(--fk-hot); }
.fk-build-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#fk-build-hint { color: var(--fk-faint); font-size: 9px; margin-top: 5px; letter-spacing: 0.1em; }
#fk-build-hint.is-placing { color: var(--fk-hot); }
/* ---------------------------------------------------------------- inspector */
#fk-inspect {
display: none;
top: 210px; left: 10px;
width: 300px;
max-height: calc(100vh - 300px);
overflow-y: auto;
z-index: 11;
}
#fk-inspect.is-open { display: block; }
.fk-ins-head { display: flex; justify-content: space-between; align-items: baseline; gap: 6px; }
.fk-ins-name { color: var(--fk-cool); letter-spacing: 0.08em; }
.fk-ins-x {
background: none; border: 1px solid var(--fk-line); color: var(--fk-dim);
font: inherit; font-size: 10px; line-height: 1; padding: 2px 5px; cursor: pointer;
}
.fk-ins-x:hover { border-color: var(--fk-hot); color: var(--fk-hot); }
.fk-ins-flavor {
color: var(--fk-dim); font-size: 10px; text-transform: none; letter-spacing: 0;
font-style: italic; margin: 5px 0;
}
.fk-ins-jam { display: none; color: var(--fk-amber); font-size: 11px; margin-top: 5px; }
.fk-ins-jam.is-on { display: block; }
.fk-ins-empty { color: var(--fk-faint); font-size: 10px; }
/* ---------------------------------------------------------------- fax */
#fk-fax {
display: none;
top: 200px; right: 10px;
width: 260px;
background: #12100a;
border-color: #4a4230;
z-index: 11;
}
#fk-fax.is-open { display: block; }
/* fax paper: warm, torn-off, faintly ruled */
#fk-fax::after {
content: ""; position: absolute; left: 0; right: 0; bottom: -5px; height: 5px;
background: repeating-linear-gradient(90deg, #12100a 0 4px, transparent 4px 8px);
}
.fk-fax-h { color: #a09468; }
.fk-fax-sub { color: #6a6048; font-size: 9px; letter-spacing: 0.1em; }
.fk-fax-flavor {
color: #e8dcb0; font-size: 11px; text-transform: none; letter-spacing: 0;
margin: 6px 0; line-height: 1.4;
}
#fk-fax .fk-chip { border-color: #4a4230; }
#fk-fax .fk-chip-n { color: #a09468; }
#fk-fax .fk-chip.is-met .fk-chip-n { color: var(--fk-cool); }
.fk-fax-stamp {
position: absolute; inset: 0;
display: none; align-items: center; justify-content: center;
pointer-events: none;
}
.fk-fax-stamp.is-on { display: flex; animation: fk-stamp 1.8s ease-out forwards; }
.fk-fax-stamp span {
border: 3px solid var(--fk-red); color: var(--fk-red);
font-size: 22px; font-weight: bold; letter-spacing: 0.1em;
padding: 4px 10px; transform: rotate(-11deg);
}
/* aggressively mundane: it just lands, once, and stays there. */
@keyframes fk-stamp {
0% { opacity: 0; transform: scale(2.4); }
12% { opacity: 1; transform: scale(1); }
14% { transform: scale(1.04); }
16% { transform: scale(1); }
85% { opacity: 1; }
100% { opacity: 0; }
}
/* ---------------------------------------------------------------- toasts */
#fk-toasts {
position: fixed; bottom: 10px; right: 10px;
display: flex; flex-direction: column; align-items: flex-end; gap: 4px;
pointer-events: none;
z-index: 11;
}
.fk-toast {
background: var(--fk-bg); border: 1px solid var(--fk-line);
border-left: 3px solid var(--fk-dim);
padding: 4px 8px; font-size: 10px; max-width: 320px;
animation: fk-toast-in 140ms ease-out;
}
.fk-toast.is-out { opacity: 0; transition: opacity 400ms linear; }
.fk-toast.k-jammed { border-left-color: var(--fk-amber); color: var(--fk-amber); }
.fk-toast.k-brownout { border-left-color: var(--fk-red); color: var(--fk-red); }
.fk-toast.k-commissionDone { border-left-color: var(--fk-cool); color: var(--fk-cool); }
@keyframes fk-toast-in { from { opacity: 0; transform: translateX(12px); } }
`;
let injected = false;
export function injectStyle(): void {
if (injected) return;
const tag = document.createElement('style');
tag.id = 'fk-ui-style';
tag.textContent = CSS;
document.head.appendChild(tag);
injected = true;
}

97
fktry/src/ui/voice.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* LANE-UI the house voice.
*
* Register: OSHA-poster deadpan (lore §8). Everything horrifying is labelled like
* routine industrial equipment. Nothing here apologises, explains itself, or uses an
* exclamation mark. The player is an employee who has already read the placard.
*
* All copy in the UI comes from this file so the tone stays in one place.
*/
import type { SimEvent } from '../contracts';
/**
* Flavor lines for the inspector, keyed by MachineDef.id.
*
* TEMPORARY: hand-transcribed from docs/FKTRY_LORE.md §4 by LANE-UI. These belong in
* data/machines.json as a `flavor` field owned by LANE-DATA CONTRACT REQUEST filed in
* NOTES. Unknown ids fall back to `GENERIC_FLAVOR`, so new machines never crash the
* panel, they just sound like a filing cabinet until the codex line lands.
*/
const MACHINE_FLAVOR: Record<string, string> = {
'seam-extractor':
'Chews the payload seam and spits raw ore. Do not place hands in the seam. ' +
'The seam has no policy regarding hands.',
belt:
'Moves things. Moving things is the entire job. Back-pressure is a normal ' +
'operating condition and is not a grievance.',
demuxer:
'Cleaves raw ore into luma and chroma. One intake, four chutes, no apologies. ' +
'Contents were never yours.',
quantizer:
'Rounds coefficients to zero. The dial goes to CRIME. Material leaking from the ' +
'seams is HF DUST and is not glitter. Do not lick the machine.',
'mosh-reactor':
'A decoder sealed in a vessel and starved of anchors until it hallucinates. ' +
'The hallucination is the product. Certified vessel. Uncertified hallucination.',
'decode-asic':
'Handles standard profiles only. Cool, efficient, incurious. Generates bandwidth ' +
'by declining to be interesting.',
shipper:
'Composites shipped freight onto THE SCREEN. All deliveries final. ' +
'All sins permanent. Congratulations.',
};
const GENERIC_FLAVOR =
'Industrial equipment. Operates within tolerances. Tolerances not disclosed.';
export function machineFlavor(defId: string): string {
return MACHINE_FLAVOR[defId] ?? GENERIC_FLAVOR;
}
/** Toast copy for events the player should notice without reading a manual. */
export function eventToast(ev: SimEvent, machineName: (id: number) => string): string | null {
switch (ev.kind) {
case 'jammed':
return `${machineName(ev.entity)} HAS STOPPED. ${ev.reason.toUpperCase()}.`;
case 'brownout':
return ev.on
? 'BUFFER UNDERRUN. THIS SECTOR IS NOW A STILL LIFE.'
: 'BUFFER RESTORED. RESUME SINNING.';
case 'commissionDone':
return 'FAX SENT. THE COLLECTOR DOES NOT SAY THANK YOU.';
default:
return null; // shipped/crafted/placed/removed are too frequent to toast
}
}
export const COPY = {
bandwidth: 'BANDWIDTH',
brownout: 'BROWNOUT',
shipped: 'SHIPPED',
paused: 'HALTED',
running: 'RUNNING',
tick: 'TICK',
buildBarTitle: 'REQUISITION',
inspectorEmptyRecipe: 'NO PROCESS ASSIGNED. UNIT IS BEING PAID TO EXIST.',
inspectorInput: 'INTAKE',
inspectorOutput: 'OUTPUT',
inspectorProgress: 'PROCESS',
inspectorJamPrefix: 'FAULT: ',
inspectorClose: 'X',
faxHeader: 'INCOMING FAX',
faxSubheader: 'GLITCH ACQUISITIONS — UNSOLICITED',
faxNoCommission: 'NO ACTIVE COMMISSION. THE FAX MACHINE IS RESTING.',
faxStamp: 'FAX SENT',
/** Shown on the build bar when the player has something selected. */
placingHint: 'LMB PLACE · R ROTATE · ESC CANCEL',
idleHint: '1-9 SELECT · SPACE HALT · TAB MANIFEST',
/** Inspector fallback when no entity is under the cursor. */
manifestEmpty: 'NO UNITS ON RECORD. THE FLOOR IS AUDITED AND EMPTY.',
} as const;
/** Direction names for the rotation readout. Dir 0..3 = N,E,S,W clockwise. */
export const DIR_NAME = ['N', 'E', 'S', 'W'] as const;