Checkpoint before line-grid scrolling refactor
This commit is contained in:
+35
-10
@@ -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 |
@@ -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 = () => {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user