deadhead-space-freight-tycoon/apps/web/src/platform/game.ts

96 lines
3.2 KiB
TypeScript

import {
DeviceClock,
GameEngine,
IndexedDbBackend,
MemoryBackend,
NotificationSync,
ResolutionLoop,
SaveStorage,
TabLock,
type LockManagerLike,
type LoopHost,
type NotificationScheduler,
} from "@deadhead/engine";
import { usePreferences } from "../state/preferences";
import { platformNotifications } from "./notifications";
/**
* The game on this device (CR-014): save storage, the device clock, the tab
* lock, local notifications and the engine, wired to the browser.
*/
export interface Game {
engine: GameEngine;
lock: TabLock;
storage: SaveStorage;
clock: DeviceClock;
devTools: boolean;
}
/** Developer tools: development builds, and test builds made with VITE_DEV_TOOLS=true. */
export const DEV_TOOLS = !import.meta.env.PROD || import.meta.env.VITE_DEV_TOOLS === "true";
const DEV_OFFSET_KEY = "deadhead:dev-clock-offset";
function savedDevOffset(): number {
try {
const value = Number(localStorage.getItem(DEV_OFFSET_KEY));
return Number.isFinite(value) && value > 0 ? value : 0;
} catch {
return 0;
}
}
export function createGame(scheduler: NotificationScheduler = platformNotifications()): Game {
const storage = new SaveStorage(
typeof indexedDB === "undefined" ? new MemoryBackend() : new IndexedDbBackend(indexedDB),
);
const clock = new DeviceClock(Date.now, { offsetMs: DEV_TOOLS ? savedDevOffset() : 0 });
if (DEV_TOOLS) {
// The developer clock offset survives a reload (test builds fast-forward, then reload).
const advance = clock.advance.bind(clock);
clock.advance = (ms) => {
advance(ms);
try {
localStorage.setItem(DEV_OFFSET_KEY, String(clock.offsetMs));
} catch {
// Storage blocked: the offset lasts until the page reloads.
}
};
}
const locks = (navigator as Navigator & { locks?: LockManagerLike }).locks;
const lock = new TabLock(locks);
const engine = new GameEngine({
storage,
clock,
canWrite: () => lock.held,
devTools: DEV_TOOLS,
// Responses are checked in development, as the server did outside production.
validateResponses: !import.meta.env.PROD,
notifications: new NotificationSync(scheduler, () => usePreferences.getState().notifications),
});
// The Settings toggle reschedules (or clears) at once.
usePreferences.subscribe((prefs, before) => {
if (prefs.notifications !== before.notifications) void engine.resyncNotifications();
});
return { engine, lock, storage, clock, devTools: DEV_TOOLS };
}
/** The browser as the loop's host: a timer, and the page becoming visible again. */
export const browserLoopHost: LoopHost = {
setInterval: (fn, ms) => window.setInterval(fn, ms),
clearInterval: (handle) => window.clearInterval(handle as number),
onResume(fn) {
const listener = () => {
if (document.visibilityState === "visible") fn();
};
document.addEventListener("visibilitychange", listener);
return () => document.removeEventListener("visibilitychange", listener);
},
};
export function startResolutionLoop(game: Game, onChange: () => void): ResolutionLoop {
const loop = new ResolutionLoop(game.engine, browserLoopHost, onChange);
void loop.start();
return loop;
}