Files
Georg 6bd1f45362 Reset per-game reveal state on new game so reveal animates over cached content
Starting a new game reuses block ids (1,2,3...). The reveal clock's per-block
start times (activeRevealBlockStarts in the lab) and the renderer's animation/
revealed sets are keyed by block id and were never cleared on a client reset, so
a new game over already-cached content inherited the previous run's start times.
beginPageReveal then computed a huge elapsed and the shader treated the reveal as
already complete — showing everything at once instead of animating.

resetClientPlaybackAndDisplay (run on new game and restore) now emits
story:client-reset; the lab clears activeRevealBlockStarts/pending reveal state,
the texture renderer clears active animations and revealed-block ids, and the
timeline invalidates prepared segments. So each game starts with a clean reveal
clock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:40:31 +02:00

676 lines
27 KiB
JavaScript

/**
* Game Loop Module for AI Interactive Fiction
* Manages the main game logic and connects various modules
*/
import { BaseModule } from './base-module.js';
class GameLoopModule extends BaseModule {
constructor() {
super('game-loop', 'Game Loop');
// Dependencies
this.dependencies = ['ui-controller', 'socket-client', 'text-buffer', 'sentence-queue', 'playback-coordinator', 'animation-queue', 'audio-manager', 'tts-factory', 'ui-input-handler', 'story-history'];
// Game state
this.gameState = {
started: false,
startedOnce: false,
ended: false,
canLoad: false,
currentRoom: null,
inventory: [],
commandHistory: []
};
this.isRunning = false;
this.autoSaveSlot = 'autosave';
this.currentChoices = [];
this.currentInputMode = 'none';
this.autoSaveInProgress = false;
this.autoSaveQueued = false;
this.resumeAttempted = false;
this.lastInkState = null;
this.clientResetGeneration = 0;
this.restoreGeneration = 0;
this.pendingHistoryRestoreCleanup = null;
this.gameOperationGeneration = 0;
this.autoSaveGeneration = 0;
// Bind methods using parent's bindMethods utility
this.bindMethods([
'start',
'setupUiEventListeners',
'setupSocketEventListeners',
'updateGameState',
'updateUIState',
'refreshGameApiState',
'hasSaveGame',
'queueUnrenderedHistoryBlocks',
'autoSaveCurrentSession',
'restoreBrowserSave',
'restoreInputStateFromSave',
'hasUnrenderedHistory',
'resumeAutosaveIfAvailable',
'requestStartGame',
'requestSaveGame',
'requestLoadGame',
'resetClientPlaybackAndDisplay',
'getWebGLBookState',
'applyWebGLBookState'
]);
}
clearPendingHistoryRestore(reason = 'cancelled') {
if (this.pendingHistoryRestoreCleanup) {
this.pendingHistoryRestoreCleanup(reason);
this.pendingHistoryRestoreCleanup = null;
return;
}
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: false, reason }
}));
}
async initialize() {
this.reportProgress(100, "Game loop initialized");
return true;
}
async start() {
console.log("GameLoop: Starting game sequence...");
try {
// The dependencies are now automatically available via getModule
this.updateUIState();
this.setupUiEventListeners();
console.log("GameLoop: Setting up socket listeners and connecting...");
// Set up socket event listeners and connect
const connected = await this.setupSocketEventListeners();
// Set the game loop as running
this.isRunning = true;
return connected;
} catch (error) {
console.error("Error starting game loop:", error);
return false;
}
}
setupUiEventListeners() {
if (this.uiEventsBound) return;
this.uiEventsBound = true;
document.addEventListener('ui:game:restart', () => this.requestStartGame());
document.addEventListener('ui:game:save', () => this.requestSaveGame());
document.addEventListener('ui:game:load', () => this.requestLoadGame());
document.addEventListener('story:input-mode', (event) => {
this.currentInputMode = ['text', 'choice', 'end', 'none'].includes(event.detail) ? event.detail : 'none';
if (event.detail !== 'end') {
return;
}
this.gameState.started = false;
this.gameState.ended = true;
this.gameState.canSave = false;
this.updateUIState();
this.autoSaveCurrentSession();
});
document.addEventListener('story:choices', (event) => {
this.currentChoices = Array.isArray(event.detail) ? event.detail : [];
});
document.addEventListener('story:turn-complete', (event) => {
const detail = event.detail || {};
this.currentChoices = Array.isArray(detail.choices) ? detail.choices : this.currentChoices;
this.currentInputMode = ['text', 'choice', 'end', 'none'].includes(detail.inputMode)
? detail.inputMode
: this.currentInputMode;
this.autoSaveCurrentSession();
});
}
setupSocketEventListeners() {
// Get the socket client module using parent's getModule method
const socketClient = this.getModule('socket-client');
if (!socketClient) {
console.error("Socket client module not found");
return Promise.resolve(false);
}
// Connect UI controller to socket client for command handling
const uiController = this.getModule('ui-controller');
if (uiController) {
uiController.socketClient = socketClient;
} else {
console.warn("GameLoop: UI Controller not ready for Socket Client assignment.");
}
// Listen for socket connection event
socketClient.on('connect', () => {
console.log("GameLoop: Socket connected event received.");
this.refreshGameApiState();
});
socketClient.on('disconnect', () => {
this.resumeAttempted = false;
});
// Listen for game state updates
socketClient.on('gameStateUpdate', (data) => {
console.log("GameLoop: Game state update received", data);
this.updateGameState(data);
});
// Listen for narrative responses
socketClient.on('narrativeResponse', (data) => {
console.log("GameLoop: Narrative response received", data);
// Text processing is handled by socket-client -> text-buffer -> ui-controller pipeline
});
socketClient.on('gameSaved', () => {
this.gameState.canLoad = true;
this.updateUIState();
});
socketClient.on('gameLoaded', () => {
this.gameState.started = true;
this.gameState.canSave = true;
this.gameState.canLoad = true;
this.updateUIState();
});
// Connect to the socket server
return socketClient.connect().then(success => {
if (success) {
console.log("GameLoop: Socket connection established successfully.");
} else {
console.error("GameLoop: Failed to connect to socket server");
}
return success;
});
}
async refreshGameApiState() {
const socketClient = this.getModule('socket-client');
if (!socketClient || !socketClient.getConnectionStatus()) return;
const [running, hasSave] = await Promise.all([
socketClient.isGameRunning(),
this.hasSaveGame(1)
]);
this.gameState.started = Boolean(running?.result);
if (!this.gameState.started) {
const resumed = await this.resumeAutosaveIfAvailable();
if (resumed) return;
}
if (this.gameState.started) {
this.gameState.startedOnce = true;
this.gameState.ended = false;
}
this.gameState.canSave = this.gameState.started;
this.gameState.canLoad = Boolean(hasSave?.result);
this.updateUIState();
}
async resumeAutosaveIfAvailable() {
if (this.resumeAttempted) return false;
this.resumeAttempted = true;
const operationGeneration = ++this.gameOperationGeneration;
const isCurrentOperation = () => operationGeneration === this.gameOperationGeneration;
const storyHistory = this.getModule('story-history');
const socketClient = this.getModule('socket-client');
if (!storyHistory || !socketClient || typeof storyHistory.loadSlot !== 'function') {
return false;
}
const browserSave = await storyHistory.loadSlot(this.autoSaveSlot);
if (!isCurrentOperation()) return false;
if (!browserSave?.inkState || browserSave.running === false) {
return false;
}
await this.resetClientPlaybackAndDisplay();
if (!isCurrentOperation()) return false;
this.currentChoices = [];
this.currentInputMode = 'none';
document.dispatchEvent(new CustomEvent('story:choices', { detail: [] }));
document.dispatchEvent(new CustomEvent('story:input-mode', { detail: 'none' }));
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: true, reason: 'autosave-reconnect-prepare' }
}));
const response = await socketClient.resumeGame(browserSave.inkState);
if (!isCurrentOperation()) return false;
if (!response?.success) {
console.warn('GameLoop: autosave resume failed', response);
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: false, reason: 'autosave-reconnect-failed' }
}));
return false;
}
this.gameState.started = Boolean(response.running);
this.gameState.startedOnce = true;
this.gameState.ended = !response.running && browserSave.inputMode === 'end';
this.gameState.canSave = this.gameState.started;
this.gameState.canLoad = true;
this.updateUIState();
await this.restoreBrowserSave(browserSave, 'autosave-resume', { resetDisplay: false });
if (!isCurrentOperation()) return false;
this.restoreInputStateFromSave(browserSave, 'autosave-resume');
return true;
}
/**
* Update the game state
* @param {Object} data - New game state data
*/
updateGameState(data) {
if (!data) return;
// Update game state
if (data.currentRoom) {
this.gameState.currentRoom = data.currentRoom;
}
if (data.inventory) {
this.gameState.inventory = data.inventory;
}
// Update UI with new game state
this.updateUIState();
}
/**
* Update UI with current game state
*/
updateUIState() {
const uiController = this.getModule('ui-controller');
if (!uiController) return;
// Update UI components based on game state
const state = {
canRestart: true,
canSave: Boolean(this.gameState.canSave && this.gameState.started),
canLoad: Boolean(this.gameState.canLoad),
gameStarted: Boolean(this.gameState.started || this.gameState.startedOnce || this.gameState.ended)
};
document.body.dataset.gameRunning = state.gameStarted ? 'true' : 'false';
uiController.updateButtonStates(state);
}
/**
* Request to start a new game
*/
async requestStartGame() {
const socketClient = this.getModule('socket-client');
if (!socketClient) return;
const operationGeneration = ++this.gameOperationGeneration;
const isCurrentOperation = () => operationGeneration === this.gameOperationGeneration;
this.gameState.started = true;
this.gameState.startedOnce = true;
this.gameState.ended = false;
this.gameState.canSave = true;
this.updateUIState();
await this.resetClientPlaybackAndDisplay();
if (!isCurrentOperation()) return;
const storyHistory = this.getModule('story-history');
if (storyHistory && typeof storyHistory.startNewGame === 'function') {
await storyHistory.startNewGame();
if (!isCurrentOperation()) return;
if (typeof storyHistory.saveSlot === 'function') {
await storyHistory.saveSlot(this.autoSaveSlot, {
inkState: null,
webglBookState: this.getWebGLBookState(),
choices: [],
inputMode: 'none',
running: false
});
}
}
const response = await socketClient.newGame();
if (!isCurrentOperation()) return;
if (!response?.success) {
console.error('GameLoop: newGame failed', response);
this.gameState.started = false;
this.gameState.canSave = false;
this.updateUIState();
return;
}
this.gameState.started = true;
this.gameState.startedOnce = true;
this.gameState.ended = false;
this.gameState.canSave = true;
this.gameState.canLoad = Boolean(response.canLoad);
this.updateUIState();
if (response.savedState && storyHistory && typeof storyHistory.saveSlot === 'function') {
if (!isCurrentOperation()) return;
await storyHistory.saveSlot(this.autoSaveSlot, {
inkState: response.savedState,
webglBookState: this.getWebGLBookState(),
choices: [],
inputMode: 'none',
running: true
});
this.lastInkState = response.savedState;
}
}
/**
* Request to save the current game
*/
async requestSaveGame() {
const socketClient = this.getModule('socket-client');
if (!socketClient || !this.gameState.started) return;
const response = await socketClient.saveGame(1);
if (response?.success) {
const storyHistory = this.getModule('story-history');
const audioManager = this.getModule('audio-manager');
if (storyHistory && typeof storyHistory.saveSlot === 'function') {
await storyHistory.saveSlot(1, {
inkState: response.savedState || null,
latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0,
renderedLineCount: storyHistory.renderedLineCount || 0,
musicState: audioManager?.getMusicState?.() || null,
webglBookState: this.getWebGLBookState(),
choices: this.currentChoices,
inputMode: this.currentInputMode,
running: this.gameState.started && !this.gameState.ended
});
this.lastInkState = response.savedState || this.lastInkState;
}
this.gameState.canLoad = true;
this.updateUIState();
}
}
/**
* Request to load a saved game
*/
async requestLoadGame() {
const socketClient = this.getModule('socket-client');
if (!socketClient) return;
const operationGeneration = ++this.gameOperationGeneration;
const isCurrentOperation = () => operationGeneration === this.gameOperationGeneration;
const storyHistory = this.getModule('story-history');
const browserSave = storyHistory && typeof storyHistory.loadSlot === 'function'
? await storyHistory.loadSlot(1)
: null;
if (!isCurrentOperation()) return;
const hasSave = browserSave ? { result: true } : await socketClient.hasSaveGame(1);
if (!isCurrentOperation()) return;
if (!hasSave?.result) {
this.gameState.canLoad = false;
this.updateUIState();
return;
}
this.gameState.started = true;
this.gameState.startedOnce = true;
this.gameState.ended = false;
this.gameState.canSave = true;
this.gameState.canLoad = true;
this.updateUIState();
await this.restoreBrowserSave(browserSave, 'load-game', { resetDisplay: true });
if (!isCurrentOperation()) return;
const response = await socketClient.loadGame(1, browserSave?.inkState || null);
if (!isCurrentOperation()) return;
if (response?.success && browserSave && Array.isArray(browserSave.choices)) {
this.currentChoices = browserSave.choices;
this.currentInputMode = browserSave.inputMode || this.currentInputMode;
}
if (response?.success) {
this.gameState.started = true;
this.gameState.startedOnce = true;
this.gameState.ended = false;
this.gameState.canSave = true;
this.gameState.canLoad = true;
this.updateUIState();
}
}
async restoreBrowserSave(browserSave, reason = 'load-game', options = {}) {
const storyHistory = this.getModule('story-history');
if (!browserSave || !storyHistory) return;
if (options.resetDisplay) {
await this.resetClientPlaybackAndDisplay();
}
const restoreGeneration = ++this.restoreGeneration;
const resetGeneration = this.clientResetGeneration;
const isCurrentRestore = () =>
restoreGeneration === this.restoreGeneration &&
resetGeneration === this.clientResetGeneration;
this.clearPendingHistoryRestore(`${reason}-superseded`);
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: true, reason }
}));
if (browserSave?.gameId && storyHistory?.setCurrentGame) {
storyHistory.setCurrentGame(
browserSave.gameId,
browserSave.latestBlockId || 0,
browserSave.latestRenderedBlockId || 0,
browserSave.renderedLineCount || 0
);
}
this.applyWebGLBookState(browserSave.webglBookState);
const uiController = this.getModule('ui-controller');
if (browserSave && uiController?.displayHandler?.restoreFromHistory) {
await uiController.displayHandler.restoreFromHistory(browserSave);
if (!isCurrentRestore()) return;
}
const audioManager = this.getModule('audio-manager');
if (browserSave?.musicState && audioManager?.restoreMusicState) {
await audioManager.restoreMusicState(browserSave.musicState);
if (!isCurrentRestore()) return;
}
const hasUnrenderedHistory = this.hasUnrenderedHistory(browserSave);
if (hasUnrenderedHistory) {
const sentenceQueue = this.getModule('sentence-queue');
sentenceQueue?.pauseBeforeNext?.('load-resume');
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'waiting-generating', reason: 'restoring-pending-output' }
}));
}
if (hasUnrenderedHistory) {
await this.queueUnrenderedHistoryBlocks(browserSave, isCurrentRestore);
if (!isCurrentRestore()) return;
}
if (!hasUnrenderedHistory) {
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: false, reason: `${reason}-complete` }
}));
} else {
const clearRestoring = (eventOrReason = 'pending-output-drained') => {
const clearReason = typeof eventOrReason === 'string'
? eventOrReason
: 'pending-output-drained';
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: false, reason: clearReason }
}));
document.removeEventListener('tts:queue-empty', clearRestoring);
if (this.pendingHistoryRestoreCleanup === clearRestoring) {
this.pendingHistoryRestoreCleanup = null;
}
};
this.pendingHistoryRestoreCleanup = clearRestoring;
document.addEventListener('tts:queue-empty', clearRestoring);
}
}
restoreInputStateFromSave(browserSave, reason = 'load-game') {
const choices = Array.isArray(browserSave?.choices) ? browserSave.choices : [];
const savedMode = ['text', 'choice', 'end', 'none'].includes(browserSave?.inputMode)
? browserSave.inputMode
: null;
const inputMode = savedMode || (choices.length > 0 ? 'choice' : 'none');
this.currentChoices = choices;
this.currentInputMode = inputMode;
document.dispatchEvent(new CustomEvent('story:choices', { detail: choices }));
document.dispatchEvent(new CustomEvent('story:input-mode', { detail: inputMode }));
if (!this.hasUnrenderedHistory(browserSave)) {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: `${reason}-input-restored`, inputMode }
}));
}
}
getWebGLBookState() {
return window.WebGLBookPreferenceBridge?.getBookState?.() || null;
}
applyWebGLBookState(state = null) {
if (!state || typeof state !== 'object') return;
window.WebGLBookPreferenceBridge?.applyBookState?.(state);
}
hasUnrenderedHistory(browserSave) {
return Boolean(browserSave) &&
Number(browserSave.latestBlockId || 0) > Number(browserSave.latestRenderedBlockId || 0);
}
async hasSaveGame(slot = 1) {
const storyHistory = this.getModule('story-history');
if (storyHistory && typeof storyHistory.hasSaveSlot === 'function') {
const hasBrowserSave = await storyHistory.hasSaveSlot(slot);
if (hasBrowserSave) return { success: true, result: true, slot };
}
const socketClient = this.getModule('socket-client');
return socketClient?.hasSaveGame ? socketClient.hasSaveGame(slot) : { success: false, result: false };
}
async autoSaveCurrentSession() {
if (!this.gameState.startedOnce || this.autoSaveInProgress) {
this.autoSaveQueued = this.autoSaveInProgress;
return;
}
const socketClient = this.getModule('socket-client');
const storyHistory = this.getModule('story-history');
if (!socketClient || !storyHistory || typeof storyHistory.saveSlot !== 'function') {
return;
}
const autoSaveGeneration = this.autoSaveGeneration;
const gameId = storyHistory.currentGameId || null;
this.autoSaveInProgress = true;
try {
const response = this.gameState.started && typeof socketClient.exportGameState === 'function'
? await socketClient.exportGameState()
: null;
if (autoSaveGeneration !== this.autoSaveGeneration || storyHistory.currentGameId !== gameId) {
return;
}
if (!response?.success || !response.savedState) {
return;
}
this.lastInkState = response.savedState;
const audioManager = this.getModule('audio-manager');
await storyHistory.saveSlot(this.autoSaveSlot, {
gameId,
inkState: response.savedState,
latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0,
renderedLineCount: storyHistory.renderedLineCount || 0,
musicState: audioManager?.getMusicState?.() || null,
webglBookState: this.getWebGLBookState(),
choices: this.currentChoices,
inputMode: this.currentInputMode,
running: this.gameState.started && !this.gameState.ended
});
} finally {
this.autoSaveInProgress = false;
if (autoSaveGeneration === this.autoSaveGeneration && this.autoSaveQueued) {
this.autoSaveQueued = false;
this.autoSaveCurrentSession();
} else {
this.autoSaveQueued = false;
}
}
}
async queueUnrenderedHistoryBlocks(saveRecord = {}, isCurrentRestore = null) {
const storyHistory = this.getModule('story-history');
const textBuffer = this.getModule('text-buffer');
if (!storyHistory || !textBuffer || typeof textBuffer.addBlocks !== 'function') return;
const start = Math.max(1, Number(saveRecord.latestRenderedBlockId || 0) + 1);
const end = Math.max(0, Number(saveRecord.latestBlockId || 0));
if (end < start) return;
const blocks = await storyHistory.getBlocksRange(saveRecord.gameId, start, end);
if (typeof isCurrentRestore === 'function' && !isCurrentRestore()) return;
if (saveRecord.gameId && storyHistory.currentGameId !== saveRecord.gameId) return;
textBuffer.addBlocks(blocks);
}
async resetClientPlaybackAndDisplay() {
this.clientResetGeneration += 1;
this.restoreGeneration += 1;
this.autoSaveGeneration += 1;
this.clearPendingHistoryRestore('client-reset');
const playbackCoordinator = this.getModule('playback-coordinator');
if (playbackCoordinator && typeof playbackCoordinator.stop === 'function') {
await playbackCoordinator.stop();
}
const animationQueue = this.getModule('animation-queue');
if (animationQueue && typeof animationQueue.clearAll === 'function') {
animationQueue.clearAll();
}
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory && typeof ttsFactory.stop === 'function') {
ttsFactory.stop();
}
const audioManager = this.getModule('audio-manager');
if (audioManager && typeof audioManager.stopAllSounds === 'function') {
audioManager.stopAllSounds();
}
const sentenceQueue = this.getModule('sentence-queue');
if (sentenceQueue && typeof sentenceQueue.clear === 'function') {
sentenceQueue.clear();
}
const textBuffer = this.getModule('text-buffer');
if (textBuffer && typeof textBuffer.clear === 'function') {
textBuffer.clear();
}
const uiController = this.getModule('ui-controller');
if (uiController) {
uiController.clearDisplay();
}
const inputHandler = this.getModule('ui-input-handler');
if (inputHandler && typeof inputHandler.clearHistory === 'function') {
inputHandler.clearHistory();
}
// Signal a client reset so transient, block-id-keyed reveal/animation state is
// cleared. Without this, a new game that reuses block ids over already-cached
// content keeps the previous run's reveal start times and skips the animation.
document.dispatchEvent(new CustomEvent('story:client-reset', {
detail: { reason: 'client-reset' }
}));
}
}
// Create the singleton instance
const GameLoop = new GameLoopModule();
// Export the module
export { GameLoop };