| // Central bridge between the imperative game core and the React UI. | |
| // | |
| // Data flows one way per concern: | |
| // game -> store state the UI renders (mode label, loco, telemetry, boot | |
| // milestones). Written via useGame.setState from game code; | |
| // high-frequency values (telemetry) are throttled at the | |
| // producer so React never re-renders at frame rate. | |
| // UI -> gameApi intents (set colour, switch loco, reset, open/close | |
| // menu side effects). gameApi is a plain object populated | |
| // once the game boots; optional-chained calls make the UI | |
| // safe to interact with before that. | |
| import { create } from "zustand"; | |
| import { subscribeWithSelector } from "zustand/middleware"; | |
| export const useGame = create( | |
| subscribeWithSelector(() => ({ | |
| // Boot lifecycle | |
| prebootDone: false, // title assets ready, grey veil dropped | |
| entered: false, // first "Waddle in" clicked (latches) | |
| menuOpen: false, // title / pause overlay up (opened by App after preboot) | |
| biosVisible: false, // BIOS readout playing over the scene | |
| bootDone: false, | |
| bootFailed: false, | |
| // Game state mirrored for the UI | |
| modeLabel: "Run", | |
| loco: "legs", // "legs" | "rollers" - what the game is actually running | |
| locoWant: "legs", // what the quickbar asked for (game reconciles) | |
| locoSwitching: false, | |
| rollersLoading: false, // OSD line while the roller stack streams in | |
| variant: "classic", | |
| padConnected: false, | |
| touchMode: false, | |
| ballActive: false, | |
| // Throttled telemetry block (4 Hz), bottom-right OSD | |
| telemetry: { fps: 0, ctrlHz: 0, speed: 0, odo: 0, peers: 0 }, | |
| })), | |
| ); | |
| // Imperative game surface, assigned by game/game.js once booted. The UI | |
| // only ever optional-chains into it. | |
| export const gameApi = {}; | |
| // Debug handles for the console / automated QA (same spirit as window.rl). | |
| if (typeof window !== "undefined") { | |
| window.__store = useGame; | |
| window.__gameApi = gameApi; | |
| } | |
| // BIOS/POST milestone log. Plain module state on purpose: the readout | |
| // component polls it inside its own paced replay loop (same as the old | |
| // playBios), so no reactivity is needed and log spam never re-renders React. | |
| // Entries: { label, status, raw, halt, progress } - status null = pending. | |
| export const bootLog = []; | |
| export const bootLine = (label) => { | |
| const entry = { label, status: null, raw: false, progress: null }; | |
| bootLog.push(entry); | |
| const done = (status = "OK") => { | |
| entry.status = status; | |
| }; | |
| done.progress = (p) => { | |
| entry.progress = p; | |
| }; | |
| return done; | |
| }; | |
| export const bootNote = (label) => { | |
| bootLog.push({ label, status: "", raw: true }); | |
| }; | |
| // Fatal boot failure: freeze the BIOS on the halt screen (the readout is | |
| // the diagnostic surface). Drops the title overlay if still up. | |
| export function bootHalt(detail) { | |
| const s = useGame.getState(); | |
| if (s.bootFailed || s.bootDone) return; | |
| bootNote(`>> ${detail}`); | |
| bootLog.push({ label: "SYSTEM HALTED", status: "", raw: true, halt: true }); | |
| useGame.setState({ bootFailed: true, menuOpen: false, biosVisible: true }); | |
| } | |