Compare commits
4 Commits
5a85360ef9
...
e1206fea78
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1206fea78 | ||
|
|
df1eee7249 | ||
|
|
c18309f464 | ||
|
|
d017e710ad |
@ -63,3 +63,90 @@ NOTES written.
|
||||
### Round N — YYYY-MM-DD — [your model]
|
||||
SHIPPED: / DECISIONS: / BLOCKED/BROKEN: / CONTRACT REQUEST: / PROPOSAL: / NEXT:
|
||||
```
|
||||
|
||||
### Round 1 — 2026-07-17 — Opus 4.8
|
||||
|
||||
**SHIPPED:** All five ordered surfaces, in `src/ui/**`, verified in a browser (screenshots
|
||||
taken of each). Modules: `index.ts` (root/wiring), `style.ts` (injected CSS), `voice.ts`
|
||||
(all player-facing copy), `hotkeys.ts` (the keymap, one file as ordered), `buildbar.ts`,
|
||||
`topstrip.ts`, `inspector.ts`, `fax.ts`, `toasts.ts`, `chips.ts` (shared item chip),
|
||||
`selection.ts` (writes `bus._sel`), `palette.ts`, `pick.ts`, `dom.ts`, `fixture.ts` (dev).
|
||||
|
||||
1. Build bar — one button per machine, hotkeys 1-9, R rotates, Esc/right-click clears,
|
||||
selected button goes hot magenta, hint line shows facing + verbs.
|
||||
2. Top strip — bandwidth meter (cyan → amber ≥85% → red + flashing BROWNOUT over 100%),
|
||||
buffer, shipped ticker + per-item chips, TICK / HALTED.
|
||||
3. Inspector — name, codex flavor, INTAKE/OUTPUT chips w/ live buffers, process bar,
|
||||
heat readout w/ THROTTLING, jam reason in amber. Esc or X closes.
|
||||
4. Commission fax — fax-paper card under THE SCREEN, wants as chips w/ `1/1` progress,
|
||||
reward line, and a FAX SENT rubber stamp on `commissionDone`.
|
||||
5. Toasts — max 4, 6s TTL, colored per event kind, house voice.
|
||||
|
||||
**VERIFIED:** `npm run check` clean; `npm test` 70/70 (other lanes' suites). Dev server on
|
||||
:8151 (8150 was another lane's session). Confirmed with own eyes: hotkey 1 → `bus._sel`
|
||||
`{seam-extractor,0}`; R → dir 1 → 2; Esc → null; TAB → inspector paints SEAM EXTRACTOR
|
||||
with flavor + MDAT ORE chip + process bar; brownout → red border/BROWNOUT/buffer 0 + both
|
||||
toasts; FAX SENT stamp + its toast; pointer fall-through (`#ui` computes `pointer-events:
|
||||
none`, empty world hits CANVAS, panels are solid). No console errors.
|
||||
|
||||
**DECISIONS:**
|
||||
- All player-facing strings live in `voice.ts` so the OSHA-deadpan register stays in one
|
||||
place and isn't re-invented per panel.
|
||||
- The UI keeps its own flattened `roster` (pick.ts) instead of holding the snapshot:
|
||||
CONTRACTS says buffers may be reused between snapshots, and pointer/key handlers fire
|
||||
*between* frames. The backing array is reused so 500 entities don't allocate per frame.
|
||||
- Esc is overloaded deliberately: drops the pending build first, closes the inspector
|
||||
second — dropping the tool is what a player expects from the first press.
|
||||
|
||||
**BLOCKED/BROKEN (read this part):**
|
||||
- **Click-to-inspect does not work.** `ui.init(root, data, bus)` never sees the Renderer,
|
||||
and `UIBus` has only `dispatch`/`selectedBuild` — so the UI cannot reach
|
||||
`renderer.pickTile`, which is the only thing that knows the camera. LANE-RENDER has a
|
||||
working `pickTile` this round; I just can't call it. `pick.ts` opportunistically probes
|
||||
`window.__fktryRenderer.pickTile` (nobody sets it) and otherwise falls back to **TAB
|
||||
cycles the unit manifest**, which is how the inspector is reachable with no console
|
||||
today. Tile→entity hit-testing (incl. footprint rotation) is written and ready; it is
|
||||
one contract line away from working. See CONTRACT REQUEST 1.
|
||||
- **LANE-DATA shipped 21 machines; hotkeys only cover 9.** Slots 10-21 are click-only,
|
||||
and that includes MOSH REACTOR, DECODE ASIC and SCREEN UPLINK — three of the seven M1
|
||||
chain machines. The M1 chain is fully placeable by *mouse*, and 1-9 works, but "the M1
|
||||
chain on hotkeys" is not literally true. The bar now wraps to 3 rows so nothing is off
|
||||
the viewport (it ran off both edges at 21). See PROPOSAL.
|
||||
- **No DOM tests for `src/ui`.** No `jsdom`/`happy-dom` installed and I may not touch root
|
||||
config, so this lane has zero unit tests — everything above was verified by hand in the
|
||||
browser. See CONTRACT REQUEST 4.
|
||||
- `?uifixture` is dev scaffolding I wrote (`fixture.ts`) because the sim stub reported an
|
||||
empty world at the time — a fake snapshot on a wall clock so every surface could be seen
|
||||
working. It is inert without the query param and dispatches nothing. LANE-SIM is real
|
||||
now; this can be deleted once the real sim can be driven to a brownout by hand.
|
||||
- Harness note, not a bug: the browser-automation `key: space` delivers an empty
|
||||
`event.key`, so pause could only be exercised via a dispatched `' '` KeyboardEvent
|
||||
(works: HALTED renders) and via `dispatch({setPaused})` (works). Real spacebar sends
|
||||
`' '` per spec; the handler matches that.
|
||||
|
||||
**CONTRACT REQUEST:**
|
||||
1. **`UIBus.pickTile(clientX, clientY): Vec2 | null`** (or pass the `Renderer` to
|
||||
`ui.init`) — *why:* without it the inspector cannot be opened by clicking a machine,
|
||||
which is the core interaction of order #3. Renderer already implements it; only the
|
||||
plumbing is missing. This is my only real blocker.
|
||||
2. **`MachineDef.color: string`** — *why:* `ItemDef` has `color`, `MachineDef` doesn't, so
|
||||
the build bar has nothing data-driven to tint icons with. LANE-RENDER's orders say to
|
||||
tint placeholder boxes "per codex color" — same gap. Right now `palette.ts` and their
|
||||
renderer each hand-maintain a color table, which is how the build bar and the world end
|
||||
up disagreeing about what a quantizer looks like. Data should own it once.
|
||||
3. **`MachineDef.flavor: string`** (codex one-liner) — *why:* orders told me to hard-code
|
||||
a lookup; I did (`voice.ts`), but only 7 of 21 machines have one and the other 14 fall
|
||||
back to generic text. This is LANE-DATA's material, not mine.
|
||||
4. **`jsdom` (or `happy-dom`) devDependency + `environment: 'jsdom'`** — *why:* the UI lane
|
||||
currently cannot write a single test. Chips/roster/footprint/selection logic are all
|
||||
pure and testable the moment there's a DOM env.
|
||||
|
||||
**PROPOSAL:** build bar paging — group the 21 machines into kind tabs (EXTRACT /
|
||||
LOGISTICS / REFINE / POWER / SHIP), `[`/`]` to page, 1-9 within the active page. Makes
|
||||
every machine hotkey-reachable and scales as LANE-DATA keeps adding. Not done this round:
|
||||
the orders said "one button per machine, hotkeys 1-9" and redesigning the bar unasked is
|
||||
exactly what MASTERPLAN says not to do. Say the word and it's a small round-2 change.
|
||||
|
||||
**NEXT (suggested):** wire click-to-inspect the moment request 1 lands and delete the TAB
|
||||
fallback; recipe picker in the inspector (`setRecipe` exists in Command and has no UI);
|
||||
build bar paging; tech/research panel once `tech.json` is populated (it's `[]`).
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
73
fktry/src/ui/chips.ts
Normal file
73
fktry/src/ui/chips.ts
Normal 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
40
fktry/src/ui/dom.ts
Normal 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');
|
||||
}
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
135
fktry/src/ui/fixture.ts
Normal file
135
fktry/src/ui/fixture.ts
Normal file
@ -0,0 +1,135 @@
|
||||
/**
|
||||
* LANE-UI — dev-only fake sim, for verifying the HUD before LANE-SIM lands.
|
||||
*
|
||||
* LANE-SIM's stub reports zero entities, zero bandwidth and no events forever, so with
|
||||
* the real snapshot there is literally nothing on screen to check: an empty inspector
|
||||
* and a 0/0 meter prove nothing. This module synthesises a plausible snapshot from the
|
||||
* real tick so every surface can be seen doing its job in a browser.
|
||||
*
|
||||
* INERT unless the URL says `?uifixture`. It never dispatches Commands and never touches
|
||||
* another lane; when the real sim arrives, drop the query param (or this file).
|
||||
*
|
||||
* See NOTES — this is scaffolding, not a feature.
|
||||
*/
|
||||
import type { GameData, EntityState, SimEvent, SimSnapshot } from '../contracts';
|
||||
|
||||
export function fixtureEnabled(): boolean {
|
||||
return typeof location !== 'undefined' && new URLSearchParams(location.search).has('uifixture');
|
||||
}
|
||||
|
||||
export interface Fixture {
|
||||
snapshot(paused: boolean): SimSnapshot;
|
||||
/** Events for ticks that have elapsed since the last call. */
|
||||
drain(): SimEvent[];
|
||||
}
|
||||
|
||||
const SHIP_EVERY = 90; // one melt every 3s at 30tps
|
||||
const MS_PER_TICK = 1000 / 30;
|
||||
/** Never replay more than this many ticks of events in one drain (backgrounded tab). */
|
||||
const MAX_CATCHUP = 300;
|
||||
|
||||
export function createFixture(data: GameData): Fixture {
|
||||
const entities: EntityState[] = data.machines.map((m, i) => ({
|
||||
id: i + 1,
|
||||
def: m.id,
|
||||
pos: { x: i * 3 - 9, y: i % 2 === 0 ? -2 : 2 },
|
||||
dir: (i % 4) as 0 | 1 | 2 | 3,
|
||||
recipe: m.recipes[0] ?? null,
|
||||
progress: 0,
|
||||
inputBuf: {},
|
||||
outputBuf: {},
|
||||
jammed: null,
|
||||
heat: 0,
|
||||
}));
|
||||
|
||||
const recipes = new Map(data.recipes.map((r) => [r.id, r]));
|
||||
const jamVictim = entities.find((e) => e.def === 'mosh-reactor') ?? entities[0];
|
||||
let lastTick = -1;
|
||||
let brownoutOn = false;
|
||||
|
||||
// Wall clock, not the passed-in sim tick. rAF stops in a backgrounded tab, so a
|
||||
// tick-driven fixture crawls and you can never actually watch the brownout arrive.
|
||||
// This is a fake sim for eyeballing the HUD; nothing here has to be deterministic.
|
||||
const t0 = performance.now();
|
||||
const nowTick = () => Math.floor((performance.now() - t0) / MS_PER_TICK);
|
||||
|
||||
function brownoutAt(tick: number) {
|
||||
return tick % 600 > 480; // ~4s of pain every 20s
|
||||
}
|
||||
|
||||
return {
|
||||
snapshot(paused) {
|
||||
const tick = nowTick();
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const e = entities[i];
|
||||
const r = e.recipe ? recipes.get(e.recipe) : null;
|
||||
e.progress = r ? ((tick + i * 13) % r.ticks) / r.ticks : 0;
|
||||
// The mosh reactor gets to be the one that's unhappy, for the amber readout.
|
||||
e.jammed = e.def === 'mosh-reactor' && tick % 300 > 240 ? 'output buffer full' : null;
|
||||
e.heat = e.def === 'quantizer' ? Math.min(1, (tick % 900) / 900) : 0;
|
||||
if (r) {
|
||||
e.inputBuf = {};
|
||||
for (const [item, need] of Object.entries(r.inputs)) {
|
||||
e.inputBuf[item] = (tick + i) % (need + 2);
|
||||
}
|
||||
e.outputBuf = {};
|
||||
for (const item of Object.keys(r.outputs)) {
|
||||
e.outputBuf[item] = Math.floor(((tick + i * 7) % 200) / 40);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shipped = Math.floor(tick / SHIP_EVERY);
|
||||
const out = brownoutAt(tick);
|
||||
const draw = 6 + (out ? 22 : Math.abs(((tick / 60) % 4) - 2) * 7);
|
||||
|
||||
const shippedTotal: Record<string, number> = {};
|
||||
if (shipped > 0) {
|
||||
shippedTotal.melt = shipped;
|
||||
shippedTotal['anchor-slab'] = shipped;
|
||||
}
|
||||
|
||||
return {
|
||||
tick,
|
||||
paused,
|
||||
entities,
|
||||
beltItems: [],
|
||||
bandwidth: { gen: 20, draw, stored: out ? 0 : 40 + Math.sin(tick / 40) * 20, brownout: out },
|
||||
shippedTotal,
|
||||
activeCommission: data.commissions[0]?.id ?? null,
|
||||
commissionProgress: { melt: shipped },
|
||||
};
|
||||
},
|
||||
|
||||
drain() {
|
||||
const tick = nowTick();
|
||||
const events: SimEvent[] = [];
|
||||
if (lastTick < 0) {
|
||||
lastTick = tick;
|
||||
return events;
|
||||
}
|
||||
if (tick - lastTick > MAX_CATCHUP) lastTick = tick - MAX_CATCHUP;
|
||||
for (let t = lastTick + 1; t <= tick; t++) {
|
||||
if (t % SHIP_EVERY === 0 && t > 0) {
|
||||
events.push({ kind: 'shipped', item: 'melt', count: 1, tick: t });
|
||||
// Recurring rather than one-shot: the stamp is a 1.8s animation, and a
|
||||
// fixture that fires it once at tick 90 is unobservable in practice.
|
||||
if (t % (SHIP_EVERY * 3) === 0) {
|
||||
const c = data.commissions[0];
|
||||
if (c) events.push({ kind: 'commissionDone', commission: c.id, tick: t });
|
||||
}
|
||||
}
|
||||
if (t % 300 === 241 && jamVictim) {
|
||||
events.push({ kind: 'jammed', entity: jamVictim.id, reason: 'output buffer full', tick: t });
|
||||
}
|
||||
const out = brownoutAt(t);
|
||||
if (out !== brownoutOn) {
|
||||
brownoutOn = out;
|
||||
events.push({ kind: 'brownout', on: out, tick: t });
|
||||
}
|
||||
}
|
||||
lastTick = tick;
|
||||
return events;
|
||||
},
|
||||
};
|
||||
}
|
||||
60
fktry/src/ui/hotkeys.ts
Normal file
60
fktry/src/ui/hotkeys.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
38
fktry/src/ui/palette.ts
Normal file
38
fktry/src/ui/palette.ts
Normal 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
96
fktry/src/ui/pick.ts
Normal 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). 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<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
48
fktry/src/ui/selection.ts
Normal 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
242
fktry/src/ui/style.ts
Normal 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;
|
||||
}
|
||||
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 })),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
97
fktry/src/ui/voice.ts
Normal file
97
fktry/src/ui/voice.ts
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user