glytch/fktry/src/ui/buildbar.ts
type-two 2904ddbb8c [ui] research: the STANDARDS COMMITTEE, and padlocks that fall off live
T opens the tech tree — every tech grouped by era in era order, cost chips in
item colours, era prefix stripped from node names (the heading already says
it), UNLOCKS: tooltips in machine names not ids. Pick a node to dispatch
setResearch; picking the active one stands it down; ratified nodes are inert.

The v4 gating rule lives in a pure research.ts: an id any tech claims is locked
until that tech completes, and an id no tech mentions is always available — so
belts never padlock. Locked machines are greyed with a padlock and a
REQUIRES: <TECH> tooltip, and can't be picked by click or by hotkey. The
padlock falls off in the same frame `researched` lands, and the player never
keeps holding something they can no longer build.

With no ResearchState at all the safe reading is that gates hold rather than
fall open, and the panel says RESEARCH OFFLINE instead of rendering a tree that
can't do anything.

Accent precedence now matches RENDER exactly: MachineDef.accent, then the
first-output derivation, then the codex kind table. RESEARCH joins the page
order for the new 'lab' kind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:13:50 +10:00

186 lines
5.9 KiB
TypeScript

/**
* 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<string>();
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<string>();
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,
};
}