tfrere's picture
tfrere HF Staff
feat: Vite+React+MUI+R3F app shell in app/ (framework-agnostic game core, zustand bridge)
3570fe8
Raw
History Blame Contribute Delete
3.16 kB
// 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 });
}