/** * LANE-UI — build bar. Kind tabs, `[`/`]` to page, 1-9 within the active page. * Click or hotkey selects for placement; main.ts does the ghost and the actual place. */ import type { GameData, MachineDef, SimSnapshot } from '../contracts'; import { attrs, cls, el, panel, text } from './dom'; import { paginate, pageOf, type BuildPage } from './pages'; import { createAccentLookup, machineChassis } from './palette'; import { gateFor, indexTech, type TechIndex } from './research'; import type { BuildSelection } from './selection'; import { COPY, DIR_NAME } from './voice'; export interface BuildBar { el: HTMLElement; /** Select slot `n` (1-based) on the active page. */ selectSlot(n: number): void; pageBy(delta: number): void; sync(): void; /** Re-read the lock state; cheap enough to call per frame. */ update(snap: SimSnapshot): void; } export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar { const pages: BuildPage[] = paginate(data.machines); const accentOf = createAccentLookup(data); const techIndex: TechIndex = indexTech(data); /** Machine ids currently gated by unfinished research. */ let locked = new Set(); const root = panel(); root.id = 'fk-build'; const tabRow = el('div', 'fk-tabs'); const row = el('div', 'fk-build-row'); const hint = el('div'); hint.id = 'fk-build-hint'; root.append(tabRow, row, hint); let active = 0; const tabs = pages.map((p, i) => { const tab = el('button', 'fk-tab', [p.label]); attrs(tab, { type: 'button' }); tab.addEventListener('click', () => { active = i; render(); }); tabRow.append(tab); return tab; }); // REMOVE lives beside the tabs, not on a page: it is a mode, not a machine, and it // must be reachable whatever page you are on. const removeBtn = el('button', 'fk-tab fk-tab-remove', [ COPY.removeLabel, el('span', 'fk-tab-key', [COPY.removeKey]), ]); attrs(removeBtn, { type: 'button', title: 'REMOVE MODE (X)' }); removeBtn.addEventListener('click', () => sel.toggleRemove()); tabRow.append(removeBtn); /** Buttons for the active page only; rebuilt on page change (nine at most). */ let buttons: Array<{ def: string; btn: HTMLButtonElement; lock: HTMLElement }> = []; function buildButtons(page: BuildPage) { row.replaceChildren(); buttons = page.machines.map((m: MachineDef, i: number) => { const btn = el('button', 'fk-build-btn'); // Two-material rule (codex §8): grimy body, one impossible element. const ico = el('div', 'fk-build-ico'); ico.style.background = machineChassis(m); const accent = el('div', 'fk-build-accent'); accent.style.background = accentOf(m); accent.style.boxShadow = `0 0 5px ${accentOf(m)}`; ico.append(accent); const lock = el('span', 'fk-build-lock', ['\u{1F512}']); ico.append(lock); btn.append(ico); const key = el('span', 'fk-build-key'); key.textContent = String(i + 1); // every slot on every page has a hotkey now btn.append(key); btn.append(el('span', 'fk-build-name', [m.name])); btn.addEventListener('click', () => select(m.id)); row.append(btn); return { def: m.id, btn, lock }; }); applyLocks(); } /** A locked machine can't be picked — by click or by hotkey. */ function select(defId: string) { if (locked.has(defId)) return; sel.select(defId); } function applyLocks() { for (const b of buttons) { const isLocked = locked.has(b.def); cls(b.btn, 'is-locked', isLocked); cls(b.lock, 'is-on', isLocked); const m = data.machines.find((x) => x.id === b.def); const gate = isLocked ? techIndex.gatedBy.get(b.def) : null; attrs(b.btn, { title: gate ? `${m?.name ?? b.def} — ${COPY.requires} ${gate.id.replace(/-/g, ' ').toUpperCase()}` : `${m?.name ?? b.def} — ${m?.kind ?? ''}`, }); } } function render() { buildButtons(pages[active]); for (let i = 0; i < tabs.length; i++) cls(tabs[i], 'is-active', i === active); sync(); } function sync() { const cur = sel.get(); const removing = sel.isRemoving(); for (const b of buttons) cls(b.btn, 'is-sel', cur?.def === b.def); cls(removeBtn, 'is-armed', removing); if (removing) { text(hint, COPY.removeHint); } else 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); cls(hint, 'is-removing', removing); } // A selection made from anywhere (including a future hotkey we don't own) pulls the // bar to the page that contains it, so the hot button is always the visible one. sel.onChange(() => { const cur = sel.get(); if (cur) { const p = pageOf(pages, cur.def); if (p >= 0 && p !== active) { active = p; render(); return; } } sync(); }); render(); return { el: root, update(snap) { // Recompute only when the unlocked set actually changes — the padlocks then fall // off in the same frame the `researched` event lands. const next = new Set(); for (const m of data.machines) { if (gateFor(techIndex, snap.research, m.id)) next.add(m.id); } if (next.size === locked.size && [...next].every((d) => locked.has(d))) return; locked = next; applyLocks(); // Never leave the player holding something they can no longer build. const cur = sel.get(); if (cur && locked.has(cur.def)) sel.clear(); }, selectSlot(n) { const b = buttons[n - 1]; if (b) select(b.def); }, pageBy(delta) { if (pages.length === 0) return; active = (active + delta + pages.length) % pages.length; render(); }, sync, }; }