Reproduced the "clicking POWER does nothing" report with a real mouse: elementFromPoint at the button's own centre returned the strip container, not the button. The horizontal scrollbar was painted over the lower half of every tab and ate clicks there — which is exactly why keyboard paging still worked and clicking didn't. Fixed by giving the scrollbar its own lane (padding-bottom >= its height). The full button height now hit-tests to the button and a real click activates the tab. The strip itself is the approved round-5 design: one row that scrolls rather than wrapping. The catalog keeps growing — RESEARCH joined in v4 and threw REMOVE onto a line of its own — and a wrapping row moves every other tab under the player's cursor. Paging with [ ] scrolls the active tab into view. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
189 lines
6.1 KiB
TypeScript
189 lines
6.1 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);
|
|
// The strip scrolls, so paging with [ ] must bring its tab into view or the player
|
|
// pages blind. `nearest` avoids yanking the strip when the tab is already visible.
|
|
tabs[active]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
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,
|
|
};
|
|
}
|