feat(state): wire story triggers and choices through runtime
This commit is contained in:
+130
-2
@@ -1,7 +1,25 @@
|
|||||||
import { content } from '../content';
|
import { content } from '../content';
|
||||||
import { enqueueAction as engineEnqueueAction, type GameState, tickGame } from '../engine/game';
|
import {
|
||||||
|
cancelQueuedAction as engineCancelQueuedAction,
|
||||||
|
enqueueAction as engineEnqueueAction,
|
||||||
|
type GameState,
|
||||||
|
isActionAvailable,
|
||||||
|
tickGame,
|
||||||
|
} from '../engine/game';
|
||||||
|
import {
|
||||||
|
applyChoice as engineApplyChoice,
|
||||||
|
enterStoryNode,
|
||||||
|
initStory,
|
||||||
|
} from '../engine/story';
|
||||||
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
|
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
|
||||||
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
|
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
|
||||||
|
import { getPrefs } from './prefs';
|
||||||
|
import {
|
||||||
|
processStoryTriggers,
|
||||||
|
shouldAutoOpenPanel,
|
||||||
|
storyEventsToLogEntries,
|
||||||
|
type StoryUiEffect,
|
||||||
|
} from './storyOrchestration';
|
||||||
import { useGameStore } from './store';
|
import { useGameStore } from './store';
|
||||||
import { formatOfflineDuration, toView } from './viewModel';
|
import { formatOfflineDuration, toView } from './viewModel';
|
||||||
|
|
||||||
@@ -45,6 +63,11 @@ class GameRuntime {
|
|||||||
: 'A new tale begins. Choose an action.',
|
: '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.publish();
|
||||||
this.installLifecycleHooks();
|
this.installLifecycleHooks();
|
||||||
this.rafId = requestAnimationFrame(this.frame);
|
this.rafId = requestAnimationFrame(this.frame);
|
||||||
@@ -63,6 +86,11 @@ class GameRuntime {
|
|||||||
if (!state) {
|
if (!state) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isActionAvailable(state, content, actionId)) {
|
||||||
|
const actionView = toView(state, content).actions.find((a) => a.id === actionId);
|
||||||
|
useGameStore.getState().appendLog(actionView?.disabledReason ?? 'Cannot enqueue');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
engineEnqueueAction(state, content, actionId);
|
engineEnqueueAction(state, content, actionId);
|
||||||
const action = content.actionsById[actionId];
|
const action = content.actionsById[actionId];
|
||||||
@@ -77,12 +105,112 @@ class GameRuntime {
|
|||||||
this.publish();
|
this.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyStoryChoice(choiceId: string): void {
|
||||||
|
const state = this.state;
|
||||||
|
if (!state) return;
|
||||||
|
try {
|
||||||
|
const events = engineApplyChoice(state, content, choiceId);
|
||||||
|
const entries = storyEventsToLogEntries(events);
|
||||||
|
const store = useGameStore.getState();
|
||||||
|
for (const entry of entries) store.appendStoryLog(entry);
|
||||||
|
const choiceLabel = entries.at(-1)?.choiceLabel;
|
||||||
|
if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`);
|
||||||
|
store.setStoryPanelOpen(false);
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openStoryPanel(): void {
|
||||||
|
useGameStore.getState().setStoryPanelOpen(true);
|
||||||
|
useGameStore.getState().setStoryHasUnread(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeStoryPanel(): void {
|
||||||
|
useGameStore.getState().setStoryPanelOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (shouldAutoOpenPanel(prefs, 'fork_choice', content)) {
|
||||||
|
store.setStoryPanelOpen(true);
|
||||||
|
store.setStoryHasUnread(false);
|
||||||
|
} else {
|
||||||
|
store.setStoryHasUnread(true);
|
||||||
|
store.setStoryPanelOpen(false);
|
||||||
|
}
|
||||||
|
this.publish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.closeStoryPanel();
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyStoryUiEffect(effect: StoryUiEffect): void {
|
||||||
|
const store = useGameStore.getState();
|
||||||
|
for (const entry of effect.logEntries) store.appendStoryLog(entry);
|
||||||
|
for (const line of effect.eventLogLines) store.appendLog(line);
|
||||||
|
if (effect.shouldOpenPanel) {
|
||||||
|
store.setStoryPanelOpen(true);
|
||||||
|
store.setStoryHasUnread(false);
|
||||||
|
} 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 => {
|
private readonly frame = (monoNow: number): void => {
|
||||||
const state = this.state;
|
const state = this.state;
|
||||||
if (state) {
|
if (state) {
|
||||||
advance(this.loop, monoNow, () => tickGame(state, content, TICK_MS));
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {
|
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {
|
||||||
|
this.runPublishTriggers();
|
||||||
this.publish();
|
this.publish();
|
||||||
this.lastPublishAt = monoNow;
|
this.lastPublishAt = monoNow;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user