feat: bootstrap walking skeleton
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { content } from '../content';
|
||||
import { startAction as engineStartAction, type GameState, tickGame } from '../engine/game';
|
||||
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
|
||||
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
|
||||
import { useGameStore } from './store';
|
||||
import { formatOfflineDuration, toView } from './viewModel';
|
||||
|
||||
/**
|
||||
* The game runtime: the one place that owns the engine state and the wall clock.
|
||||
*
|
||||
* It drives the fixed-timestep tick loop off requestAnimationFrame, mirrors a
|
||||
* view snapshot into the Zustand store at ~10 fps, autosaves on an interval, and
|
||||
* persists on tab-hide / unload. Boot loads the save and credits offline time.
|
||||
* All environment coupling (RAF, Date.now, DOM events, IndexedDB) lives here so
|
||||
* the engine stays pure.
|
||||
*/
|
||||
|
||||
const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh
|
||||
const AUTOSAVE_INTERVAL_MS = 10_000;
|
||||
|
||||
class GameRuntime {
|
||||
private state: GameState | null = null;
|
||||
private readonly loop: TickLoop = createTickLoop();
|
||||
private readonly backend: SaveBackend = createDefaultBackend();
|
||||
private rafId: number | null = null;
|
||||
private lastPublishAt = 0;
|
||||
private lastSaveAt = 0;
|
||||
private booted = false;
|
||||
|
||||
async boot(): Promise<void> {
|
||||
if (this.booted) {
|
||||
return;
|
||||
}
|
||||
this.booted = true;
|
||||
|
||||
const now = Date.now();
|
||||
const { state, offlineMs } = await loadGame(content, this.backend, now);
|
||||
this.state = state;
|
||||
this.lastSaveAt = now;
|
||||
|
||||
const store = useGameStore.getState();
|
||||
store.appendLog(
|
||||
offlineMs >= 1000
|
||||
? `Welcome back — credited ${formatOfflineDuration(offlineMs)} of offline progress.`
|
||||
: 'A new tale begins. Choose an action.',
|
||||
);
|
||||
|
||||
this.publish();
|
||||
this.installLifecycleHooks();
|
||||
this.rafId = requestAnimationFrame(this.frame);
|
||||
}
|
||||
|
||||
/** Stop the loop. Used on teardown (e.g. HMR dispose); not needed in normal play. */
|
||||
stop(): void {
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
startAction(actionId: string): void {
|
||||
const state = this.state;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
engineStartAction(state, content, actionId);
|
||||
const action = content.actionsById[actionId];
|
||||
if (action) {
|
||||
useGameStore.getState().appendLog(`Started: ${action.name}.`);
|
||||
}
|
||||
this.publish();
|
||||
}
|
||||
|
||||
private readonly frame = (monoNow: number): void => {
|
||||
const state = this.state;
|
||||
if (state) {
|
||||
advance(this.loop, monoNow, () => tickGame(state, content, TICK_MS));
|
||||
|
||||
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {
|
||||
this.publish();
|
||||
this.lastPublishAt = monoNow;
|
||||
}
|
||||
|
||||
const wall = Date.now();
|
||||
if (wall - this.lastSaveAt >= AUTOSAVE_INTERVAL_MS) {
|
||||
void this.save(wall);
|
||||
}
|
||||
}
|
||||
this.rafId = requestAnimationFrame(this.frame);
|
||||
};
|
||||
|
||||
private publish(): void {
|
||||
const state = this.state;
|
||||
if (state) {
|
||||
useGameStore.getState().setView(toView(state, content));
|
||||
}
|
||||
}
|
||||
|
||||
private async save(now: number = Date.now()): Promise<void> {
|
||||
const state = this.state;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
this.lastSaveAt = now;
|
||||
await saveGame(state, this.backend, now);
|
||||
}
|
||||
|
||||
private installLifecycleHooks(): void {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
void this.save();
|
||||
}
|
||||
});
|
||||
window.addEventListener('beforeunload', () => {
|
||||
void this.save();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const gameRuntime = new GameRuntime();
|
||||
Reference in New Issue
Block a user