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>
97 lines
3.0 KiB
TypeScript
97 lines
3.0 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|