Checkpoint before line-grid scrolling refactor
This commit is contained in:
@@ -40,6 +40,7 @@ Required module rules:
|
||||
- Every app module declares all required dependencies in its `dependencies` list.
|
||||
- The loader loads module scripts, resolves the dependency graph, initializes modules in dependency order, awaits async initialization, and only then hides the loading overlay.
|
||||
- Modules must rely on the loader for dependency readiness. Do not add fallback paths for missing dependencies inside modules.
|
||||
- Do not add fallback code that bypasses an authoritative module, service, parser, state store, or API to hide an architectural problem. If such a fallback already exists or seems tempting, stop and report the architectural mismatch before changing code.
|
||||
- Module states are `PENDING`, `LOADING`, `WAITING`, `INITIALIZING`, `FINISHED`, and `ERROR`.
|
||||
- Modules must report real state transitions. A module must not report `FINISHED` until its critical initialization is actually ready.
|
||||
- `setTimeout` must not be used to paper over dependency or async ordering bugs. It is acceptable inside isolated scheduling systems such as animation timing, debounce, throttle, or browser rendering workarounds when documented by context.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
Now let's fix the history scrolling:
|
||||
1.) Make sure <div id="page_right"> does not scroll! This should have no scrollbar or scrolling css whatsoever. It's fixed size. The #story_scrollbar should have a fixed height of 100% of #page_right and be placed absolutely over the parent, so it cannot be influenced by anything next or below it and it does not let clicks bubble through it to anything else below (no unpausing story on scrollbar usage). Neither #story or anything inside it should have a scrollbar!
|
||||
2.) Every text or image block that is inserted into the dom should update it's entry in the historyDB to include it's height in lines. Every time this happens the height is added to a game engine value that stores the length of the game history in lines. The #page_rigiht is considered to have a fixed height in lines (calculate id). The #story_scrollbar is calculated using the position and size in lines of the current page view to the game history. For this calculation the history ends at the latestRenderedBlockId, not the latestBlockId.
|
||||
3.) When a new block is put into the dom, the #story div is moved up (dynamically with an ease in/ease out animation) within the #page_rigt div until its bottom edge aligns with the bottom edge of it's parent (that's scrolling to the bottom). Any time this position changes the scrollbar is updated accordingly.
|
||||
4.) Scrolling using the arrow up or arrow down keys or the mousewheel moves the pane smoothly up and down until it's end. There should be a value storing the block currently shown in the center of the page. The system dynamically adds and removes blocks to the top and bottom of the #paragraphs list so that there are 20 blocks above and 20 blocks below the central block (if available) at all times.
|
||||
5.) Any kind of manual scrolling, be it via mousewheel, arrow keys up/down, or using the scrollbar immediately disables autoplay.
|
||||
6.) Inserting images should first insert the image covered, then scroll down, and only once the scrolling animation finished start the revealing fade in of the image which should take 2 seconds.
|
||||
|
||||
|
||||
Scrolling using the arrow keys or mousewheel works fine now, But I found the following bugs already:
|
||||
1.) Autoplay does not disable on scrolling.
|
||||
2.) Restoring the chapter beginning shows the chapter heading as a normal line of text and no longer produces the drop-cap.
|
||||
3.) Loading a game that was saved while choices were present does not restore the choice dialog.
|
||||
4.) The game now seems to always scroll to the center of the page to add/animate in new content. Please keep this position at the page bottom as it was, only adding as much space as needed for the new content (like before).
|
||||
5.) Make sure autoscrolling or manual scrolling always stops at the nearest line boundary, so no cut of lines can be visible.
|
||||
6.) Suggest a plan how to fix the scrollbar controls. They look correct now. But moving them with the mouse feels clunky and somethimes it triggers strange behavior. We need a solution where the bar can be moved freele up and down without doing anything yet. Once released the content between the current position and the target position including the necessary margins are loaded before the scrolling is animated and then the now unneccessary blocks unloaded.
|
||||
|
||||
|
||||
That sounds still too complicated! Why invalidate the layout index?
|
||||
Assume the following:
|
||||
1.) The #right_page div has a size relative to the window. There is ONE line height value, which is a divisor or the page height/the page height has a fixed number of lines: Line height = Page height/fixed number of lines.
|
||||
2.) All content has an exact multiple of line height as height all margins and paddings included.
|
||||
3.) Therefore any coordinates or pixel sizes of the virtual content pane can be derived mathematically from line coordinates.
|
||||
4.) Scrolling means translating the content vertically (with ease in/eas out animation) to the closest position where the page edges aligns with line edges.
|
||||
+716
-331
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Vendored
-46
@@ -188,16 +188,6 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
// Start a new game
|
||||
socket.on('startGame', async () => {
|
||||
try {
|
||||
await handleGameApi(socket, 'newGame', []);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error starting game:', error);
|
||||
socket.emit('error', { message: 'Failed to start game. Please try again.' });
|
||||
}
|
||||
});
|
||||
// Process player command
|
||||
socket.on('playerCommand', async (data) => {
|
||||
try {
|
||||
@@ -218,42 +208,6 @@ io.on('connection', (socket) => {
|
||||
socket.emit('error', { message: 'Failed to process command. Please try again.' });
|
||||
}
|
||||
});
|
||||
// Save game state
|
||||
socket.on('saveGame', () => {
|
||||
try {
|
||||
const gameRunner = gameSessions.get(socket.id);
|
||||
if (!gameRunner) {
|
||||
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
|
||||
return;
|
||||
}
|
||||
socket.data.saveGames.set(1, gameRunner.getGameState());
|
||||
socket.emit('gameSaved');
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error saving game:', error);
|
||||
socket.emit('error', { message: 'Failed to save game. Please try again.' });
|
||||
}
|
||||
});
|
||||
// Load game state
|
||||
socket.on('loadGame', async () => {
|
||||
try {
|
||||
const gameRunner = gameSessions.get(socket.id);
|
||||
if (!gameRunner) {
|
||||
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
|
||||
return;
|
||||
}
|
||||
// Check if there's a saved game
|
||||
if (!socket.data.saveGames?.has(1)) {
|
||||
socket.emit('error', { message: 'No saved game found.' });
|
||||
return;
|
||||
}
|
||||
await handleGameApi(socket, 'loadGame', [1]);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error loading game:', error);
|
||||
socket.emit('error', { message: 'Failed to load game. Please try again.' });
|
||||
}
|
||||
});
|
||||
// Handle disconnection
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Client disconnected: ${socket.id}`);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
-11
@@ -167,17 +167,6 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
// Start a new game
|
||||
socket.on('startGame', async () => {
|
||||
try {
|
||||
console.log('Starting test game session');
|
||||
startDemoGame();
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error starting game:', error);
|
||||
socket.emit('error', { message: 'Failed to start game. Please try again.' });
|
||||
}
|
||||
});
|
||||
// Process player command
|
||||
socket.on('playerCommand', async (data) => {
|
||||
try {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+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',
|
||||
@@ -106,6 +108,10 @@ class SentenceQueueModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
pauseBeforeNext(reason = 'manual-pause') {
|
||||
this.pauseBeforeNextReason = reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a sentence to the queue
|
||||
* @param {string} sentence - Sentence to add
|
||||
@@ -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,18 +122,9 @@ 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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -186,17 +186,6 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Start a new game
|
||||
socket.on('startGame', async () => {
|
||||
try {
|
||||
await handleGameApi(socket, 'newGame', []);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting game:', error);
|
||||
socket.emit('error', { message: 'Failed to start game. Please try again.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Process player command
|
||||
socket.on('playerCommand', async (data) => {
|
||||
try {
|
||||
@@ -221,50 +210,6 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Save game state
|
||||
socket.on('saveGame', () => {
|
||||
try {
|
||||
const gameRunner = gameSessions.get(socket.id);
|
||||
|
||||
if (!gameRunner) {
|
||||
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
|
||||
return;
|
||||
}
|
||||
|
||||
socket.data.saveGames.set(1, gameRunner.getGameState());
|
||||
|
||||
socket.emit('gameSaved');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving game:', error);
|
||||
socket.emit('error', { message: 'Failed to save game. Please try again.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Load game state
|
||||
socket.on('loadGame', async () => {
|
||||
try {
|
||||
const gameRunner = gameSessions.get(socket.id);
|
||||
|
||||
if (!gameRunner) {
|
||||
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there's a saved game
|
||||
if (!socket.data.saveGames?.has(1)) {
|
||||
socket.emit('error', { message: 'No saved game found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
await handleGameApi(socket, 'loadGame', [1]);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading game:', error);
|
||||
socket.emit('error', { message: 'Failed to load game. Please try again.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle disconnection
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Client disconnected: ${socket.id}`);
|
||||
|
||||
@@ -136,18 +136,6 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Start a new game
|
||||
socket.on('startGame', async () => {
|
||||
try {
|
||||
console.log('Starting test game session');
|
||||
startDemoGame();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting game:', error);
|
||||
socket.emit('error', { message: 'Failed to start game. Please try again.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Process player command
|
||||
socket.on('playerCommand', async (data) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user