diff --git a/fktry/src/ui/buildbar.ts b/fktry/src/ui/buildbar.ts index d5e9dc5..95f2ed2 100644 --- a/fktry/src/ui/buildbar.ts +++ b/fktry/src/ui/buildbar.ts @@ -1,53 +1,76 @@ /** - * LANE-UI — build bar. One button per machine in GameData.machines, hotkeys 1-9. + * 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 } from '../contracts'; +import type { GameData, MachineDef } from '../contracts'; import { attrs, cls, el, panel, text } from './dom'; +import { paginate, pageOf, type BuildPage } from './pages'; import { machineColor } from './palette'; import type { BuildSelection } from './selection'; import { COPY, DIR_NAME } from './voice'; export interface BuildBar { el: HTMLElement; - /** Select the machine in slot `n` (1-based, as printed on the button). */ + /** Select slot `n` (1-based) on the active page. */ selectSlot(n: number): void; + pageBy(delta: number): void; sync(): void; } export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar { + const pages: BuildPage[] = paginate(data.machines); + 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); - const buttons: Array<{ def: string; btn: HTMLButtonElement }> = []; + let active = 0; - data.machines.forEach((m, i) => { - const btn = el('button', 'fk-build-btn'); - attrs(btn, { type: 'button', title: `${m.name} — ${m.kind}` }); - - const ico = el('div', 'fk-build-ico'); - ico.style.background = machineColor(m); - btn.append(ico); - - // Only the first nine get a printed hotkey; the rest are click-only until the - // bar grows tabs/pages (a later round's problem). - if (i < 9) { - const key = el('span', 'fk-build-key'); - key.textContent = String(i + 1); - btn.append(key); - } - - btn.append(el('span', 'fk-build-name', [m.name])); - btn.addEventListener('click', () => sel.select(m.id)); - row.append(btn); - buttons.push({ def: m.id, btn }); + 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; }); - root.append(row, hint); + /** Buttons for the active page only; rebuilt on page change (nine at most). */ + let buttons: Array<{ def: string; btn: HTMLButtonElement }> = []; + + function buildButtons(page: BuildPage) { + row.replaceChildren(); + buttons = page.machines.map((m: MachineDef, i: number) => { + const btn = el('button', 'fk-build-btn'); + attrs(btn, { type: 'button', title: `${m.name} — ${m.kind}` }); + + const ico = el('div', 'fk-build-ico'); + ico.style.background = machineColor(m); + 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', () => sel.select(m.id)); + row.append(btn); + return { def: m.id, btn }; + }); + } + + 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(); @@ -61,8 +84,22 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar { cls(hint, 'is-placing', !!cur); } - sel.onChange(sync); - sync(); + // 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, @@ -70,6 +107,11 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar { const b = buttons[n - 1]; if (b) sel.select(b.def); }, + pageBy(delta) { + if (pages.length === 0) return; + active = (active + delta + pages.length) % pages.length; + render(); + }, sync, }; } diff --git a/fktry/src/ui/hotkeys.ts b/fktry/src/ui/hotkeys.ts index f28fe2e..30f89b3 100644 --- a/fktry/src/ui/hotkeys.ts +++ b/fktry/src/ui/hotkeys.ts @@ -1,55 +1,84 @@ /** * LANE-UI — the keyboard map. ALL bindings live here; this file will grow. * - * Round 1 map: - * 1..9 select build slot N (build bar) + * Round 2 map: + * 1..9 select build slot N *within the active page* + * [ ] previous / next build page * R rotate pending build dir * ESC clear selection / close inspector * SPACE toggle HALT - * TAB cycle the unit manifest (temporary — see NOTES: no pickTile in UIBus) * - * WASD and the middle-drag pan belong to LANE-RENDER's camera; nothing here binds them. - * Keys are matched on `event.key`, lowercased, so layout-shifted digits still work. + * WASD and the middle-drag pan belong to LANE-RENDER's camera (see render/camera.ts); + * nothing here binds them. Keys match on `event.key` lowercased, so layout-shifted + * digits still work. + * + * Split in two: `KeyRouter` is the pure map + dispatch (no window, testable in node), + * `Hotkeys` is the thin binding to the real window. */ -export type HotkeyHandler = (ev: KeyboardEvent) => void; +export type HotkeyHandler = (ev: KeyEventLike) => void; -export interface Hotkeys { - /** `key` is a lowercased `event.key`, e.g. "r", "escape", " ", "tab", "1". */ +/** The parts of KeyboardEvent this module actually reads. */ +export interface KeyEventLike { + key: string; + metaKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + target?: unknown; + preventDefault?(): void; +} + +export interface KeyRouter { + /** `key` is a lowercased `event.key`, e.g. "r", "escape", " ", "[", "1". */ bind(key: string, handler: HotkeyHandler): void; + /** Returns true if a binding ran. */ + handle(ev: KeyEventLike): boolean; +} + +export interface Hotkeys extends KeyRouter { attach(): void; detach(): void; } /** Don't steal keys while the player is typing into something. */ -function isTypingTarget(t: EventTarget | null): boolean { +export function isTypingTarget(t: unknown): boolean { + if (typeof HTMLElement === 'undefined') return false; if (!(t instanceof HTMLElement)) return false; return t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName); } -/** Keys we always swallow so the browser doesn't scroll/focus-walk the page. */ -const SWALLOW = new Set([' ', 'tab']); +/** Keys we swallow so the browser doesn't scroll or focus-walk the page. */ +const SWALLOW = new Set([' ']); -export function createHotkeys(): Hotkeys { +export function createKeyRouter(): KeyRouter { const map = new Map(); - const onKeyDown = (ev: KeyboardEvent) => { - if (isTypingTarget(ev.target)) return; - // Let the browser keep its own shortcuts (⌘R, ctrl-tab, …). - if (ev.metaKey || ev.ctrlKey || ev.altKey) return; - - const key = ev.key.toLowerCase(); - const handler = map.get(key); - if (!handler) return; - - if (SWALLOW.has(key)) ev.preventDefault(); - handler(ev); - }; - return { bind(key, handler) { map.set(key.toLowerCase(), handler); }, + handle(ev) { + if (isTypingTarget(ev.target)) return false; + // Leave the browser's own shortcuts alone (⌘R, ctrl-tab, …). + if (ev.metaKey || ev.ctrlKey || ev.altKey) return false; + + const handler = map.get(ev.key.toLowerCase()); + if (!handler) return false; + + if (SWALLOW.has(ev.key.toLowerCase())) ev.preventDefault?.(); + handler(ev); + return true; + }, + }; +} + +export function createHotkeys(): Hotkeys { + const router = createKeyRouter(); + const onKeyDown = (ev: KeyboardEvent) => router.handle(ev); + + return { + bind: router.bind, + handle: router.handle, attach() { window.addEventListener('keydown', onKeyDown); }, diff --git a/fktry/src/ui/pages.ts b/fktry/src/ui/pages.ts new file mode 100644 index 0000000..f95df90 --- /dev/null +++ b/fktry/src/ui/pages.ts @@ -0,0 +1,73 @@ +/** + * LANE-UI — build bar pagination (pure; see pages.test.ts). + * + * Round 2 orders: kind tabs, `[`/`]` to page, 1-9 within the active page, "every machine + * hotkey-reachable at any catalog size". + * + * Kind tabs alone don't deliver that last clause: LANE-DATA's 21 machines include 14 + * crafters, and 1-9 cannot reach 14. So a page is at most PER_PAGE machines, and a kind + * group larger than that spills into numbered pages (REFINE 1/2, REFINE 2/2). That keeps + * the invariant true no matter how big the catalog gets. + */ +import type { MachineDef, MachineKind } from '../contracts'; + +/** Hotkeys are 1-9, so a page can never hold more than nine. */ +export const PER_PAGE = 9; + +/** Kinds collapse into these tabs, in house order: dig, move, refine, power, ship. */ +const GROUP_OF: Record = { + extractor: 'EXTRACT', + belt: 'LOGISTICS', + splitter: 'LOGISTICS', + buffer: 'LOGISTICS', + crafter: 'REFINE', + power: 'POWER', + shipper: 'SHIP', +}; + +const GROUP_ORDER = ['EXTRACT', 'LOGISTICS', 'REFINE', 'POWER', 'SHIP']; + +export interface BuildPage { + /** Tab label, e.g. "REFINE" or "REFINE 2/2". */ + label: string; + machines: MachineDef[]; +} + +export function paginate(machines: MachineDef[], perPage: number = PER_PAGE): BuildPage[] { + const groups = new Map(); + for (const m of machines) { + // An unknown kind still gets a home rather than vanishing from the bar. + const g = GROUP_OF[m.kind] ?? 'OTHER'; + const list = groups.get(g); + if (list) list.push(m); + else groups.set(g, [m]); + } + + const names = [...groups.keys()].sort((a, b) => { + const ia = GROUP_ORDER.indexOf(a); + const ib = GROUP_ORDER.indexOf(b); + // Unknown groups sort last, alphabetically, rather than jumping the queue. + if (ia === -1 && ib === -1) return a < b ? -1 : a > b ? 1 : 0; + if (ia === -1) return 1; + if (ib === -1) return -1; + return ia - ib; + }); + + const pages: BuildPage[] = []; + for (const name of names) { + const list = groups.get(name)!; + const chunks = Math.max(1, Math.ceil(list.length / perPage)); + for (let i = 0; i < chunks; i++) { + pages.push({ + label: chunks > 1 ? `${name} ${i + 1}/${chunks}` : name, + machines: list.slice(i * perPage, (i + 1) * perPage), + }); + } + } + return pages; +} + +/** Page index holding `defId`, or -1. Used to follow a click-selection to its tab. */ +export function pageOf(pages: BuildPage[], defId: string): number { + return pages.findIndex((p) => p.machines.some((m) => m.id === defId)); +}