299 lines
9.2 KiB
TypeScript
299 lines
9.2 KiB
TypeScript
import { content } from '../content';
|
|
import {
|
|
cancelQueuedAction as engineCancelQueuedAction,
|
|
type GameState,
|
|
maybeStartLoopAction,
|
|
performAction as performActionEngine,
|
|
tickGame,
|
|
} from '../engine/game';
|
|
import { applyChoice as engineApplyChoice, enterStoryNode, initStory } from '../engine/story';
|
|
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
|
|
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
|
|
import { getPrefs } from './prefs';
|
|
import { type ActivePanel, useGameStore } from './store';
|
|
import {
|
|
processStoryTriggers,
|
|
type StoryUiEffect,
|
|
shouldAutoNavigateToStory,
|
|
storyEventsToLogEntries,
|
|
} from './storyOrchestration';
|
|
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;
|
|
|
|
export 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;
|
|
|
|
private readonly visibilityChangeListener = (): void => {
|
|
if (document.visibilityState === 'hidden') {
|
|
void this.save();
|
|
}
|
|
};
|
|
|
|
private readonly beforeUnloadListener = (): void => {
|
|
void this.save();
|
|
};
|
|
|
|
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.',
|
|
);
|
|
|
|
initStory(this.state, content);
|
|
const prefs = getPrefs();
|
|
store.setPrefs(prefs);
|
|
const bootEffect = processStoryTriggers(this.state, content, { reason: 'boot' }, prefs);
|
|
this.applyStoryUiEffect(bootEffect);
|
|
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;
|
|
}
|
|
document.removeEventListener('visibilitychange', this.visibilityChangeListener);
|
|
window.removeEventListener('beforeunload', this.beforeUnloadListener);
|
|
this.booted = false;
|
|
}
|
|
|
|
performAction(actionId: string): void {
|
|
const state = this.state;
|
|
if (!state) return;
|
|
const action = content.actionsById[actionId];
|
|
if (!action) return;
|
|
|
|
try {
|
|
const isStory = action.kind === 'story';
|
|
const events = performActionEngine(state, content, actionId);
|
|
const store = useGameStore.getState();
|
|
|
|
// Append any custom log outcomes
|
|
for (const event of events.filter((e) => e.kind === 'log')) {
|
|
store.appendLog(event.prose);
|
|
}
|
|
|
|
if (isStory) {
|
|
const entries = storyEventsToLogEntries(events);
|
|
for (const entry of entries) {
|
|
store.appendStoryLog(entry);
|
|
}
|
|
const choiceLabel = entries.at(-1)?.choiceLabel;
|
|
if (choiceLabel) {
|
|
store.appendLog(`Story: ${choiceLabel}`);
|
|
}
|
|
this.runPublishTriggers();
|
|
} else if (action.kind === 'timed') {
|
|
const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started';
|
|
store.appendLog(`${verb}: ${action.name}.`);
|
|
}
|
|
|
|
this.publish();
|
|
} catch (err) {
|
|
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Action failed');
|
|
}
|
|
}
|
|
|
|
setActivePanel(panel: ActivePanel): void {
|
|
const store = useGameStore.getState();
|
|
store.setActivePanel(panel);
|
|
if (panel === 'story') {
|
|
store.setStoryHasUnread(false);
|
|
}
|
|
}
|
|
|
|
toggleActionGroupCollapsed(groupKey: string): void {
|
|
useGameStore.getState().toggleActionGroupCollapsed(groupKey);
|
|
}
|
|
|
|
enqueueAction(actionId: string): void {
|
|
this.performAction(actionId);
|
|
}
|
|
|
|
applyStoryChoice(choiceId: string): void {
|
|
const action = content.actions.find((a) => a.storyChoiceId === choiceId);
|
|
if (action) {
|
|
this.performAction(action.id);
|
|
} else {
|
|
const state = this.state;
|
|
if (!state) return;
|
|
try {
|
|
const events = engineApplyChoice(state, content, choiceId);
|
|
const entries = storyEventsToLogEntries(events);
|
|
const store = useGameStore.getState();
|
|
|
|
// Append any custom log outcomes
|
|
for (const event of events.filter((e) => e.kind === 'log')) {
|
|
store.appendLog(event.prose);
|
|
}
|
|
|
|
for (const entry of entries) store.appendStoryLog(entry);
|
|
const choiceLabel = entries.at(-1)?.choiceLabel;
|
|
if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`);
|
|
this.runPublishTriggers();
|
|
this.publish();
|
|
} catch (err) {
|
|
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed');
|
|
}
|
|
}
|
|
}
|
|
|
|
cancelQueuedAction(index: number): void {
|
|
const state = this.state;
|
|
if (!state) return;
|
|
try {
|
|
engineCancelQueuedAction(state, index);
|
|
useGameStore.getState().appendLog('Removed queued action.');
|
|
this.publish();
|
|
} catch (err) {
|
|
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Cancel failed');
|
|
}
|
|
}
|
|
|
|
continueStory(): void {
|
|
const state = this.state;
|
|
if (!state) return;
|
|
if (state.currentStoryNodeId === 'boot_intro') {
|
|
const events = enterStoryNode(state, content, 'fork_choice');
|
|
const entries = storyEventsToLogEntries(events);
|
|
const store = useGameStore.getState();
|
|
for (const entry of entries) store.appendStoryLog(entry);
|
|
for (const entry of entries) {
|
|
store.appendLog(
|
|
`Story: ${(content.storyNodesById[entry.nodeId]?.prose ?? '').slice(0, 40)}…`,
|
|
);
|
|
}
|
|
const prefs = store.prefs;
|
|
if (shouldAutoNavigateToStory(prefs, 'fork_choice', content)) {
|
|
this.setActivePanel('story');
|
|
} else {
|
|
store.setStoryHasUnread(true);
|
|
}
|
|
this.publish();
|
|
return;
|
|
}
|
|
this.setActivePanel('play');
|
|
this.publish();
|
|
}
|
|
|
|
private applyStoryUiEffect(effect: StoryUiEffect): void {
|
|
const store = useGameStore.getState();
|
|
const prefs = store.prefs;
|
|
for (const entry of effect.logEntries) store.appendStoryLog(entry);
|
|
for (const line of effect.eventLogLines) store.appendLog(line);
|
|
|
|
if (effect.shouldOpenPanel) {
|
|
if (prefs.storyOpenMode === 'auto') {
|
|
store.setActivePanel('story');
|
|
store.setStoryHasUnread(false);
|
|
} else {
|
|
store.setStoryHasUnread(true);
|
|
}
|
|
} else if (effect.enteredNodeIds.length > 0) {
|
|
store.setStoryHasUnread(true);
|
|
}
|
|
}
|
|
|
|
private runPublishTriggers(): void {
|
|
const state = this.state;
|
|
if (!state) return;
|
|
const prefs = useGameStore.getState().prefs;
|
|
const effect = processStoryTriggers(state, content, { reason: 'publish' }, prefs);
|
|
this.applyStoryUiEffect(effect);
|
|
}
|
|
|
|
private readonly frame = (monoNow: number): void => {
|
|
const state = this.state;
|
|
if (state) {
|
|
advance(this.loop, monoNow, () => {
|
|
const result = tickGame(state, content, TICK_MS);
|
|
for (const actionId of result.completedActionIds) {
|
|
const prefs = useGameStore.getState().prefs;
|
|
const effect = processStoryTriggers(
|
|
state,
|
|
content,
|
|
{ reason: 'actionComplete', actionId },
|
|
prefs,
|
|
);
|
|
this.applyStoryUiEffect(effect);
|
|
}
|
|
maybeStartLoopAction(state, content);
|
|
});
|
|
|
|
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {
|
|
this.runPublishTriggers();
|
|
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', this.visibilityChangeListener);
|
|
window.addEventListener('beforeunload', this.beforeUnloadListener);
|
|
}
|
|
}
|
|
|
|
export const gameRuntime = new GameRuntime();
|
|
|
|
if (import.meta.hot) {
|
|
import.meta.hot.dispose(() => {
|
|
gameRuntime.stop();
|
|
});
|
|
}
|