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