Add storage-backed story history

This commit is contained in:
2026-05-15 21:58:30 +02:00
parent f2e786d5bc
commit 42582352d6
16 changed files with 1048 additions and 113 deletions
+36 -4
View File
@@ -9,7 +9,7 @@ class GameLoopModule extends BaseModule {
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'];
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 = {
@@ -30,6 +30,7 @@ class GameLoopModule extends BaseModule {
'updateGameState',
'updateUIState',
'refreshGameApiState',
'hasSaveGame',
'requestStartGame',
'requestSaveGame',
'requestLoadGame',
@@ -136,7 +137,7 @@ class GameLoopModule extends BaseModule {
const [running, hasSave] = await Promise.all([
socketClient.isGameRunning(),
socketClient.hasSaveGame(1)
this.hasSaveGame(1)
]);
this.gameState.started = Boolean(running?.result);
@@ -191,6 +192,10 @@ class GameLoopModule extends BaseModule {
if (!socketClient) return;
await this.resetClientPlaybackAndDisplay();
const storyHistory = this.getModule('story-history');
if (storyHistory && typeof storyHistory.startNewGame === 'function') {
await storyHistory.startNewGame();
}
const response = await socketClient.newGame();
if (!response?.success) {
console.error('GameLoop: newGame failed', response);
@@ -211,6 +216,12 @@ class GameLoopModule extends BaseModule {
const response = await socketClient.saveGame(1);
if (response?.success) {
const storyHistory = this.getModule('story-history');
if (storyHistory && typeof storyHistory.saveSlot === 'function') {
await storyHistory.saveSlot(1, {
inkState: response.savedState || null
});
}
this.gameState.canLoad = true;
this.updateUIState();
}
@@ -223,7 +234,11 @@ class GameLoopModule extends BaseModule {
const socketClient = this.getModule('socket-client');
if (!socketClient) return;
const hasSave = await socketClient.hasSaveGame(1);
const storyHistory = this.getModule('story-history');
const browserSave = storyHistory && typeof storyHistory.loadSlot === 'function'
? await storyHistory.loadSlot(1)
: null;
const hasSave = browserSave ? { result: true } : await socketClient.hasSaveGame(1);
if (!hasSave?.result) {
this.gameState.canLoad = false;
this.updateUIState();
@@ -231,7 +246,14 @@ class GameLoopModule extends BaseModule {
}
await this.resetClientPlaybackAndDisplay();
const response = await socketClient.loadGame(1);
if (browserSave?.gameId && storyHistory?.setCurrentGame) {
storyHistory.setCurrentGame(browserSave.gameId, browserSave.latestBlockId || 0);
}
const uiController = this.getModule('ui-controller');
if (browserSave && uiController?.displayHandler?.restoreFromHistory) {
await uiController.displayHandler.restoreFromHistory(browserSave);
}
const response = await socketClient.loadGame(1, browserSave?.inkState || null);
if (response?.success) {
this.gameState.started = true;
this.gameState.canSave = true;
@@ -240,6 +262,16 @@ class GameLoopModule extends BaseModule {
}
}
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 resetClientPlaybackAndDisplay() {
const playbackCoordinator = this.getModule('playback-coordinator');
if (playbackCoordinator && typeof playbackCoordinator.stop === 'function') {
+1
View File
@@ -103,6 +103,7 @@ const ModuleLoader = (function() {
// Core functionality modules
{ id: 'persistence-manager', script: '/js/persistence-manager-module.js', weight: 12 },
{ id: 'localization', script: '/js/localization-module.js', weight: 12 },
{ id: 'story-history', script: '/js/story-history-module.js', weight: 8 },
{ id: 'game-config', script: '/js/game-config-module.js', weight: 8 },
{ id: 'text-processor', script: '/js/text-processor-module.js', weight: 15 },
{ id: 'markup-parser', script: '/js/markup-parser-module.js', weight: 5 },
+25 -1
View File
@@ -21,6 +21,8 @@ class SentenceQueueModule extends BaseModule {
this.prefetchingCache = new Map();
this.activeImageWrap = null;
this.autoplay = true;
this.inputMode = 'text';
this.lastContinueAt = 0;
// Bind methods
this.bindMethods([
@@ -79,6 +81,14 @@ class SentenceQueueModule extends BaseModule {
this.autoplay = value !== false;
}
});
this.addEventListener(document, 'story:input-mode', (event) => {
this.inputMode = ['text', 'choice', 'end'].includes(event.detail) ? event.detail : 'text';
});
this.addEventListener(document, 'ui:command', (event) => {
if (event.detail?.type === 'continue') {
this.lastContinueAt = performance.now();
}
});
return true;
} catch (error) {
console.error("Error initializing Sentence Queue:", error);
@@ -139,6 +149,7 @@ class SentenceQueueModule extends BaseModule {
if (this.onSentenceReadyCallback) {
await new Promise(resolve => {
sentence.onComplete = resolve;
sentence.playbackStartedAt = performance.now();
this.onSentenceReadyCallback(sentence, resolve);
});
}
@@ -148,7 +159,7 @@ class SentenceQueueModule extends BaseModule {
await this.waitForSkippableMediaPause(mediaPauseSeconds, sentence.kind, sentence.id);
}
if (sentence.kind === 'paragraph' && !this.shouldAutoplay()) {
if (this.shouldPauseAfterSentence(sentence)) {
await this.waitForManualContinue(sentence.id);
}
@@ -495,6 +506,19 @@ class SentenceQueueModule extends BaseModule {
}
}
shouldPauseAfterSentence(sentence) {
if (sentence.kind !== 'paragraph' || this.shouldAutoplay()) {
return false;
}
if (this.lastContinueAt >= (sentence.playbackStartedAt || 0)) {
return false;
}
if (this.sentenceQueue.length <= 1 && this.inputMode === 'choice') {
return false;
}
return this.sentenceQueue.length > 1;
}
shouldAutoplay() {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
+3 -3
View File
@@ -131,7 +131,7 @@ class SocketClientModule extends BaseModule {
// Create Socket.IO connection (will automatically use /socket.io endpoint)
this.socket = window.io(socketUrl, {
reconnection: false, // We handle reconnection ourselves
transports: ['websocket', 'polling'] // Prefer WebSocket
transports: ['polling', 'websocket']
});
this.socket.on('connect', () => {
@@ -573,8 +573,8 @@ class SocketClientModule extends BaseModule {
return this.callGameApi('newGame', []);
}
loadGame(slot = 1) {
return this.callGameApi('loadGame', [slot]);
loadGame(slot = 1, savedState = null) {
return this.callGameApi('loadGame', savedState ? [slot, savedState] : [slot]);
}
saveGame(slot = 1) {
+189
View File
@@ -0,0 +1,189 @@
/**
* Story History Module
* Stores rendered story blocks in IndexedDB and keeps only a short live window
* in the page DOM.
*/
import { BaseModule } from './base-module.js';
class StoryHistoryModule extends BaseModule {
constructor() {
super('story-history', 'Story History');
this.dependencies = ['persistence-manager'];
this.dbName = 'ttsAudioCacheDB';
this.dbVersion = 2;
this.historyStore = 'storyHistoryStore';
this.saveStore = 'storySaveStore';
this.db = null;
this.currentGameId = null;
this.nextBlockId = 1;
this.visibleLimit = 20;
this.bindMethods([
'initialize',
'openDB',
'startNewGame',
'setCurrentGame',
'recordBlock',
'saveSlot',
'loadSlot',
'hasSaveSlot',
'getSaveSlots',
'getBlocks',
'clearGame',
'tx'
]);
}
async initialize() {
await this.openDB();
this.reportProgress(100, 'Story history ready');
return true;
}
openDB() {
if (this.db) return Promise.resolve(this.db);
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(this.historyStore)) {
const historyStore = db.createObjectStore(this.historyStore, { keyPath: 'key' });
historyStore.createIndex('gameId', 'gameId', { unique: false });
historyStore.createIndex('gameOrder', ['gameId', 'blockId'], { unique: true });
}
if (!db.objectStoreNames.contains(this.saveStore)) {
db.createObjectStore(this.saveStore, { keyPath: 'slot' });
}
};
});
}
tx(storeName, mode = 'readonly') {
return this.db.transaction(storeName, mode).objectStore(storeName);
}
async startNewGame() {
const gameId = `game-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this.currentGameId = gameId;
this.nextBlockId = 1;
const persistenceManager = this.getModule('persistence-manager');
persistenceManager?.updatePreference?.('app', 'currentGameId', gameId);
return gameId;
}
setCurrentGame(gameId, latestBlockId = 0) {
this.currentGameId = gameId || this.currentGameId;
this.nextBlockId = Math.max(1, Number(latestBlockId || 0) + 1);
}
recordBlock(block) {
if (!this.db || !this.currentGameId || !block) return Promise.resolve(null);
const blockId = this.nextBlockId++;
const record = {
...block,
key: `${this.currentGameId}:${blockId}`,
gameId: this.currentGameId,
blockId,
createdAt: Date.now()
};
return new Promise((resolve, reject) => {
const request = this.tx(this.historyStore, 'readwrite').put(record);
request.onsuccess = () => resolve(record);
request.onerror = () => reject(request.error);
});
}
saveSlot(slot = 1, saveData = {}) {
if (!this.db) return Promise.resolve(false);
const record = {
slot,
...saveData,
gameId: saveData.gameId || this.currentGameId,
latestBlockId: Math.max(0, this.nextBlockId - 1),
savedAt: Date.now()
};
return new Promise((resolve, reject) => {
const request = this.tx(this.saveStore, 'readwrite').put(record);
request.onsuccess = () => resolve(record);
request.onerror = () => reject(request.error);
});
}
loadSlot(slot = 1) {
if (!this.db) return Promise.resolve(null);
return new Promise((resolve, reject) => {
const request = this.tx(this.saveStore).get(slot);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
async hasSaveSlot(slot = 1) {
return Boolean(await this.loadSlot(slot));
}
getSaveSlots() {
if (!this.db) return Promise.resolve([]);
return new Promise((resolve, reject) => {
const request = this.tx(this.saveStore).getAllKeys();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
getBlocks(gameId = this.currentGameId, limit = this.visibleLimit, beforeBlockId = Infinity) {
if (!this.db || !gameId) return Promise.resolve([]);
return new Promise((resolve, reject) => {
const blocks = [];
const index = this.tx(this.historyStore).index('gameOrder');
const upper = Number.isFinite(beforeBlockId) ? beforeBlockId : Number.MAX_SAFE_INTEGER;
const range = IDBKeyRange.bound([gameId, 0], [gameId, upper], false, true);
const request = index.openCursor(range, 'prev');
request.onsuccess = () => {
const cursor = request.result;
if (!cursor || blocks.length >= limit) {
resolve(blocks.reverse());
return;
}
blocks.push(cursor.value);
cursor.continue();
};
request.onerror = () => reject(request.error);
});
}
clearGame(gameId = this.currentGameId) {
if (!this.db || !gameId) return Promise.resolve();
return new Promise((resolve, reject) => {
const store = this.tx(this.historyStore, 'readwrite');
const index = store.index('gameId');
const request = index.openCursor(IDBKeyRange.only(gameId));
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve();
return;
}
cursor.delete();
cursor.continue();
};
request.onerror = () => reject(request.error);
});
}
}
const storyHistory = new StoryHistoryModule();
export { storyHistory as StoryHistory };
if (window.moduleRegistry) {
window.moduleRegistry.register(storyHistory);
}
window.StoryHistory = storyHistory;
+11 -1
View File
@@ -32,7 +32,7 @@ class TTSFactoryModule extends BaseModule {
this.db = null; // Will hold the DB connection
this.dbName = 'ttsAudioCacheDB';
this.storeName = 'audioCacheStore';
this.dbVersion = 1;
this.dbVersion = 2;
this.currentCacheSize = 0; // Track current size in bytes
this.maxCacheSizeBytes = 100 * 1024 * 1024; // 100 MB by default
this.cacheInitialized = false;
@@ -1537,6 +1537,16 @@ class TTSFactoryModule extends BaseModule {
console.log("Created 'size' index.");
}
}
if (!db.objectStoreNames.contains('storyHistoryStore')) {
const historyStore = db.createObjectStore('storyHistoryStore', { keyPath: 'key' });
historyStore.createIndex('gameId', 'gameId', { unique: false });
historyStore.createIndex('gameOrder', ['gameId', 'blockId'], { unique: true });
console.log("Object store 'storyHistoryStore' created.");
}
if (!db.objectStoreNames.contains('storySaveStore')) {
db.createObjectStore('storySaveStore', { keyPath: 'slot' });
console.log("Object store 'storySaveStore' created.");
}
};
});
}
+163 -5
View File
@@ -9,7 +9,7 @@ class UIDisplayHandlerModule extends BaseModule {
super('ui-display-handler', 'UI Display Handler');
// Module dependencies
this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization'];
this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue'];
// DOM elements
this.container = null;
@@ -20,6 +20,7 @@ class UIDisplayHandlerModule extends BaseModule {
this.resizeTimer = null;
this.storyResizeObserver = null;
this.lastStoryMetrics = null;
this.visibleBlockLimit = 20;
// Resources to preload
this.cssPath = '/css/style.css';
@@ -35,6 +36,12 @@ class UIDisplayHandlerModule extends BaseModule {
'applyTranslations',
'displayText',
'renderSentence',
'recordRenderedItem',
'trimVisibleBlocks',
'restoreFromHistory',
'renderStoredItem',
'loadPreviousHistoryPage',
'updateStoryScrollbar',
'handleDeferredMediaBlock',
'renderImageBlock',
'calculateImageMetrics',
@@ -76,6 +83,7 @@ class UIDisplayHandlerModule extends BaseModule {
this.playbackCoordinator = this.getModule('playback-coordinator');
this.gameConfig = this.getModule('game-config');
this.localization = this.getModule('localization');
this.storyHistory = this.getModule('story-history');
this.reportProgress(50, "Initializing display containers");
@@ -97,6 +105,14 @@ class UIDisplayHandlerModule extends BaseModule {
this.addEventListener(document, 'story:scroll-to-turn', (event) => {
this.scrollToTurn(event.detail?.turnId);
});
this.addEventListener(document, 'story:history-updated', (event) => {
this.updateStoryScrollbar(event.detail || {});
});
this.addEventListener(document, 'wheel', (event) => {
if (event.target?.closest?.('#page_right') && event.deltaY < 0 && this.pageRight?.scrollTop <= 2) {
this.loadPreviousHistoryPage();
}
}, { passive: true });
this.addEventListener(document, 'story:process-state', (event) => {
const state = event.detail?.state || 'ready';
const remark = document.getElementById('remark_text');
@@ -294,6 +310,12 @@ class UIDisplayHandlerModule extends BaseModule {
this.pageRight.id = 'page_right';
bookContainer.appendChild(this.pageRight);
}
if (!document.getElementById('story_scrollbar')) {
const storyScrollbar = document.createElement('div');
storyScrollbar.id = 'story_scrollbar';
storyScrollbar.innerHTML = '<div id="story_scrollbar_thumb"></div>';
this.pageRight.appendChild(storyScrollbar);
}
// Create or find story container
this.container = document.getElementById('story');
@@ -478,7 +500,7 @@ class UIDisplayHandlerModule extends BaseModule {
// Store element reference in sentence
sentence.element = paragraphElement;
this.renderedItems.push({
await this.recordRenderedItem({
type: sentence.kind === 'heading' ? 'heading' : 'paragraph',
id: sentence.id,
turnId: sentence.turnId ?? null,
@@ -493,6 +515,7 @@ class UIDisplayHandlerModule extends BaseModule {
paragraphIndex: sentence.paragraphIndex
}
});
await this.trimVisibleBlocks();
this.scrollStoryToEnd(true);
@@ -591,6 +614,137 @@ class UIDisplayHandlerModule extends BaseModule {
});
}
async restoreFromHistory(saveRecord = {}) {
if (!this.paragraphContainer || !this.storyHistory || !saveRecord?.gameId) return;
const sentenceQueue = this.getModule('sentence-queue');
if (!sentenceQueue || typeof sentenceQueue.prepareLayout !== 'function') return;
const blocks = await this.storyHistory.getBlocks(
saveRecord.gameId,
this.visibleBlockLimit,
(saveRecord.latestBlockId || Number.MAX_SAFE_INTEGER) + 1
);
this.paragraphContainer.innerHTML = '';
this.renderedItems = [];
for (const item of blocks) {
await this.renderStoredItem(item);
}
this.updateStoryScrollbar({ latestBlockId: saveRecord.latestBlockId || blocks.at(-1)?.blockId || 1 });
this.scrollStoryToEnd(false);
}
async renderStoredItem(item) {
const sentenceQueue = this.getModule('sentence-queue');
if (!sentenceQueue) return null;
this.renderedItems.push(item);
if (item.type === 'image') {
const imageLayout = typeof sentenceQueue.prepareImageLayout === 'function'
? await sentenceQueue.prepareImageLayout(item.metadata || {})
: null;
const imageElement = this.renderImageBlock({
...(item.metadata || {}),
imageLayout: imageLayout || item.metadata?.imageLayout
}, false);
if (imageElement && item.blockId != null) imageElement.dataset.storyBlockId = String(item.blockId);
return imageElement;
}
if (item.type !== 'heading' && item.type !== 'paragraph') return null;
const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {});
const element = this.layoutRenderer.renderParagraph(layout, { id: item.id });
if (item.turnId != null) {
element.dataset.turnId = String(item.turnId);
element.classList.add('story-turn-block');
}
if (item.blockId != null) element.dataset.storyBlockId = String(item.blockId);
element.querySelectorAll('.word').forEach(word => {
word.style.transition = 'none';
word.style.animation = 'none';
word.style.visibility = 'visible';
word.style.opacity = '1';
word.style.transform = 'translateY(0)';
word.style.clipPath = 'inset(0 0 0 0)';
});
this.paragraphContainer.appendChild(element);
return element;
}
async loadPreviousHistoryPage() {
if (!this.storyHistory || !this.paragraphContainer || this.loadingHistoryPage) return;
const firstBlock = this.paragraphContainer.querySelector('[data-story-block-id]');
const beforeBlockId = Number(firstBlock?.dataset?.storyBlockId || 0);
if (!beforeBlockId || beforeBlockId <= 1) return;
this.loadingHistoryPage = true;
try {
const blocks = await this.storyHistory.getBlocks(
this.storyHistory.currentGameId,
this.visibleBlockLimit,
beforeBlockId
);
if (!blocks.length) return;
this.paragraphContainer.innerHTML = '';
this.renderedItems = [];
for (const item of blocks) {
await this.renderStoredItem(item);
}
this.pageRight.scrollTop = 0;
this.updateStoryScrollbar({ latestBlockId: this.storyHistory.nextBlockId - 1 });
} finally {
this.loadingHistoryPage = false;
}
}
async recordRenderedItem(item) {
this.renderedItems.push(item);
if (this.storyHistory && typeof this.storyHistory.recordBlock === 'function') {
try {
const record = await this.storyHistory.recordBlock(item);
if (record && item.id) {
item.blockId = record.blockId;
item.gameId = record.gameId;
const element = document.getElementById(item.id);
if (element) element.dataset.storyBlockId = String(record.blockId);
}
document.dispatchEvent(new CustomEvent('story:history-updated', {
detail: {
gameId: record?.gameId || null,
latestBlockId: record?.blockId || null
}
}));
} catch (error) {
console.warn('UIDisplayHandler: Failed to store story history item:', error);
}
}
}
updateStoryScrollbar(detail = {}) {
const thumb = document.getElementById('story_scrollbar_thumb');
if (!thumb) return;
const latest = Math.max(1, Number(detail.latestBlockId || this.storyHistory?.nextBlockId || 1));
const visible = Math.min(this.visibleBlockLimit, latest);
const heightPercent = Math.max(8, Math.min(100, (visible / latest) * 100));
const topPercent = latest <= visible ? 0 : 100 - heightPercent;
thumb.style.height = `${heightPercent}%`;
thumb.style.top = `${topPercent}%`;
}
async trimVisibleBlocks() {
if (!this.paragraphContainer) return;
const blocks = Array.from(this.paragraphContainer.querySelectorAll('.story-turn-block'));
const excess = blocks.length - this.visibleBlockLimit;
if (excess <= 0) return;
blocks.slice(0, excess).forEach(block => {
block.classList.add('story-block-archiving');
window.setTimeout(() => block.remove(), 360);
});
this.renderedItems = this.renderedItems.slice(Math.max(0, this.renderedItems.length - this.visibleBlockLimit));
}
animatePageScroll(targetTop, duration = 720) {
if (!this.pageRight) return;
if (!duration) {
@@ -662,14 +816,15 @@ class UIDisplayHandlerModule extends BaseModule {
}));
if (sentence.kind === 'image') {
const element = this.renderImageBlock(sentence.metadata || {}, true);
this.renderedItems.push({
const element = this.renderImageBlock({ ...(sentence.metadata || {}), id: sentence.id }, true);
await this.recordRenderedItem({
type: 'image',
id: sentence.id,
turnId: sentence.turnId ?? null,
text: '',
metadata: sentence.metadata || {}
metadata: { ...(sentence.metadata || {}), id: sentence.id }
});
await this.trimVisibleBlocks();
this.scrollStoryToEnd(true);
@@ -743,6 +898,9 @@ class UIDisplayHandlerModule extends BaseModule {
const metrics = metadata.imageLayout || this.calculateImageMetrics(metadata.size);
const figure = document.createElement('figure');
if (metadata.id) {
figure.id = metadata.id;
}
figure.className = [
'story-image-block',
`story-image-${metrics.size || 'landscape'}`,