Checkpoint before line-grid scrolling refactor

This commit is contained in:
2026-05-16 13:44:03 +02:00
parent 42582352d6
commit fe33e4f0ab
25 changed files with 1989 additions and 840 deletions
+35 -10
View File
@@ -102,6 +102,7 @@ html, body {
body {
overflow: hidden;
background-image: url('../images/brown-wooden-flooring.jpg');
/* background-image: url('../images/table.png'); */
background-position: center center;
background-size: cover;
background-repeat: no-repeat;
@@ -383,6 +384,7 @@ ol.choice {
width: var(--book-width);
height: var(--book-height);
background-image: url('../images/book-3057904.png');
/* background-image: url('../images/book.png'); */
background-size: contain; /* Changed from cover to contain */
background-position: center;
background-repeat: no-repeat; /* Prevents repeating the image when aspect ratio doesn't match */
@@ -407,17 +409,20 @@ ol.choice {
}
#story {
overflow-x: visible;
overflow: visible !important;
box-sizing: border-box;
overflow-anchor: none;
text-align: justify;
text-justify: inter-word;
margin-bottom: 0;
line-height: 1.5;
will-change: transform;
transform: translateY(0);
}
#paragraphs {
box-sizing: border-box;
overflow: visible !important;
overflow-anchor: none;
}
@@ -471,7 +476,7 @@ ol.choice {
.story-image-visible img {
opacity: 1;
clip-path: polygon(0 0, 220% 0, 0 220%);
transition: opacity 900ms ease, clip-path 900ms ease;
transition: opacity 2000ms ease, clip-path 2000ms ease;
}
/* #story p span {
@@ -486,8 +491,11 @@ ol.choice {
/* background-color: rgba(200,200,200,0.5); */
right: 7%;
padding-bottom: 0;
scroll-behavior: smooth;
overscroll-behavior: contain;
overflow: hidden !important;
overflow-x: hidden !important;
overflow-y: hidden !important;
scroll-behavior: auto;
overscroll-behavior: none;
overflow-anchor: none;
scrollbar-width: none;
/* transform: translateX(-1%) translateY(2%) rotateX(0deg) rotateY(-1deg) rotateZ(0deg); */
@@ -497,17 +505,29 @@ ol.choice {
display: none;
}
#page_right *,
#page_right *::-webkit-scrollbar {
scrollbar-width: none;
}
#page_right *::-webkit-scrollbar {
display: none;
}
#story_scrollbar {
position: sticky;
float: right;
top: 0.4rem;
position: absolute;
top: 0;
right: 0.35rem;
bottom: 0;
width: 0.22rem;
height: calc(100% - 0.8rem);
margin-right: -1.5rem;
height: 100%;
margin: 0;
border-radius: 999px;
background: rgba(0, 0, 0, 0.12);
z-index: 12;
pointer-events: none;
pointer-events: auto;
touch-action: none;
cursor: pointer;
}
#story_scrollbar_thumb {
@@ -519,6 +539,7 @@ ol.choice {
border-radius: inherit;
background: rgba(0, 0, 0, 0.72);
transition: top 260ms ease, height 260ms ease;
pointer-events: auto;
}
@@ -742,6 +763,7 @@ body:not([data-game-running="true"]) #command_history {
width: 100%;
margin: 0 0 1rem 0;
opacity: 0;
visibility: hidden;
transition: opacity 0.45s ease;
pointer-events: none;
}
@@ -750,6 +772,8 @@ body:not([data-game-running="true"]) #command_history {
display: none;
}
html[data-history-restoring="true"] .story-choices,
body:not([data-game-running="true"]) .story-choices,
html[data-process-state="waiting-generating"] .story-choices,
html[data-process-state="playing-generating"] .story-choices,
html[data-process-state="playing-ready"] .story-choices,
@@ -760,6 +784,7 @@ html[data-process-state="command-waiting"] .story-choices {
html[data-process-state="ready"] .story-choices[data-choice-ready="true"] {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

+100 -26
View File
@@ -22,6 +22,7 @@ class AudioManagerModule extends BaseModule {
this.activeTtsPlaybackCount = 0;
this.ttsQueueEmpty = true;
this.pendingMusicPlayback = null;
this.currentMusicState = null;
this.assetRoots = {
images: '/images/',
music: '/music/',
@@ -203,14 +204,14 @@ class AudioManagerModule extends BaseModule {
this.currentLoop = audio;
} else {
this.currentAudio = audio.cloneNode(true);
this.currentAudio.volume = this.getSfxVolume();
this.setMediaVolume(this.currentAudio, this.getSfxVolume());
this.currentAudio.play().catch(error => {
console.error('Error playing audio:', error);
});
return this.currentAudio;
}
audio.volume = this.getMusicVolume();
this.setMediaVolume(audio, this.getMusicVolume());
audio.play().catch(error => {
console.error('Error playing audio:', error);
});
@@ -233,14 +234,14 @@ class AudioManagerModule extends BaseModule {
}
this.currentLoop = new Audio(url);
this.currentLoop.loop = true;
this.currentLoop.volume = this.getMusicVolume();
this.setMediaVolume(this.currentLoop, this.getMusicVolume());
this.currentLoop.play().catch(error => {
console.error('Error playing audio loop:', error);
});
return this.currentLoop;
} else {
this.currentAudio = new Audio(url);
this.currentAudio.volume = this.getSfxVolume();
this.setMediaVolume(this.currentAudio, this.getSfxVolume());
this.currentAudio.play().catch(error => {
console.error('Error playing audio:', error);
});
@@ -331,19 +332,19 @@ class AudioManagerModule extends BaseModule {
updateVolumes() {
this.sounds.forEach(audio => {
const isMusic = audio.loop;
audio.volume = this.masterVolume * (isMusic ? this.musicVolume : this.sfxVolume);
this.setMediaVolume(audio, this.masterVolume * (isMusic ? this.musicVolume : this.sfxVolume));
});
if (this.currentAudio) {
this.currentAudio.volume = this.masterVolume * this.sfxVolume;
this.setMediaVolume(this.currentAudio, this.masterVolume * this.sfxVolume);
}
if (this.currentLoop) {
this.currentLoop.volume = this.getMusicVolume();
this.setMediaVolume(this.currentLoop, this.getMusicVolume());
}
if (this.currentMusic) {
this.currentMusic.volume = this.getMusicVolume();
this.setMediaVolume(this.currentMusic, this.getMusicVolume());
}
}
@@ -351,6 +352,11 @@ class AudioManagerModule extends BaseModule {
return Math.max(0, Math.min(1, Number.isFinite(Number(volume)) ? Number(volume) : 1));
}
setMediaVolume(audio, volume) {
if (!audio) return;
audio.volume = this.clampVolume(volume);
}
getSfxVolume() {
return this.masterVolume * this.sfxVolume;
}
@@ -387,8 +393,8 @@ class AudioManagerModule extends BaseModule {
const audio = this.currentMusic;
const token = ++this.musicFadeToken;
const startVolume = audio.volume;
const targetVolume = this.getUnduckedMusicVolume() * this.musicDuckingFactor;
const startVolume = this.clampVolume(audio.volume);
const targetVolume = this.clampVolume(this.getUnduckedMusicVolume() * this.musicDuckingFactor);
const start = performance.now();
const tick = () => {
@@ -396,7 +402,7 @@ class AudioManagerModule extends BaseModule {
return;
}
const progress = Math.min(1, (performance.now() - start) / duration);
audio.volume = startVolume + ((targetVolume - startVolume) * progress);
this.setMediaVolume(audio, startVolume + ((targetVolume - startVolume) * progress));
if (progress < 1) {
requestAnimationFrame(tick);
}
@@ -428,7 +434,7 @@ class AudioManagerModule extends BaseModule {
const promise = new Promise((resolve, reject) => {
const audio = new Audio(url);
audio.preload = 'auto';
audio.volume = this.getSfxVolume();
this.setMediaVolume(audio, this.getSfxVolume());
audio.addEventListener('canplaythrough', () => resolve(audio), { once: true });
audio.addEventListener('error', () => reject(new Error(`Failed to preload sound effect: ${url}`)), { once: true });
audio.load();
@@ -556,7 +562,7 @@ class AudioManagerModule extends BaseModule {
try {
const template = await this.preloadSfx(filename);
const audio = template.cloneNode(true);
audio.volume = this.getSfxVolume();
this.setMediaVolume(audio, this.getSfxVolume());
this.currentAudio = audio;
const maxDuration = Math.max(0, Number(options.maxDurationSeconds || options.maxDuration || 0)) * 1000;
const endMode = String(options.endMode || options.mode || 'stop').toLowerCase().startsWith('fade') ? 'fade' : 'stop';
@@ -595,12 +601,12 @@ class AudioManagerModule extends BaseModule {
fadeOutAudio(audio, duration = 1000) {
if (!audio) return Promise.resolve(false);
const startVolume = audio.volume;
const startVolume = this.clampVolume(audio.volume);
const startedAt = performance.now();
return new Promise(resolve => {
const step = () => {
const progress = Math.min(1, (performance.now() - startedAt) / duration);
audio.volume = startVolume * (1 - progress);
this.setMediaVolume(audio, startVolume * (1 - progress));
if (progress < 1 && !audio.paused && !audio.ended) {
requestAnimationFrame(step);
return;
@@ -617,6 +623,8 @@ class AudioManagerModule extends BaseModule {
async playMusic(filename, mode = 'crossfade', options = {}) {
const url = this.getAssetUrl('music', filename);
const shouldLoop = options.loop !== false;
const startAt = Math.max(0, Number(options.startAt ?? options.currentTime ?? 0) || 0);
const fadeInSeconds = Math.max(0, Number(options.fadeInSeconds ?? options.fadeIn ?? 0) || 0);
if (mode === 'queue' && this.currentMusic && !this.currentMusic.paused) {
this.queuedMusic = { filename, mode: 'cut', options: { loop: shouldLoop } };
@@ -631,22 +639,44 @@ class AudioManagerModule extends BaseModule {
const next = new Audio(url);
next.loop = shouldLoop;
next.volume = mode === 'crossfade' && this.currentMusic ? 0 : this.getMusicVolume();
this.setMediaVolume(next, (mode === 'crossfade' && this.currentMusic) || fadeInSeconds > 0 ? 0 : this.getMusicVolume());
if (startAt > 0) {
try {
next.currentTime = startAt;
} catch {
next.addEventListener('loadedmetadata', () => {
next.currentTime = Math.min(startAt, Number.isFinite(next.duration) ? next.duration : startAt);
}, { once: true });
}
}
next.addEventListener('ended', () => {
if (this.currentMusic === next) {
this.currentMusic = null;
this.currentMusicState = null;
}
});
const nextState = {
filename,
url,
loop: shouldLoop,
mode,
startedAt: Date.now()
};
if (mode === 'cut' || !this.currentMusic) {
this.stopCurrentMusic();
this.currentMusic = next;
this.currentMusicState = nextState;
await this.startMusicAudio(next, filename);
if (fadeInSeconds > 0) {
this.fadeAudioTo(next, this.getMusicVolume(), fadeInSeconds * 1000);
}
return next;
}
const previous = this.currentMusic;
this.currentMusic = next;
this.currentMusicState = nextState;
await this.startMusicAudio(next, filename);
this.crossfade(previous, next, 1500);
console.log(`AudioManager: Crossfading music to ${filename}`);
@@ -678,7 +708,7 @@ class AudioManagerModule extends BaseModule {
const pending = this.pendingMusicPlayback;
this.pendingMusicPlayback = null;
pending.audio.volume = this.getMusicVolume();
this.setMediaVolume(pending.audio, this.getMusicVolume());
try {
await pending.audio.play();
@@ -697,17 +727,61 @@ class AudioManagerModule extends BaseModule {
this.currentMusic.pause();
this.currentMusic.currentTime = 0;
this.currentMusic = null;
this.currentMusicState = null;
}
getMusicState() {
if (!this.currentMusic || this.currentMusic.paused || this.currentMusic.ended || !this.currentMusicState?.filename) {
return null;
}
return {
filename: this.currentMusicState.filename,
currentTime: Math.max(0, Number(this.currentMusic.currentTime || 0)),
loop: Boolean(this.currentMusic.loop),
mode: this.currentMusicState.mode || 'cut',
volume: this.currentMusic.volume,
duckingFactor: this.musicDuckingFactor
};
}
async restoreMusicState(state = null) {
if (!state?.filename) return null;
return this.playMusic(state.filename, 'cut', {
loop: state.loop !== false,
startAt: Number(state.currentTime || 0),
fadeInSeconds: 1.5
});
}
fadeAudioTo(audio, targetVolume, duration = 1000) {
if (!audio) return Promise.resolve(false);
const startVolume = this.clampVolume(audio.volume);
const target = this.clampVolume(targetVolume);
const startedAt = performance.now();
return new Promise(resolve => {
const step = (now) => {
const progress = duration <= 0 ? 1 : Math.min(1, (now - startedAt) / duration);
this.setMediaVolume(audio, startVolume + ((target - startVolume) * progress));
if (progress < 1 && !audio.paused && !audio.ended) {
requestAnimationFrame(step);
return;
}
resolve(true);
};
requestAnimationFrame(step);
});
}
crossfade(previous, next, duration = 1500) {
const start = performance.now();
const previousStart = previous ? previous.volume : 0;
const target = this.getMusicVolume();
const previousStart = previous ? this.clampVolume(previous.volume) : 0;
const target = this.clampVolume(this.getMusicVolume());
const tick = () => {
const progress = Math.min(1, (performance.now() - start) / duration);
if (previous) previous.volume = previousStart * (1 - progress);
next.volume = target * progress;
if (previous) this.setMediaVolume(previous, previousStart * (1 - progress));
this.setMediaVolume(next, target * progress);
if (progress < 1) {
requestAnimationFrame(tick);
@@ -718,7 +792,7 @@ class AudioManagerModule extends BaseModule {
previous.pause();
previous.currentTime = 0;
}
next.volume = this.getMusicVolume();
this.setMediaVolume(next, this.getMusicVolume());
};
tick();
@@ -747,7 +821,7 @@ class AudioManagerModule extends BaseModule {
}
const audio = this.currentAudio;
const initialVolume = audio.volume;
const initialVolume = this.clampVolume(audio.volume);
const volumeStep = initialVolume / (duration / 50);
let currentVolume = initialVolume;
@@ -757,10 +831,10 @@ class AudioManagerModule extends BaseModule {
clearInterval(fadeInterval);
audio.pause();
audio.currentTime = 0;
audio.volume = initialVolume; // Reset volume for future use
this.setMediaVolume(audio, initialVolume); // Reset volume for future use
resolve();
} else {
audio.volume = currentVolume;
this.setMediaVolume(audio, currentVolume);
}
}, 50);
});
@@ -806,7 +880,7 @@ class AudioManagerModule extends BaseModule {
}
// Apply master volume and speech volume
audio.volume = this.masterVolume * speechVolume * this._ttsVolume;
this.setMediaVolume(audio, this.masterVolume * speechVolume * this.ttsVolume);
// Set up cleanup
audio.onended = () => {
+19 -2
View File
@@ -13,7 +13,7 @@ class ChoiceDisplayModule extends BaseModule {
this.container = null;
this.choices = [];
this.inputMode = 'text';
this.processState = document.documentElement.dataset.processState || 'ready';
this.processState = document.documentElement.dataset.processState || 'loading';
this.template = {
cells: {
default: {
@@ -54,6 +54,14 @@ class ChoiceDisplayModule extends BaseModule {
this.addEventListener(document, 'story:process-state', (event) => {
this.handleProcessState(event.detail?.state || 'ready');
});
this.addEventListener(document, 'story:turn-start', () => {
this.processState = 'waiting-generating';
this.render();
});
this.addEventListener(document, 'story:history-restoring', (event) => {
document.documentElement.dataset.historyRestoring = event.detail?.active ? 'true' : 'false';
this.render();
});
this.addEventListener(document, 'keydown', this.handleKeyDown);
this.reportProgress(100, 'Choice display ready');
@@ -81,6 +89,9 @@ class ChoiceDisplayModule extends BaseModule {
} else if (!commandInput && this.container !== choicesRoot.lastElementChild) {
choicesRoot.appendChild(this.container);
}
this.container.hidden = true;
this.container.dataset.choiceReady = 'false';
}
handleChoices(choices) {
@@ -188,7 +199,13 @@ class ChoiceDisplayModule extends BaseModule {
if (!this.container) return;
this.container.innerHTML = '';
const readyForChoices = this.inputMode === 'choice' && this.choices.length > 0 && this.processState === 'ready';
const restoringHistory = document.documentElement.dataset.historyRestoring === 'true';
const gameRunning = document.body?.dataset?.gameRunning === 'true';
const readyForChoices = gameRunning &&
!restoringHistory &&
this.inputMode === 'choice' &&
this.choices.length > 0 &&
this.processState === 'ready';
this.container.hidden = !readyForChoices;
this.container.dataset.choiceReady = readyForChoices ? 'true' : 'false';
if (this.container.hidden) {
-21
View File
@@ -21,7 +21,6 @@ export class DebugUtilsModule extends BaseModule {
// Make utilities available globally for console access
window.DebugUtils = {
testTextPipeline: this.testTextPipeline.bind(this),
testSocketConnection: this.testSocketConnection.bind(this),
testTTS: this.testTTS.bind(this),
forceReconnect: this.forceReconnect.bind(this)
@@ -32,26 +31,6 @@ export class DebugUtilsModule extends BaseModule {
return true;
}
/**
* Test the text processing pipeline with sample text
* @param {string} text - Test text to process
* @returns {boolean} - Success status
*/
testTextPipeline(text = "This is a test sentence. Let's see if it displays correctly!") {
console.log("Debug: Testing text pipeline with:", text);
// Get the text buffer module properly through dependency system
const textBuffer = this.getModule('text-buffer');
if (!textBuffer) {
console.error("Debug: TextBuffer module not found");
return false;
}
textBuffer.addText(text);
console.log("Debug: Text added to buffer");
return true;
}
/**
* Test the socket connection
* @returns {boolean} - Success status
+55 -2
View File
@@ -31,6 +31,7 @@ class GameLoopModule extends BaseModule {
'updateUIState',
'refreshGameApiState',
'hasSaveGame',
'queueUnrenderedHistoryBlocks',
'requestStartGame',
'requestSaveGame',
'requestLoadGame',
@@ -217,9 +218,13 @@ class GameLoopModule extends BaseModule {
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
inkState: response.savedState || null,
latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0,
renderedLineCount: storyHistory.renderedLineCount || 0,
musicState: audioManager?.getMusicState?.() || null
});
}
this.gameState.canLoad = true;
@@ -246,20 +251,57 @@ class GameLoopModule extends BaseModule {
}
await this.resetClientPlaybackAndDisplay();
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: true, reason: 'load-game' }
}));
if (browserSave?.gameId && storyHistory?.setCurrentGame) {
storyHistory.setCurrentGame(browserSave.gameId, browserSave.latestBlockId || 0);
storyHistory.setCurrentGame(
browserSave.gameId,
browserSave.latestBlockId || 0,
browserSave.latestRenderedBlockId || 0,
browserSave.renderedLineCount || 0
);
}
const uiController = this.getModule('ui-controller');
if (browserSave && uiController?.displayHandler?.restoreFromHistory) {
await uiController.displayHandler.restoreFromHistory(browserSave);
}
const audioManager = this.getModule('audio-manager');
if (browserSave?.musicState && audioManager?.restoreMusicState) {
await audioManager.restoreMusicState(browserSave.musicState);
}
const hasUnrenderedHistory = browserSave &&
Number(browserSave.latestBlockId || 0) > Number(browserSave.latestRenderedBlockId || 0);
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' }
}));
}
const response = await socketClient.loadGame(1, browserSave?.inkState || null);
if (response?.success && hasUnrenderedHistory) {
await this.queueUnrenderedHistoryBlocks(browserSave);
}
if (response?.success) {
this.gameState.started = true;
this.gameState.canSave = true;
this.gameState.canLoad = true;
this.updateUIState();
}
if (!hasUnrenderedHistory) {
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: false, reason: 'load-game-complete' }
}));
} else {
const clearRestoring = () => {
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: false, reason: 'pending-output-drained' }
}));
document.removeEventListener('tts:queue-empty', clearRestoring);
};
document.addEventListener('tts:queue-empty', clearRestoring);
}
}
async hasSaveGame(slot = 1) {
@@ -272,6 +314,17 @@ class GameLoopModule extends BaseModule {
return socketClient?.hasSaveGame ? socketClient.hasSaveGame(slot) : { success: false, result: false };
}
async queueUnrenderedHistoryBlocks(saveRecord = {}) {
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);
textBuffer.addBlocks(blocks);
}
async resetClientPlaybackAndDisplay() {
const playbackCoordinator = this.getModule('playback-coordinator');
if (playbackCoordinator && typeof playbackCoordinator.stop === 'function') {
+34 -11
View File
@@ -23,6 +23,7 @@ class SentenceQueueModule extends BaseModule {
this.autoplay = true;
this.inputMode = 'text';
this.lastContinueAt = 0;
this.pauseBeforeNextReason = null;
// Bind methods
this.bindMethods([
@@ -30,6 +31,7 @@ class SentenceQueueModule extends BaseModule {
'addSentence',
'processNextSentence',
'setOnSentenceReady',
'pauseBeforeNext',
'completeSentence',
'getCacheKey',
'getPreparedSentence',
@@ -105,6 +107,10 @@ class SentenceQueueModule extends BaseModule {
this.onSentenceReadyCallback = callback;
}
}
pauseBeforeNext(reason = 'manual-pause') {
this.pauseBeforeNextReason = reason;
}
/**
* Add a sentence to the queue
@@ -139,6 +145,12 @@ class SentenceQueueModule extends BaseModule {
const item = this.sentenceQueue[0];
try {
if (this.pauseBeforeNextReason) {
const reason = this.pauseBeforeNextReason;
this.pauseBeforeNextReason = null;
await this.waitForManualContinue(reason);
}
const sentence = await this.getPreparedSentence(item);
// Prefetch far enough ahead that media pauses do not block TTS
@@ -294,6 +306,8 @@ class SentenceQueueModule extends BaseModule {
kind: metadata.type,
text: text || '',
turnId: metadata.turnId ?? null,
blockId: metadata.blockId ?? null,
gameId: metadata.gameId ?? null,
status: 'ready',
metadata: imageLayout ? { ...metadata, imageLayout } : metadata,
tts: { duration: 0, provider: null, audioData: null, play: null, stop: null, enabled: false },
@@ -325,6 +339,8 @@ class SentenceQueueModule extends BaseModule {
kind: metadata.type === 'heading' ? 'heading' : 'paragraph',
text,
turnId: metadata.turnId ?? null,
blockId: metadata.blockId ?? null,
gameId: metadata.gameId ?? null,
paragraphIndex: metadata.paragraphIndex ?? null,
isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter),
role: metadata.role || (metadata.type === 'heading' ? 'chapter-heading' : 'body'),
@@ -746,20 +762,27 @@ class SentenceQueueModule extends BaseModule {
probe.remove();
const pageWidth = storyElement.clientWidth;
const size = String(metadata.size || 'landscape').toLowerCase();
const aspect = size === 'portrait' ? (9 / 16) : size === 'square' ? 1 : (16 / 9);
const imageGap = lineHeight * 0.9;
const maxWidth = size === 'portrait' ? pageWidth * 0.5 : pageWidth;
const naturalHeight = maxWidth / aspect;
const requestedSize = String(metadata.size || 'landscape').toLowerCase();
const size = requestedSize === 'widescreen' ? 'landscape' : requestedSize;
const isPortrait = size === 'portrait';
const aspect = isPortrait ? (9 / 16) : size === 'square' ? 1 : (16 / 9);
const imageGap = lineHeight;
const maxOuterWidth = isPortrait ? pageWidth * 0.5 : pageWidth;
const maxImageWidth = isPortrait
? Math.max(lineHeight * 4, maxOuterWidth - imageGap)
: maxOuterWidth;
const naturalHeight = maxImageWidth / aspect;
const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
const height = imageLineCount * lineHeight;
const width = Math.min(maxWidth, height * aspect);
const verticalMargin = lineHeight / 2;
const lineCount = imageLineCount + 1;
const verticalMargin = isPortrait ? lineHeight / 2 : 0;
const lineCount = isPortrait ? imageLineCount + 1 : imageLineCount;
const height = isPortrait
? Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2))
: imageLineCount * lineHeight;
const width = Math.min(maxImageWidth, height * aspect);
if (size === 'portrait') {
if (isPortrait) {
this.activeImageWrap = {
lines: lineCount,
lines: lineCount + 1,
width: width + imageGap,
imageWidth: width,
gap: imageGap,
+84 -14
View File
@@ -9,10 +9,11 @@ class SocketClientModule extends BaseModule {
super('socket-client', 'Socket Client');
// Dependencies
this.dependencies = ['text-buffer', 'markup-parser'];
this.dependencies = ['text-buffer', 'markup-parser', 'story-history'];
this.socket = null;
this.textBuffer = null;
this.storyHistory = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
@@ -20,6 +21,8 @@ class SocketClientModule extends BaseModule {
this.url = null;
this.eventListeners = {};
this.defaultHost = 'localhost:3000';
this.receivedBlockCounter = 0;
this.receivedParagraphCounter = 0;
// Bind methods using parent's bindMethods utility
this.bindMethods([
@@ -44,6 +47,8 @@ class SocketClientModule extends BaseModule {
'setupGameEventHandlers',
'processTurnResult',
'processParagraphResult',
'storeAndQueueBlocks',
'normalizeHistoryBlock',
'dispatchTurnTags',
'isTimedCueTag',
'cueMarkersFromTags',
@@ -93,6 +98,11 @@ class SocketClientModule extends BaseModule {
console.error("Socket Client: Failed to get text-buffer module");
return false;
}
this.storyHistory = this.getModule('story-history');
if (!this.storyHistory) {
console.error("Socket Client: Failed to get story-history module");
return false;
}
this.reportProgress(50, "Setting up connection parameters");
@@ -191,7 +201,7 @@ class SocketClientModule extends BaseModule {
});
}
processTurnResult(data) {
async processTurnResult(data) {
if (!data) return;
const turnId = Number(data.turnId);
@@ -199,6 +209,10 @@ class SocketClientModule extends BaseModule {
console.error('Socket Client: Invalid TurnResult received', data);
return;
}
if (turnId === 1) {
this.receivedBlockCounter = 0;
this.receivedParagraphCounter = 0;
}
if (Array.isArray(data.globalTags) && data.globalTags.length > 0) {
document.dispatchEvent(new CustomEvent('story:global-tags', {
@@ -214,12 +228,24 @@ class SocketClientModule extends BaseModule {
role: null,
cueTags: []
};
const turnBlocks = [];
data.paragraphs.forEach((paragraph) => {
pendingParagraph = this.processParagraphResult(paragraph, turnId, pendingParagraph);
const result = this.processParagraphResult(paragraph, turnId, pendingParagraph);
pendingParagraph = result.pendingParagraph;
turnBlocks.push(...result.blocks);
});
this.dispatchChoices(Array.isArray(data.choices) ? data.choices : []);
this.dispatchInputMode(data.inputMode || (Array.isArray(data.choices) && data.choices.length > 0 ? 'choice' : 'text'));
await this.storeAndQueueBlocks(turnBlocks);
const choices = Array.isArray(data.choices) ? data.choices : [];
const inputMode = data.inputMode || (choices.length > 0 ? 'choice' : 'text');
this.dispatchChoices(choices);
this.dispatchInputMode(inputMode);
if (turnBlocks.length === 0 && choices.length > 0) {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'choice-only-turn', turnId }
}));
}
}
dispatchTurnTags(tags, paragraph = null) {
@@ -259,15 +285,16 @@ class SocketClientModule extends BaseModule {
const immediateTags = tags.filter(tag => !this.isStructuralTag(tag) && !this.isTimedCueTag(tag));
this.dispatchTurnTags(immediateTags, paragraph);
blocks.forEach(block => this.enqueueStructuredBlock(block));
if (!text) {
return {
role: paragraphRole || pending.role || null,
cueTags: [
...(Array.isArray(pending.cueTags) ? pending.cueTags : []),
...cueTags
]
blocks,
pendingParagraph: {
role: paragraphRole || pending.role || null,
cueTags: [
...(Array.isArray(pending.cueTags) ? pending.cueTags : []),
...cueTags
]
}
};
}
@@ -279,7 +306,7 @@ class SocketClientModule extends BaseModule {
...cueTags
])
];
this.enqueueStructuredBlock({
blocks.push({
type: 'paragraph',
text,
layoutText: paragraph.layoutText || text,
@@ -291,7 +318,50 @@ class SocketClientModule extends BaseModule {
turnId
});
return { role: null, cueTags: [] };
return { blocks, pendingParagraph: { role: null, cueTags: [] } };
}
async storeAndQueueBlocks(blocks = []) {
if (!Array.isArray(blocks) || blocks.length === 0) return;
if (!this.storyHistory) {
this.storyHistory = this.getModule('story-history');
}
const normalizedBlocks = blocks.map(block => this.normalizeHistoryBlock(block));
let records = normalizedBlocks;
if (this.storyHistory && typeof this.storyHistory.recordBlocks === 'function') {
records = await this.storyHistory.recordBlocks(normalizedBlocks);
document.dispatchEvent(new CustomEvent('story:history-updated', {
detail: {
gameId: this.storyHistory.currentGameId || null,
latestBlockId: this.storyHistory.nextBlockId - 1
}
}));
} else {
console.warn('Socket Client: Story history unavailable; queueing unstored blocks');
}
records.forEach(block => this.enqueueStructuredBlock(block));
}
normalizeHistoryBlock(block) {
const type = String(block?.type || 'paragraph');
this.receivedBlockCounter += 1;
const normalized = {
...block,
type,
id: block.id || `${type}-${block.turnId || 'turn'}-${this.receivedBlockCounter}`
};
if (type === 'paragraph') {
normalized.paragraphIndex = Number.isInteger(block.paragraphIndex)
? block.paragraphIndex
: this.receivedParagraphCounter;
this.receivedParagraphCounter += 1;
}
return normalized;
}
isStructuralTag(tag) {
+163 -4
View File
@@ -1,7 +1,6 @@
/**
* Story History Module
* Stores rendered story blocks in IndexedDB and keeps only a short live window
* in the page DOM.
* Stores received story output blocks in IndexedDB and tracks the render playhead.
*/
import { BaseModule } from './base-module.js';
@@ -11,12 +10,14 @@ class StoryHistoryModule extends BaseModule {
this.dependencies = ['persistence-manager'];
this.dbName = 'ttsAudioCacheDB';
this.dbVersion = 2;
this.dbVersion = 3;
this.historyStore = 'storyHistoryStore';
this.saveStore = 'storySaveStore';
this.db = null;
this.currentGameId = null;
this.nextBlockId = 1;
this.latestRenderedBlockId = 0;
this.renderedLineCount = 0;
this.visibleLimit = 20;
this.bindMethods([
@@ -25,11 +26,18 @@ class StoryHistoryModule extends BaseModule {
'startNewGame',
'setCurrentGame',
'recordBlock',
'recordBlocks',
'markRendered',
'updateBlockMetrics',
'saveSlot',
'loadSlot',
'hasSaveSlot',
'getSaveSlots',
'getBlocks',
'getBlocksRange',
'getWindowForTurn',
'getRenderedLineCount',
'findBlockForLine',
'clearGame',
'tx'
]);
@@ -52,6 +60,11 @@ class StoryHistoryModule extends BaseModule {
};
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('audioCacheStore')) {
const audioStore = db.createObjectStore('audioCacheStore', { keyPath: 'hash' });
audioStore.createIndex('lastAccessed', 'lastAccessed', { unique: false });
audioStore.createIndex('size', 'size', { unique: false });
}
if (!db.objectStoreNames.contains(this.historyStore)) {
const historyStore = db.createObjectStore(this.historyStore, { keyPath: 'key' });
historyStore.createIndex('gameId', 'gameId', { unique: false });
@@ -72,14 +85,18 @@ class StoryHistoryModule extends BaseModule {
const gameId = `game-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this.currentGameId = gameId;
this.nextBlockId = 1;
this.latestRenderedBlockId = 0;
this.renderedLineCount = 0;
const persistenceManager = this.getModule('persistence-manager');
persistenceManager?.updatePreference?.('app', 'currentGameId', gameId);
return gameId;
}
setCurrentGame(gameId, latestBlockId = 0) {
setCurrentGame(gameId, latestBlockId = 0, latestRenderedBlockId = 0, renderedLineCount = 0) {
this.currentGameId = gameId || this.currentGameId;
this.nextBlockId = Math.max(1, Number(latestBlockId || 0) + 1);
this.latestRenderedBlockId = Math.max(0, Number(latestRenderedBlockId || 0));
this.renderedLineCount = Math.max(0, Number(renderedLineCount || 0));
}
recordBlock(block) {
@@ -99,6 +116,66 @@ class StoryHistoryModule extends BaseModule {
});
}
async recordBlocks(blocks = []) {
if (!Array.isArray(blocks) || blocks.length === 0) return [];
const records = [];
for (const block of blocks) {
const record = await this.recordBlock(block);
if (record) records.push(record);
}
return records;
}
markRendered(blockId) {
const rendered = Math.max(0, Number(blockId || 0));
if (rendered > this.latestRenderedBlockId) {
this.latestRenderedBlockId = rendered;
}
return this.latestRenderedBlockId;
}
async updateBlockMetrics(blockId, metrics = {}) {
if (!this.db || !this.currentGameId || blockId == null) return null;
const id = Math.max(1, Number(blockId || 1));
const key = `${this.currentGameId}:${id}`;
const record = await new Promise((resolve, reject) => {
const request = this.tx(this.historyStore).get(key);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
if (!record) return null;
const lineCount = Math.max(1, Number(metrics.lineCount || record.lineCount || 1));
const previousLineCount = Number(record.lineCount || 0);
const hadLineStart = Number.isFinite(Number(record.lineStart));
const lineStart = hadLineStart
? Math.max(0, Number(record.lineStart))
: this.renderedLineCount;
const updated = {
...record,
lineStart,
lineCount,
heightPx: Math.max(0, Number(metrics.heightPx || record.heightPx || 0)),
measuredLineHeightPx: Math.max(0, Number(metrics.lineHeightPx || record.measuredLineHeightPx || 0)),
metricsUpdatedAt: Date.now()
};
if (!hadLineStart) {
this.renderedLineCount = Math.max(this.renderedLineCount, lineStart + lineCount);
} else if (lineStart + previousLineCount >= this.renderedLineCount) {
this.renderedLineCount = Math.max(lineStart + lineCount, this.renderedLineCount + (lineCount - previousLineCount));
}
await new Promise((resolve, reject) => {
const request = this.tx(this.historyStore, 'readwrite').put(updated);
request.onsuccess = () => resolve(true);
request.onerror = () => reject(request.error);
});
return updated;
}
saveSlot(slot = 1, saveData = {}) {
if (!this.db) return Promise.resolve(false);
const record = {
@@ -106,6 +183,8 @@ class StoryHistoryModule extends BaseModule {
...saveData,
gameId: saveData.gameId || this.currentGameId,
latestBlockId: Math.max(0, this.nextBlockId - 1),
latestRenderedBlockId: Math.max(0, Number(saveData.latestRenderedBlockId ?? this.latestRenderedBlockId ?? 0)),
renderedLineCount: Math.max(0, Number(saveData.renderedLineCount ?? this.renderedLineCount ?? 0)),
savedAt: Date.now()
};
return new Promise((resolve, reject) => {
@@ -158,6 +237,86 @@ class StoryHistoryModule extends BaseModule {
});
}
getBlocksRange(gameId = this.currentGameId, startBlockId = 1, endBlockId = Infinity) {
if (!this.db || !gameId) return Promise.resolve([]);
const start = Math.max(1, Number(startBlockId || 1));
const end = Number.isFinite(endBlockId) ? Number(endBlockId) : Number.MAX_SAFE_INTEGER;
if (end < start) return Promise.resolve([]);
return new Promise((resolve, reject) => {
const blocks = [];
const index = this.tx(this.historyStore).index('gameOrder');
const range = IDBKeyRange.bound([gameId, start], [gameId, end]);
const request = index.openCursor(range, 'next');
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve(blocks);
return;
}
blocks.push(cursor.value);
cursor.continue();
};
request.onerror = () => reject(request.error);
});
}
async getWindowForTurn(gameId = this.currentGameId, turnId, visibleLimit = this.visibleLimit) {
if (!this.db || !gameId || turnId == null) return { blocks: [], targetBlockId: null };
const target = await new Promise((resolve, reject) => {
const index = this.tx(this.historyStore).index('gameId');
const request = index.openCursor(IDBKeyRange.only(gameId), 'next');
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve(null);
return;
}
if (String(cursor.value?.turnId) === String(turnId)) {
resolve(cursor.value);
return;
}
cursor.continue();
};
request.onerror = () => reject(request.error);
});
if (!target?.blockId) return { blocks: [], targetBlockId: null };
const latest = Math.max(0, this.nextBlockId - 1);
const limit = Math.max(1, Number(visibleLimit || this.visibleLimit));
const halfBefore = Math.floor(limit / 2);
const maxStart = Math.max(1, latest - limit + 1);
const start = Math.max(1, Math.min(maxStart, target.blockId - halfBefore));
const blocks = await this.getBlocksRange(gameId, start, Math.min(latest, start + limit - 1));
return { blocks, targetBlockId: target.blockId };
}
async getRenderedLineCount(gameId = this.currentGameId, latestRenderedBlockId = this.latestRenderedBlockId) {
const latest = Math.max(0, Number(latestRenderedBlockId || 0));
if (!this.db || !gameId || latest <= 0) return 0;
const blocks = await this.getBlocksRange(gameId, 1, latest);
const measured = blocks.reduce((max, block) => {
const start = Number(block.lineStart);
const count = Number(block.lineCount);
if (!Number.isFinite(start) || !Number.isFinite(count)) return max;
return Math.max(max, start + Math.max(1, count));
}, 0);
this.renderedLineCount = Math.max(this.renderedLineCount, measured);
return this.renderedLineCount;
}
async findBlockForLine(gameId = this.currentGameId, line = 0, latestRenderedBlockId = this.latestRenderedBlockId) {
const latest = Math.max(0, Number(latestRenderedBlockId || 0));
if (!this.db || !gameId || latest <= 0) return null;
const targetLine = Math.max(0, Number(line || 0));
const blocks = await this.getBlocksRange(gameId, 1, latest);
return blocks.find((block) => {
const start = Number(block.lineStart);
const count = Math.max(1, Number(block.lineCount || 1));
return Number.isFinite(start) && targetLine >= start && targetLine < start + count;
}) || blocks.at(-1) || null;
}
clearGame(gameId = this.currentGameId) {
if (!this.db || !gameId) return Promise.resolve();
return new Promise((resolve, reject) => {
+6 -66
View File
@@ -20,9 +20,8 @@ class TextBufferModule extends BaseModule {
// Bind methods using parent's bindMethods utility
this.bindMethods([
'addText',
'addBlock',
'splitIntoParagraphs',
'addBlocks',
'processNextFromQueue',
'processSentences',
'processNextSentence',
@@ -86,56 +85,6 @@ class TextBufferModule extends BaseModule {
}
}
/**
* Add text to the buffer and process sentences
* @param {string} text - Text to add to the buffer
*/
addText(text) {
if (!text) return;
console.log(`TextBuffer: Adding text: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
const blocks = this.markupParser && typeof this.markupParser.parse === 'function'
? this.markupParser.parse(text)
: this.splitIntoParagraphs(text).map(paragraphText => ({
type: 'paragraph',
text: paragraphText,
layoutText: paragraphText,
cueMarkers: [],
role: 'body',
isFirstParagraphInChapter: false
}));
blocks.forEach(block => {
if (block.type === 'paragraph') {
const paragraphId = `paragraph-${this.paragraphCounter + 1}`;
this.processingQueue.push({
...block,
id: paragraphId,
paragraphIndex: this.paragraphCounter,
textBlockId: this.currentTextBlockId
});
this.paragraphCounter += 1;
} else {
this.processingQueue.push({
...block,
id: `${block.type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
});
if (block.type === 'image') {
this.currentTextBlockId += 1;
}
}
});
// Process the queue if not already processing
if (!this.isProcessingActive && this.onSentenceReadyCallback) {
this.processNextFromQueue();
} else {
console.log('TextBuffer: Text queued for processing');
}
}
/**
* Add an already parsed render block to the processing queue.
* Engine protocols should prefer this over re-serializing tags into text markup.
@@ -173,20 +122,11 @@ class TextBufferModule extends BaseModule {
}
}
/**
* Split an incoming narrative fragment into book paragraphs.
* Single newlines inside a paragraph are normalized to spaces; blank lines
* mark distinct paragraphs.
* @param {string} text - Raw text fragment
* @returns {Array<string>} - Normalized paragraph strings
*/
splitIntoParagraphs(text) {
return String(text)
.split(/\n\s*\n/g)
.map(paragraph => paragraph.replace(/\s*\n\s*/g, ' ').trim())
.filter(Boolean);
addBlocks(blocks = []) {
if (!Array.isArray(blocks)) return;
blocks.forEach(block => this.addBlock(block));
}
/**
* Process the next text fragment from the queue
*/
@@ -223,7 +163,7 @@ class TextBufferModule extends BaseModule {
this.isProcessingActive = false;
}
}
/**
* Process complete sentences in the buffer
*/
+1 -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 = 2;
this.dbVersion = 3;
this.currentCacheSize = 0; // Track current size in bytes
this.maxCacheSizeBytes = 100 * 1024 * 1024; // 100 MB by default
this.cacheInitialized = false;
+5 -10
View File
@@ -38,7 +38,6 @@ class UIControllerModule extends BaseModule {
this.bindMethods([
'initialize',
'handleCommand',
'displayText',
'setupBookInterface',
'applyBookSizing',
'setupEventListeners',
@@ -264,7 +263,7 @@ class UIControllerModule extends BaseModule {
});
this.addEventListener(document, 'click', (event) => {
if (event.target && event.target.closest && event.target.closest('#options-modal, #controls, #player_input, #command_input')) {
if (event.target && event.target.closest && event.target.closest('#options-modal, #controls, #player_input, #command_input, #story_scrollbar')) {
return;
}
@@ -668,10 +667,9 @@ class UIControllerModule extends BaseModule {
console.log('UI Controller: Command sent successfully');
} else {
console.error('UI Controller: Failed to send command to socket');
// Display an error message to the user
this.displayHandler.displayText('⚠️ Unable to send command. Server connection might be lost.', {
style: { color: '#990000' }
});
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'command-send-failed' }
}));
}
} else {
console.error('UI Controller: Socket client not available for sending commands');
@@ -775,10 +773,6 @@ class UIControllerModule extends BaseModule {
}
}
displayText(text, options = {}) {
return this.displayHandler.displayText(text, options);
}
clearDisplay() {
this.displayHandler.clear();
}
@@ -796,3 +790,4 @@ const uiController = new UIControllerModule();
// Export the module
export { uiController as UIController };
File diff suppressed because it is too large Load Diff
-17
View File
@@ -40,7 +40,6 @@ class UIEffectsModule extends BaseModule {
'applyEffect',
'applyShakeEffect',
'applyFlashEffect',
'applyTextEmphasis',
'processCommand',
'handleLightingAnimationEnd'
]);
@@ -209,8 +208,6 @@ class UIEffectsModule extends BaseModule {
return this.applyShakeEffect(options);
case 'flash':
return this.applyFlashEffect(options);
case 'emphasis':
return this.applyTextEmphasis(options.text, options);
default:
console.warn(`Unknown effect: ${effectName}`);
return null;
@@ -288,20 +285,6 @@ class UIEffectsModule extends BaseModule {
return effectId;
}
applyTextEmphasis(text, options = {}) {
// Use existing display handler to show emphasized text
const displayHandler = this.getModule('ui-display-handler');
if (!displayHandler) return null;
const style = {
fontWeight: 'bold',
color: options.color || '#990000',
fontSize: options.size || '1.2em'
};
return displayHandler.displayText(text, { style, speak: true });
}
processCommand(command) {
switch (command.action) {
case 'apply':