glytch/fktry/src/ui/pick.ts
type-two d017e710ad [ui] HUD foundation: voice, style, chips, hotkeys, selection
Shared plumbing for the DOM HUD, no framework:

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:15:38 +10:00

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;
}