feat(state): runtime performAction and nav story signals
This commit is contained in:
+85
-40
@@ -1,20 +1,20 @@
|
||||
import { content } from '../content';
|
||||
import {
|
||||
cancelQueuedAction as engineCancelQueuedAction,
|
||||
enqueueAction as engineEnqueueAction,
|
||||
type GameState,
|
||||
isActionAvailable,
|
||||
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 { useGameStore } from './store';
|
||||
import { type ActivePanel, useGameStore } from './store';
|
||||
import {
|
||||
processStoryTriggers,
|
||||
type StoryUiEffect,
|
||||
shouldAutoOpenPanel,
|
||||
shouldAutoNavigateToStory,
|
||||
storyEventsToLogEntries,
|
||||
} from './storyOrchestration';
|
||||
import { formatOfflineDuration, toView } from './viewModel';
|
||||
@@ -32,7 +32,7 @@ import { formatOfflineDuration, toView } from './viewModel';
|
||||
const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh
|
||||
const AUTOSAVE_INTERVAL_MS = 10_000;
|
||||
|
||||
class GameRuntime {
|
||||
export class GameRuntime {
|
||||
private state: GameState | null = null;
|
||||
private readonly loop: TickLoop = createTickLoop();
|
||||
private readonly backend: SaveBackend = createDefaultBackend();
|
||||
@@ -77,45 +77,83 @@ class GameRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
enqueueAction(actionId: string): void {
|
||||
performAction(actionId: string): void {
|
||||
const state = this.state;
|
||||
if (!state) {
|
||||
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;
|
||||
}
|
||||
if (!state) return;
|
||||
const action = content.actionsById[actionId];
|
||||
if (!action) return;
|
||||
|
||||
try {
|
||||
engineEnqueueAction(state, content, actionId);
|
||||
const action = content.actionsById[actionId];
|
||||
if (action) {
|
||||
const isStory = action.kind === 'story';
|
||||
const events = performActionEngine(state, content, actionId);
|
||||
const store = useGameStore.getState();
|
||||
|
||||
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';
|
||||
useGameStore.getState().appendLog(`${verb}: ${action.name}.`);
|
||||
store.appendLog(`${verb}: ${action.name}.`);
|
||||
}
|
||||
|
||||
this.publish();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Cannot start action';
|
||||
useGameStore.getState().appendLog(msg);
|
||||
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Action failed');
|
||||
}
|
||||
this.publish();
|
||||
}
|
||||
|
||||
setActivePanel(panel: ActivePanel): void {
|
||||
const store = useGameStore.getState();
|
||||
store.setActivePanel(panel);
|
||||
if (panel === 'story') {
|
||||
store.setStoryHasUnread(false);
|
||||
const state = this.state;
|
||||
if (state && state.currentStoryNodeId === 'boot_intro') {
|
||||
const events = enterStoryNode(state, content, 'fork_choice');
|
||||
const entries = storyEventsToLogEntries(events);
|
||||
for (const entry of entries) {
|
||||
store.appendStoryLog(entry);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const nodeProse = content.storyNodesById[entry.nodeId]?.prose ?? '';
|
||||
store.appendLog(`Story: ${nodeProse.slice(0, 40)}…`);
|
||||
}
|
||||
this.publish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enqueueAction(actionId: string): void {
|
||||
this.performAction(actionId);
|
||||
}
|
||||
|
||||
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');
|
||||
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();
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,12 +170,11 @@ class GameRuntime {
|
||||
}
|
||||
|
||||
openStoryPanel(): void {
|
||||
useGameStore.getState().setStoryPanelOpen(true);
|
||||
useGameStore.getState().setStoryHasUnread(false);
|
||||
this.setActivePanel('story');
|
||||
}
|
||||
|
||||
closeStoryPanel(): void {
|
||||
useGameStore.getState().setStoryPanelOpen(false);
|
||||
this.setActivePanel('play');
|
||||
}
|
||||
|
||||
continueStory(): void {
|
||||
@@ -152,7 +189,7 @@ class GameRuntime {
|
||||
store.appendLog(`Story: ${content.storyNodesById[entry.nodeId]?.prose.slice(0, 40)}…`);
|
||||
}
|
||||
const prefs = store.prefs;
|
||||
if (shouldAutoOpenPanel(prefs, 'fork_choice', content)) {
|
||||
if (shouldAutoNavigateToStory(prefs, 'fork_choice', content)) {
|
||||
store.setStoryPanelOpen(true);
|
||||
store.setStoryHasUnread(false);
|
||||
} else {
|
||||
@@ -168,11 +205,18 @@ class GameRuntime {
|
||||
|
||||
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);
|
||||
}
|
||||
store.setStoryPanelOpen(true);
|
||||
store.setStoryHasUnread(false);
|
||||
} else if (effect.enteredNodeIds.length > 0) {
|
||||
store.setStoryHasUnread(true);
|
||||
}
|
||||
@@ -201,6 +245,7 @@ class GameRuntime {
|
||||
);
|
||||
this.applyStoryUiEffect(effect);
|
||||
}
|
||||
maybeStartLoopAction(state, content);
|
||||
});
|
||||
|
||||
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {
|
||||
|
||||
Reference in New Issue
Block a user