From d017e710adfdee85914f62949459fe5cbace8a35 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 16:15:38 +1000 Subject: [PATCH] [ui] HUD foundation: voice, style, chips, hotkeys, selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- fktry/src/ui/chips.ts | 73 ++++++++++++ fktry/src/ui/dom.ts | 40 +++++++ fktry/src/ui/hotkeys.ts | 60 ++++++++++ fktry/src/ui/palette.ts | 38 ++++++ fktry/src/ui/pick.ts | 96 +++++++++++++++ fktry/src/ui/selection.ts | 48 ++++++++ fktry/src/ui/style.ts | 242 ++++++++++++++++++++++++++++++++++++++ fktry/src/ui/voice.ts | 97 +++++++++++++++ 8 files changed, 694 insertions(+) create mode 100644 fktry/src/ui/chips.ts create mode 100644 fktry/src/ui/dom.ts create mode 100644 fktry/src/ui/hotkeys.ts create mode 100644 fktry/src/ui/palette.ts create mode 100644 fktry/src/ui/pick.ts create mode 100644 fktry/src/ui/selection.ts create mode 100644 fktry/src/ui/style.ts create mode 100644 fktry/src/ui/voice.ts diff --git a/fktry/src/ui/chips.ts b/fktry/src/ui/chips.ts new file mode 100644 index 0000000..ca51688 --- /dev/null +++ b/fktry/src/ui/chips.ts @@ -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 = { name: '???', color: '#6a8a6a' }; + +export function createChipRow(items: Map): ChipRow { + const root = el('div', 'fk-chips'); + const live = new Map(); + + return { + el: root, + update(specs) { + const seen = new Set(); + + 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); + } + }, + }; +} diff --git a/fktry/src/ui/dom.ts b/fktry/src/ui/dom.ts new file mode 100644 index 0000000..56e9869 --- /dev/null +++ b/fktry/src/ui/dom.ts @@ -0,0 +1,40 @@ +/** LANE-UI — tiny DOM helpers. No framework, no dependencies, no apologies. */ + +type Attrs = Record; + +/** Create an element. `cls` is space-separated; children may be nodes or strings. */ +export function el( + tag: K, + cls?: string, + children?: Array, +): 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(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'); +} diff --git a/fktry/src/ui/hotkeys.ts b/fktry/src/ui/hotkeys.ts new file mode 100644 index 0000000..f28fe2e --- /dev/null +++ b/fktry/src/ui/hotkeys.ts @@ -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(); + + 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); + }, + }; +} diff --git a/fktry/src/ui/palette.ts b/fktry/src/ui/palette.ts new file mode 100644 index 0000000..1513f31 --- /dev/null +++ b/fktry/src/ui/palette.ts @@ -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 = { + '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 = { + 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'; +} diff --git a/fktry/src/ui/pick.ts b/fktry/src/ui/pick.ts new file mode 100644 index 0000000..d4cd0e7 --- /dev/null +++ b/fktry/src/ui/pick.ts @@ -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). Tile→entity 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, + 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; +} diff --git a/fktry/src/ui/selection.ts b/fktry/src/ui/selection.ts new file mode 100644 index 0000000..98c52e8 --- /dev/null +++ b/fktry/src/ui/selection.ts @@ -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); + }, + }; +} diff --git a/fktry/src/ui/style.ts b/fktry/src/ui/style.ts new file mode 100644 index 0000000..ed9b4a4 --- /dev/null +++ b/fktry/src/ui/style.ts @@ -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; +} diff --git a/fktry/src/ui/voice.ts b/fktry/src/ui/voice.ts new file mode 100644 index 0000000..dc4d5fb --- /dev/null +++ b/fktry/src/ui/voice.ts @@ -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 = { + '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;