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.
|
- 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.
|
- 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.
|
- 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`.
|
- 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.
|
- 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.
|
- `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
|
// Process player command
|
||||||
socket.on('playerCommand', async (data) => {
|
socket.on('playerCommand', async (data) => {
|
||||||
try {
|
try {
|
||||||
@@ -218,42 +208,6 @@ io.on('connection', (socket) => {
|
|||||||
socket.emit('error', { message: 'Failed to process command. Please try again.' });
|
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
|
// Handle disconnection
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
console.log(`Client disconnected: ${socket.id}`);
|
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
|
// Process player command
|
||||||
socket.on('playerCommand', async (data) => {
|
socket.on('playerCommand', async (data) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+35
-10
@@ -102,6 +102,7 @@ html, body {
|
|||||||
body {
|
body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-image: url('../images/brown-wooden-flooring.jpg');
|
background-image: url('../images/brown-wooden-flooring.jpg');
|
||||||
|
/* background-image: url('../images/table.png'); */
|
||||||
background-position: center center;
|
background-position: center center;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
@@ -383,6 +384,7 @@ ol.choice {
|
|||||||
width: var(--book-width);
|
width: var(--book-width);
|
||||||
height: var(--book-height);
|
height: var(--book-height);
|
||||||
background-image: url('../images/book-3057904.png');
|
background-image: url('../images/book-3057904.png');
|
||||||
|
/* background-image: url('../images/book.png'); */
|
||||||
background-size: contain; /* Changed from cover to contain */
|
background-size: contain; /* Changed from cover to contain */
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-repeat: no-repeat; /* Prevents repeating the image when aspect ratio doesn't match */
|
background-repeat: no-repeat; /* Prevents repeating the image when aspect ratio doesn't match */
|
||||||
@@ -407,17 +409,20 @@ ol.choice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#story {
|
#story {
|
||||||
overflow-x: visible;
|
overflow: visible !important;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow-anchor: none;
|
overflow-anchor: none;
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
text-justify: inter-word;
|
text-justify: inter-word;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
will-change: transform;
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#paragraphs {
|
#paragraphs {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
overflow: visible !important;
|
||||||
overflow-anchor: none;
|
overflow-anchor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,7 +476,7 @@ ol.choice {
|
|||||||
.story-image-visible img {
|
.story-image-visible img {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
clip-path: polygon(0 0, 220% 0, 0 220%);
|
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 {
|
/* #story p span {
|
||||||
@@ -486,8 +491,11 @@ ol.choice {
|
|||||||
/* background-color: rgba(200,200,200,0.5); */
|
/* background-color: rgba(200,200,200,0.5); */
|
||||||
right: 7%;
|
right: 7%;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
scroll-behavior: smooth;
|
overflow: hidden !important;
|
||||||
overscroll-behavior: contain;
|
overflow-x: hidden !important;
|
||||||
|
overflow-y: hidden !important;
|
||||||
|
scroll-behavior: auto;
|
||||||
|
overscroll-behavior: none;
|
||||||
overflow-anchor: none;
|
overflow-anchor: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
/* transform: translateX(-1%) translateY(2%) rotateX(0deg) rotateY(-1deg) rotateZ(0deg); */
|
/* transform: translateX(-1%) translateY(2%) rotateX(0deg) rotateY(-1deg) rotateZ(0deg); */
|
||||||
@@ -497,17 +505,29 @@ ol.choice {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#page_right *,
|
||||||
|
#page_right *::-webkit-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page_right *::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
#story_scrollbar {
|
#story_scrollbar {
|
||||||
position: sticky;
|
position: absolute;
|
||||||
float: right;
|
top: 0;
|
||||||
top: 0.4rem;
|
right: 0.35rem;
|
||||||
|
bottom: 0;
|
||||||
width: 0.22rem;
|
width: 0.22rem;
|
||||||
height: calc(100% - 0.8rem);
|
height: 100%;
|
||||||
margin-right: -1.5rem;
|
margin: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(0, 0, 0, 0.12);
|
background: rgba(0, 0, 0, 0.12);
|
||||||
z-index: 12;
|
z-index: 12;
|
||||||
pointer-events: none;
|
pointer-events: auto;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#story_scrollbar_thumb {
|
#story_scrollbar_thumb {
|
||||||
@@ -519,6 +539,7 @@ ol.choice {
|
|||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
background: rgba(0, 0, 0, 0.72);
|
background: rgba(0, 0, 0, 0.72);
|
||||||
transition: top 260ms ease, height 260ms ease;
|
transition: top 260ms ease, height 260ms ease;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -742,6 +763,7 @@ body:not([data-game-running="true"]) #command_history {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
transition: opacity 0.45s ease;
|
transition: opacity 0.45s ease;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -750,6 +772,8 @@ body:not([data-game-running="true"]) #command_history {
|
|||||||
display: none;
|
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="waiting-generating"] .story-choices,
|
||||||
html[data-process-state="playing-generating"] .story-choices,
|
html[data-process-state="playing-generating"] .story-choices,
|
||||||
html[data-process-state="playing-ready"] .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"] {
|
html[data-process-state="ready"] .story-choices[data-choice-ready="true"] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
pointer-events: auto;
|
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.activeTtsPlaybackCount = 0;
|
||||||
this.ttsQueueEmpty = true;
|
this.ttsQueueEmpty = true;
|
||||||
this.pendingMusicPlayback = null;
|
this.pendingMusicPlayback = null;
|
||||||
|
this.currentMusicState = null;
|
||||||
this.assetRoots = {
|
this.assetRoots = {
|
||||||
images: '/images/',
|
images: '/images/',
|
||||||
music: '/music/',
|
music: '/music/',
|
||||||
@@ -203,14 +204,14 @@ class AudioManagerModule extends BaseModule {
|
|||||||
this.currentLoop = audio;
|
this.currentLoop = audio;
|
||||||
} else {
|
} else {
|
||||||
this.currentAudio = audio.cloneNode(true);
|
this.currentAudio = audio.cloneNode(true);
|
||||||
this.currentAudio.volume = this.getSfxVolume();
|
this.setMediaVolume(this.currentAudio, this.getSfxVolume());
|
||||||
this.currentAudio.play().catch(error => {
|
this.currentAudio.play().catch(error => {
|
||||||
console.error('Error playing audio:', error);
|
console.error('Error playing audio:', error);
|
||||||
});
|
});
|
||||||
return this.currentAudio;
|
return this.currentAudio;
|
||||||
}
|
}
|
||||||
|
|
||||||
audio.volume = this.getMusicVolume();
|
this.setMediaVolume(audio, this.getMusicVolume());
|
||||||
audio.play().catch(error => {
|
audio.play().catch(error => {
|
||||||
console.error('Error playing audio:', error);
|
console.error('Error playing audio:', error);
|
||||||
});
|
});
|
||||||
@@ -233,14 +234,14 @@ class AudioManagerModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
this.currentLoop = new Audio(url);
|
this.currentLoop = new Audio(url);
|
||||||
this.currentLoop.loop = true;
|
this.currentLoop.loop = true;
|
||||||
this.currentLoop.volume = this.getMusicVolume();
|
this.setMediaVolume(this.currentLoop, this.getMusicVolume());
|
||||||
this.currentLoop.play().catch(error => {
|
this.currentLoop.play().catch(error => {
|
||||||
console.error('Error playing audio loop:', error);
|
console.error('Error playing audio loop:', error);
|
||||||
});
|
});
|
||||||
return this.currentLoop;
|
return this.currentLoop;
|
||||||
} else {
|
} else {
|
||||||
this.currentAudio = new Audio(url);
|
this.currentAudio = new Audio(url);
|
||||||
this.currentAudio.volume = this.getSfxVolume();
|
this.setMediaVolume(this.currentAudio, this.getSfxVolume());
|
||||||
this.currentAudio.play().catch(error => {
|
this.currentAudio.play().catch(error => {
|
||||||
console.error('Error playing audio:', error);
|
console.error('Error playing audio:', error);
|
||||||
});
|
});
|
||||||
@@ -331,19 +332,19 @@ class AudioManagerModule extends BaseModule {
|
|||||||
updateVolumes() {
|
updateVolumes() {
|
||||||
this.sounds.forEach(audio => {
|
this.sounds.forEach(audio => {
|
||||||
const isMusic = audio.loop;
|
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) {
|
if (this.currentAudio) {
|
||||||
this.currentAudio.volume = this.masterVolume * this.sfxVolume;
|
this.setMediaVolume(this.currentAudio, this.masterVolume * this.sfxVolume);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.currentLoop) {
|
if (this.currentLoop) {
|
||||||
this.currentLoop.volume = this.getMusicVolume();
|
this.setMediaVolume(this.currentLoop, this.getMusicVolume());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.currentMusic) {
|
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));
|
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() {
|
getSfxVolume() {
|
||||||
return this.masterVolume * this.sfxVolume;
|
return this.masterVolume * this.sfxVolume;
|
||||||
}
|
}
|
||||||
@@ -387,8 +393,8 @@ class AudioManagerModule extends BaseModule {
|
|||||||
|
|
||||||
const audio = this.currentMusic;
|
const audio = this.currentMusic;
|
||||||
const token = ++this.musicFadeToken;
|
const token = ++this.musicFadeToken;
|
||||||
const startVolume = audio.volume;
|
const startVolume = this.clampVolume(audio.volume);
|
||||||
const targetVolume = this.getUnduckedMusicVolume() * this.musicDuckingFactor;
|
const targetVolume = this.clampVolume(this.getUnduckedMusicVolume() * this.musicDuckingFactor);
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
@@ -396,7 +402,7 @@ class AudioManagerModule extends BaseModule {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const progress = Math.min(1, (performance.now() - start) / duration);
|
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) {
|
if (progress < 1) {
|
||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
@@ -428,7 +434,7 @@ class AudioManagerModule extends BaseModule {
|
|||||||
const promise = new Promise((resolve, reject) => {
|
const promise = new Promise((resolve, reject) => {
|
||||||
const audio = new Audio(url);
|
const audio = new Audio(url);
|
||||||
audio.preload = 'auto';
|
audio.preload = 'auto';
|
||||||
audio.volume = this.getSfxVolume();
|
this.setMediaVolume(audio, this.getSfxVolume());
|
||||||
audio.addEventListener('canplaythrough', () => resolve(audio), { once: true });
|
audio.addEventListener('canplaythrough', () => resolve(audio), { once: true });
|
||||||
audio.addEventListener('error', () => reject(new Error(`Failed to preload sound effect: ${url}`)), { once: true });
|
audio.addEventListener('error', () => reject(new Error(`Failed to preload sound effect: ${url}`)), { once: true });
|
||||||
audio.load();
|
audio.load();
|
||||||
@@ -556,7 +562,7 @@ class AudioManagerModule extends BaseModule {
|
|||||||
try {
|
try {
|
||||||
const template = await this.preloadSfx(filename);
|
const template = await this.preloadSfx(filename);
|
||||||
const audio = template.cloneNode(true);
|
const audio = template.cloneNode(true);
|
||||||
audio.volume = this.getSfxVolume();
|
this.setMediaVolume(audio, this.getSfxVolume());
|
||||||
this.currentAudio = audio;
|
this.currentAudio = audio;
|
||||||
const maxDuration = Math.max(0, Number(options.maxDurationSeconds || options.maxDuration || 0)) * 1000;
|
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';
|
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) {
|
fadeOutAudio(audio, duration = 1000) {
|
||||||
if (!audio) return Promise.resolve(false);
|
if (!audio) return Promise.resolve(false);
|
||||||
const startVolume = audio.volume;
|
const startVolume = this.clampVolume(audio.volume);
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const step = () => {
|
const step = () => {
|
||||||
const progress = Math.min(1, (performance.now() - startedAt) / duration);
|
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) {
|
if (progress < 1 && !audio.paused && !audio.ended) {
|
||||||
requestAnimationFrame(step);
|
requestAnimationFrame(step);
|
||||||
return;
|
return;
|
||||||
@@ -617,6 +623,8 @@ class AudioManagerModule extends BaseModule {
|
|||||||
async playMusic(filename, mode = 'crossfade', options = {}) {
|
async playMusic(filename, mode = 'crossfade', options = {}) {
|
||||||
const url = this.getAssetUrl('music', filename);
|
const url = this.getAssetUrl('music', filename);
|
||||||
const shouldLoop = options.loop !== false;
|
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) {
|
if (mode === 'queue' && this.currentMusic && !this.currentMusic.paused) {
|
||||||
this.queuedMusic = { filename, mode: 'cut', options: { loop: shouldLoop } };
|
this.queuedMusic = { filename, mode: 'cut', options: { loop: shouldLoop } };
|
||||||
@@ -631,22 +639,44 @@ class AudioManagerModule extends BaseModule {
|
|||||||
|
|
||||||
const next = new Audio(url);
|
const next = new Audio(url);
|
||||||
next.loop = shouldLoop;
|
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', () => {
|
next.addEventListener('ended', () => {
|
||||||
if (this.currentMusic === next) {
|
if (this.currentMusic === next) {
|
||||||
this.currentMusic = null;
|
this.currentMusic = null;
|
||||||
|
this.currentMusicState = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const nextState = {
|
||||||
|
filename,
|
||||||
|
url,
|
||||||
|
loop: shouldLoop,
|
||||||
|
mode,
|
||||||
|
startedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
if (mode === 'cut' || !this.currentMusic) {
|
if (mode === 'cut' || !this.currentMusic) {
|
||||||
this.stopCurrentMusic();
|
this.stopCurrentMusic();
|
||||||
this.currentMusic = next;
|
this.currentMusic = next;
|
||||||
|
this.currentMusicState = nextState;
|
||||||
await this.startMusicAudio(next, filename);
|
await this.startMusicAudio(next, filename);
|
||||||
|
if (fadeInSeconds > 0) {
|
||||||
|
this.fadeAudioTo(next, this.getMusicVolume(), fadeInSeconds * 1000);
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previous = this.currentMusic;
|
const previous = this.currentMusic;
|
||||||
this.currentMusic = next;
|
this.currentMusic = next;
|
||||||
|
this.currentMusicState = nextState;
|
||||||
await this.startMusicAudio(next, filename);
|
await this.startMusicAudio(next, filename);
|
||||||
this.crossfade(previous, next, 1500);
|
this.crossfade(previous, next, 1500);
|
||||||
console.log(`AudioManager: Crossfading music to ${filename}`);
|
console.log(`AudioManager: Crossfading music to ${filename}`);
|
||||||
@@ -678,7 +708,7 @@ class AudioManagerModule extends BaseModule {
|
|||||||
|
|
||||||
const pending = this.pendingMusicPlayback;
|
const pending = this.pendingMusicPlayback;
|
||||||
this.pendingMusicPlayback = null;
|
this.pendingMusicPlayback = null;
|
||||||
pending.audio.volume = this.getMusicVolume();
|
this.setMediaVolume(pending.audio, this.getMusicVolume());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pending.audio.play();
|
await pending.audio.play();
|
||||||
@@ -697,17 +727,61 @@ class AudioManagerModule extends BaseModule {
|
|||||||
this.currentMusic.pause();
|
this.currentMusic.pause();
|
||||||
this.currentMusic.currentTime = 0;
|
this.currentMusic.currentTime = 0;
|
||||||
this.currentMusic = null;
|
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) {
|
crossfade(previous, next, duration = 1500) {
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const previousStart = previous ? previous.volume : 0;
|
const previousStart = previous ? this.clampVolume(previous.volume) : 0;
|
||||||
const target = this.getMusicVolume();
|
const target = this.clampVolume(this.getMusicVolume());
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const progress = Math.min(1, (performance.now() - start) / duration);
|
const progress = Math.min(1, (performance.now() - start) / duration);
|
||||||
if (previous) previous.volume = previousStart * (1 - progress);
|
if (previous) this.setMediaVolume(previous, previousStart * (1 - progress));
|
||||||
next.volume = target * progress;
|
this.setMediaVolume(next, target * progress);
|
||||||
|
|
||||||
if (progress < 1) {
|
if (progress < 1) {
|
||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
@@ -718,7 +792,7 @@ class AudioManagerModule extends BaseModule {
|
|||||||
previous.pause();
|
previous.pause();
|
||||||
previous.currentTime = 0;
|
previous.currentTime = 0;
|
||||||
}
|
}
|
||||||
next.volume = this.getMusicVolume();
|
this.setMediaVolume(next, this.getMusicVolume());
|
||||||
};
|
};
|
||||||
|
|
||||||
tick();
|
tick();
|
||||||
@@ -747,7 +821,7 @@ class AudioManagerModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const audio = this.currentAudio;
|
const audio = this.currentAudio;
|
||||||
const initialVolume = audio.volume;
|
const initialVolume = this.clampVolume(audio.volume);
|
||||||
const volumeStep = initialVolume / (duration / 50);
|
const volumeStep = initialVolume / (duration / 50);
|
||||||
let currentVolume = initialVolume;
|
let currentVolume = initialVolume;
|
||||||
|
|
||||||
@@ -757,10 +831,10 @@ class AudioManagerModule extends BaseModule {
|
|||||||
clearInterval(fadeInterval);
|
clearInterval(fadeInterval);
|
||||||
audio.pause();
|
audio.pause();
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
audio.volume = initialVolume; // Reset volume for future use
|
this.setMediaVolume(audio, initialVolume); // Reset volume for future use
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
audio.volume = currentVolume;
|
this.setMediaVolume(audio, currentVolume);
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
});
|
});
|
||||||
@@ -806,7 +880,7 @@ class AudioManagerModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply master volume and speech volume
|
// Apply master volume and speech volume
|
||||||
audio.volume = this.masterVolume * speechVolume * this._ttsVolume;
|
this.setMediaVolume(audio, this.masterVolume * speechVolume * this.ttsVolume);
|
||||||
|
|
||||||
// Set up cleanup
|
// Set up cleanup
|
||||||
audio.onended = () => {
|
audio.onended = () => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class ChoiceDisplayModule extends BaseModule {
|
|||||||
this.container = null;
|
this.container = null;
|
||||||
this.choices = [];
|
this.choices = [];
|
||||||
this.inputMode = 'text';
|
this.inputMode = 'text';
|
||||||
this.processState = document.documentElement.dataset.processState || 'ready';
|
this.processState = document.documentElement.dataset.processState || 'loading';
|
||||||
this.template = {
|
this.template = {
|
||||||
cells: {
|
cells: {
|
||||||
default: {
|
default: {
|
||||||
@@ -54,6 +54,14 @@ class ChoiceDisplayModule extends BaseModule {
|
|||||||
this.addEventListener(document, 'story:process-state', (event) => {
|
this.addEventListener(document, 'story:process-state', (event) => {
|
||||||
this.handleProcessState(event.detail?.state || 'ready');
|
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.addEventListener(document, 'keydown', this.handleKeyDown);
|
||||||
|
|
||||||
this.reportProgress(100, 'Choice display ready');
|
this.reportProgress(100, 'Choice display ready');
|
||||||
@@ -81,6 +89,9 @@ class ChoiceDisplayModule extends BaseModule {
|
|||||||
} else if (!commandInput && this.container !== choicesRoot.lastElementChild) {
|
} else if (!commandInput && this.container !== choicesRoot.lastElementChild) {
|
||||||
choicesRoot.appendChild(this.container);
|
choicesRoot.appendChild(this.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.container.hidden = true;
|
||||||
|
this.container.dataset.choiceReady = 'false';
|
||||||
}
|
}
|
||||||
|
|
||||||
handleChoices(choices) {
|
handleChoices(choices) {
|
||||||
@@ -188,7 +199,13 @@ class ChoiceDisplayModule extends BaseModule {
|
|||||||
if (!this.container) return;
|
if (!this.container) return;
|
||||||
|
|
||||||
this.container.innerHTML = '';
|
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.hidden = !readyForChoices;
|
||||||
this.container.dataset.choiceReady = readyForChoices ? 'true' : 'false';
|
this.container.dataset.choiceReady = readyForChoices ? 'true' : 'false';
|
||||||
if (this.container.hidden) {
|
if (this.container.hidden) {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export class DebugUtilsModule extends BaseModule {
|
|||||||
|
|
||||||
// Make utilities available globally for console access
|
// Make utilities available globally for console access
|
||||||
window.DebugUtils = {
|
window.DebugUtils = {
|
||||||
testTextPipeline: this.testTextPipeline.bind(this),
|
|
||||||
testSocketConnection: this.testSocketConnection.bind(this),
|
testSocketConnection: this.testSocketConnection.bind(this),
|
||||||
testTTS: this.testTTS.bind(this),
|
testTTS: this.testTTS.bind(this),
|
||||||
forceReconnect: this.forceReconnect.bind(this)
|
forceReconnect: this.forceReconnect.bind(this)
|
||||||
@@ -32,26 +31,6 @@ export class DebugUtilsModule extends BaseModule {
|
|||||||
return true;
|
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
|
* Test the socket connection
|
||||||
* @returns {boolean} - Success status
|
* @returns {boolean} - Success status
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class GameLoopModule extends BaseModule {
|
|||||||
'updateUIState',
|
'updateUIState',
|
||||||
'refreshGameApiState',
|
'refreshGameApiState',
|
||||||
'hasSaveGame',
|
'hasSaveGame',
|
||||||
|
'queueUnrenderedHistoryBlocks',
|
||||||
'requestStartGame',
|
'requestStartGame',
|
||||||
'requestSaveGame',
|
'requestSaveGame',
|
||||||
'requestLoadGame',
|
'requestLoadGame',
|
||||||
@@ -217,9 +218,13 @@ class GameLoopModule extends BaseModule {
|
|||||||
const response = await socketClient.saveGame(1);
|
const response = await socketClient.saveGame(1);
|
||||||
if (response?.success) {
|
if (response?.success) {
|
||||||
const storyHistory = this.getModule('story-history');
|
const storyHistory = this.getModule('story-history');
|
||||||
|
const audioManager = this.getModule('audio-manager');
|
||||||
if (storyHistory && typeof storyHistory.saveSlot === 'function') {
|
if (storyHistory && typeof storyHistory.saveSlot === 'function') {
|
||||||
await storyHistory.saveSlot(1, {
|
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;
|
this.gameState.canLoad = true;
|
||||||
@@ -246,20 +251,57 @@ class GameLoopModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.resetClientPlaybackAndDisplay();
|
await this.resetClientPlaybackAndDisplay();
|
||||||
|
document.dispatchEvent(new CustomEvent('story:history-restoring', {
|
||||||
|
detail: { active: true, reason: 'load-game' }
|
||||||
|
}));
|
||||||
if (browserSave?.gameId && storyHistory?.setCurrentGame) {
|
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');
|
const uiController = this.getModule('ui-controller');
|
||||||
if (browserSave && uiController?.displayHandler?.restoreFromHistory) {
|
if (browserSave && uiController?.displayHandler?.restoreFromHistory) {
|
||||||
await uiController.displayHandler.restoreFromHistory(browserSave);
|
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);
|
const response = await socketClient.loadGame(1, browserSave?.inkState || null);
|
||||||
|
if (response?.success && hasUnrenderedHistory) {
|
||||||
|
await this.queueUnrenderedHistoryBlocks(browserSave);
|
||||||
|
}
|
||||||
if (response?.success) {
|
if (response?.success) {
|
||||||
this.gameState.started = true;
|
this.gameState.started = true;
|
||||||
this.gameState.canSave = true;
|
this.gameState.canSave = true;
|
||||||
this.gameState.canLoad = true;
|
this.gameState.canLoad = true;
|
||||||
this.updateUIState();
|
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) {
|
async hasSaveGame(slot = 1) {
|
||||||
@@ -272,6 +314,17 @@ class GameLoopModule extends BaseModule {
|
|||||||
return socketClient?.hasSaveGame ? socketClient.hasSaveGame(slot) : { success: false, result: false };
|
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() {
|
async resetClientPlaybackAndDisplay() {
|
||||||
const playbackCoordinator = this.getModule('playback-coordinator');
|
const playbackCoordinator = this.getModule('playback-coordinator');
|
||||||
if (playbackCoordinator && typeof playbackCoordinator.stop === 'function') {
|
if (playbackCoordinator && typeof playbackCoordinator.stop === 'function') {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
this.autoplay = true;
|
this.autoplay = true;
|
||||||
this.inputMode = 'text';
|
this.inputMode = 'text';
|
||||||
this.lastContinueAt = 0;
|
this.lastContinueAt = 0;
|
||||||
|
this.pauseBeforeNextReason = null;
|
||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
@@ -30,6 +31,7 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
'addSentence',
|
'addSentence',
|
||||||
'processNextSentence',
|
'processNextSentence',
|
||||||
'setOnSentenceReady',
|
'setOnSentenceReady',
|
||||||
|
'pauseBeforeNext',
|
||||||
'completeSentence',
|
'completeSentence',
|
||||||
'getCacheKey',
|
'getCacheKey',
|
||||||
'getPreparedSentence',
|
'getPreparedSentence',
|
||||||
@@ -106,6 +108,10 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pauseBeforeNext(reason = 'manual-pause') {
|
||||||
|
this.pauseBeforeNextReason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a sentence to the queue
|
* Add a sentence to the queue
|
||||||
* @param {string} sentence - Sentence to add
|
* @param {string} sentence - Sentence to add
|
||||||
@@ -139,6 +145,12 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
const item = this.sentenceQueue[0];
|
const item = this.sentenceQueue[0];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (this.pauseBeforeNextReason) {
|
||||||
|
const reason = this.pauseBeforeNextReason;
|
||||||
|
this.pauseBeforeNextReason = null;
|
||||||
|
await this.waitForManualContinue(reason);
|
||||||
|
}
|
||||||
|
|
||||||
const sentence = await this.getPreparedSentence(item);
|
const sentence = await this.getPreparedSentence(item);
|
||||||
|
|
||||||
// Prefetch far enough ahead that media pauses do not block TTS
|
// Prefetch far enough ahead that media pauses do not block TTS
|
||||||
@@ -294,6 +306,8 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
kind: metadata.type,
|
kind: metadata.type,
|
||||||
text: text || '',
|
text: text || '',
|
||||||
turnId: metadata.turnId ?? null,
|
turnId: metadata.turnId ?? null,
|
||||||
|
blockId: metadata.blockId ?? null,
|
||||||
|
gameId: metadata.gameId ?? null,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
metadata: imageLayout ? { ...metadata, imageLayout } : metadata,
|
metadata: imageLayout ? { ...metadata, imageLayout } : metadata,
|
||||||
tts: { duration: 0, provider: null, audioData: null, play: null, stop: null, enabled: false },
|
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',
|
kind: metadata.type === 'heading' ? 'heading' : 'paragraph',
|
||||||
text,
|
text,
|
||||||
turnId: metadata.turnId ?? null,
|
turnId: metadata.turnId ?? null,
|
||||||
|
blockId: metadata.blockId ?? null,
|
||||||
|
gameId: metadata.gameId ?? null,
|
||||||
paragraphIndex: metadata.paragraphIndex ?? null,
|
paragraphIndex: metadata.paragraphIndex ?? null,
|
||||||
isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter),
|
isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter),
|
||||||
role: metadata.role || (metadata.type === 'heading' ? 'chapter-heading' : 'body'),
|
role: metadata.role || (metadata.type === 'heading' ? 'chapter-heading' : 'body'),
|
||||||
@@ -746,20 +762,27 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
probe.remove();
|
probe.remove();
|
||||||
|
|
||||||
const pageWidth = storyElement.clientWidth;
|
const pageWidth = storyElement.clientWidth;
|
||||||
const size = String(metadata.size || 'landscape').toLowerCase();
|
const requestedSize = String(metadata.size || 'landscape').toLowerCase();
|
||||||
const aspect = size === 'portrait' ? (9 / 16) : size === 'square' ? 1 : (16 / 9);
|
const size = requestedSize === 'widescreen' ? 'landscape' : requestedSize;
|
||||||
const imageGap = lineHeight * 0.9;
|
const isPortrait = size === 'portrait';
|
||||||
const maxWidth = size === 'portrait' ? pageWidth * 0.5 : pageWidth;
|
const aspect = isPortrait ? (9 / 16) : size === 'square' ? 1 : (16 / 9);
|
||||||
const naturalHeight = maxWidth / aspect;
|
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 imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
|
||||||
const height = imageLineCount * lineHeight;
|
const verticalMargin = isPortrait ? lineHeight / 2 : 0;
|
||||||
const width = Math.min(maxWidth, height * aspect);
|
const lineCount = isPortrait ? imageLineCount + 1 : imageLineCount;
|
||||||
const verticalMargin = lineHeight / 2;
|
const height = isPortrait
|
||||||
const lineCount = imageLineCount + 1;
|
? Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2))
|
||||||
|
: imageLineCount * lineHeight;
|
||||||
|
const width = Math.min(maxImageWidth, height * aspect);
|
||||||
|
|
||||||
if (size === 'portrait') {
|
if (isPortrait) {
|
||||||
this.activeImageWrap = {
|
this.activeImageWrap = {
|
||||||
lines: lineCount,
|
lines: lineCount + 1,
|
||||||
width: width + imageGap,
|
width: width + imageGap,
|
||||||
imageWidth: width,
|
imageWidth: width,
|
||||||
gap: imageGap,
|
gap: imageGap,
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ class SocketClientModule extends BaseModule {
|
|||||||
super('socket-client', 'Socket Client');
|
super('socket-client', 'Socket Client');
|
||||||
|
|
||||||
// Dependencies
|
// Dependencies
|
||||||
this.dependencies = ['text-buffer', 'markup-parser'];
|
this.dependencies = ['text-buffer', 'markup-parser', 'story-history'];
|
||||||
|
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
this.textBuffer = null;
|
this.textBuffer = null;
|
||||||
|
this.storyHistory = null;
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.maxReconnectAttempts = 5;
|
this.maxReconnectAttempts = 5;
|
||||||
@@ -20,6 +21,8 @@ class SocketClientModule extends BaseModule {
|
|||||||
this.url = null;
|
this.url = null;
|
||||||
this.eventListeners = {};
|
this.eventListeners = {};
|
||||||
this.defaultHost = 'localhost:3000';
|
this.defaultHost = 'localhost:3000';
|
||||||
|
this.receivedBlockCounter = 0;
|
||||||
|
this.receivedParagraphCounter = 0;
|
||||||
|
|
||||||
// Bind methods using parent's bindMethods utility
|
// Bind methods using parent's bindMethods utility
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
@@ -44,6 +47,8 @@ class SocketClientModule extends BaseModule {
|
|||||||
'setupGameEventHandlers',
|
'setupGameEventHandlers',
|
||||||
'processTurnResult',
|
'processTurnResult',
|
||||||
'processParagraphResult',
|
'processParagraphResult',
|
||||||
|
'storeAndQueueBlocks',
|
||||||
|
'normalizeHistoryBlock',
|
||||||
'dispatchTurnTags',
|
'dispatchTurnTags',
|
||||||
'isTimedCueTag',
|
'isTimedCueTag',
|
||||||
'cueMarkersFromTags',
|
'cueMarkersFromTags',
|
||||||
@@ -93,6 +98,11 @@ class SocketClientModule extends BaseModule {
|
|||||||
console.error("Socket Client: Failed to get text-buffer module");
|
console.error("Socket Client: Failed to get text-buffer module");
|
||||||
return false;
|
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");
|
this.reportProgress(50, "Setting up connection parameters");
|
||||||
|
|
||||||
@@ -191,7 +201,7 @@ class SocketClientModule extends BaseModule {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
processTurnResult(data) {
|
async processTurnResult(data) {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
const turnId = Number(data.turnId);
|
const turnId = Number(data.turnId);
|
||||||
@@ -199,6 +209,10 @@ class SocketClientModule extends BaseModule {
|
|||||||
console.error('Socket Client: Invalid TurnResult received', data);
|
console.error('Socket Client: Invalid TurnResult received', data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (turnId === 1) {
|
||||||
|
this.receivedBlockCounter = 0;
|
||||||
|
this.receivedParagraphCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(data.globalTags) && data.globalTags.length > 0) {
|
if (Array.isArray(data.globalTags) && data.globalTags.length > 0) {
|
||||||
document.dispatchEvent(new CustomEvent('story:global-tags', {
|
document.dispatchEvent(new CustomEvent('story:global-tags', {
|
||||||
@@ -214,12 +228,24 @@ class SocketClientModule extends BaseModule {
|
|||||||
role: null,
|
role: null,
|
||||||
cueTags: []
|
cueTags: []
|
||||||
};
|
};
|
||||||
|
const turnBlocks = [];
|
||||||
data.paragraphs.forEach((paragraph) => {
|
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 : []);
|
await this.storeAndQueueBlocks(turnBlocks);
|
||||||
this.dispatchInputMode(data.inputMode || (Array.isArray(data.choices) && data.choices.length > 0 ? 'choice' : 'text'));
|
|
||||||
|
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) {
|
dispatchTurnTags(tags, paragraph = null) {
|
||||||
@@ -259,15 +285,16 @@ class SocketClientModule extends BaseModule {
|
|||||||
const immediateTags = tags.filter(tag => !this.isStructuralTag(tag) && !this.isTimedCueTag(tag));
|
const immediateTags = tags.filter(tag => !this.isStructuralTag(tag) && !this.isTimedCueTag(tag));
|
||||||
|
|
||||||
this.dispatchTurnTags(immediateTags, paragraph);
|
this.dispatchTurnTags(immediateTags, paragraph);
|
||||||
blocks.forEach(block => this.enqueueStructuredBlock(block));
|
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return {
|
return {
|
||||||
role: paragraphRole || pending.role || null,
|
blocks,
|
||||||
cueTags: [
|
pendingParagraph: {
|
||||||
...(Array.isArray(pending.cueTags) ? pending.cueTags : []),
|
role: paragraphRole || pending.role || null,
|
||||||
...cueTags
|
cueTags: [
|
||||||
]
|
...(Array.isArray(pending.cueTags) ? pending.cueTags : []),
|
||||||
|
...cueTags
|
||||||
|
]
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +306,7 @@ class SocketClientModule extends BaseModule {
|
|||||||
...cueTags
|
...cueTags
|
||||||
])
|
])
|
||||||
];
|
];
|
||||||
this.enqueueStructuredBlock({
|
blocks.push({
|
||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
text,
|
text,
|
||||||
layoutText: paragraph.layoutText || text,
|
layoutText: paragraph.layoutText || text,
|
||||||
@@ -291,7 +318,50 @@ class SocketClientModule extends BaseModule {
|
|||||||
turnId
|
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) {
|
isStructuralTag(tag) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Story History Module
|
* Story History Module
|
||||||
* Stores rendered story blocks in IndexedDB and keeps only a short live window
|
* Stores received story output blocks in IndexedDB and tracks the render playhead.
|
||||||
* in the page DOM.
|
|
||||||
*/
|
*/
|
||||||
import { BaseModule } from './base-module.js';
|
import { BaseModule } from './base-module.js';
|
||||||
|
|
||||||
@@ -11,12 +10,14 @@ class StoryHistoryModule extends BaseModule {
|
|||||||
|
|
||||||
this.dependencies = ['persistence-manager'];
|
this.dependencies = ['persistence-manager'];
|
||||||
this.dbName = 'ttsAudioCacheDB';
|
this.dbName = 'ttsAudioCacheDB';
|
||||||
this.dbVersion = 2;
|
this.dbVersion = 3;
|
||||||
this.historyStore = 'storyHistoryStore';
|
this.historyStore = 'storyHistoryStore';
|
||||||
this.saveStore = 'storySaveStore';
|
this.saveStore = 'storySaveStore';
|
||||||
this.db = null;
|
this.db = null;
|
||||||
this.currentGameId = null;
|
this.currentGameId = null;
|
||||||
this.nextBlockId = 1;
|
this.nextBlockId = 1;
|
||||||
|
this.latestRenderedBlockId = 0;
|
||||||
|
this.renderedLineCount = 0;
|
||||||
this.visibleLimit = 20;
|
this.visibleLimit = 20;
|
||||||
|
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
@@ -25,11 +26,18 @@ class StoryHistoryModule extends BaseModule {
|
|||||||
'startNewGame',
|
'startNewGame',
|
||||||
'setCurrentGame',
|
'setCurrentGame',
|
||||||
'recordBlock',
|
'recordBlock',
|
||||||
|
'recordBlocks',
|
||||||
|
'markRendered',
|
||||||
|
'updateBlockMetrics',
|
||||||
'saveSlot',
|
'saveSlot',
|
||||||
'loadSlot',
|
'loadSlot',
|
||||||
'hasSaveSlot',
|
'hasSaveSlot',
|
||||||
'getSaveSlots',
|
'getSaveSlots',
|
||||||
'getBlocks',
|
'getBlocks',
|
||||||
|
'getBlocksRange',
|
||||||
|
'getWindowForTurn',
|
||||||
|
'getRenderedLineCount',
|
||||||
|
'findBlockForLine',
|
||||||
'clearGame',
|
'clearGame',
|
||||||
'tx'
|
'tx'
|
||||||
]);
|
]);
|
||||||
@@ -52,6 +60,11 @@ class StoryHistoryModule extends BaseModule {
|
|||||||
};
|
};
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = () => {
|
||||||
const db = request.result;
|
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)) {
|
if (!db.objectStoreNames.contains(this.historyStore)) {
|
||||||
const historyStore = db.createObjectStore(this.historyStore, { keyPath: 'key' });
|
const historyStore = db.createObjectStore(this.historyStore, { keyPath: 'key' });
|
||||||
historyStore.createIndex('gameId', 'gameId', { unique: false });
|
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)}`;
|
const gameId = `game-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
this.currentGameId = gameId;
|
this.currentGameId = gameId;
|
||||||
this.nextBlockId = 1;
|
this.nextBlockId = 1;
|
||||||
|
this.latestRenderedBlockId = 0;
|
||||||
|
this.renderedLineCount = 0;
|
||||||
const persistenceManager = this.getModule('persistence-manager');
|
const persistenceManager = this.getModule('persistence-manager');
|
||||||
persistenceManager?.updatePreference?.('app', 'currentGameId', gameId);
|
persistenceManager?.updatePreference?.('app', 'currentGameId', gameId);
|
||||||
return gameId;
|
return gameId;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentGame(gameId, latestBlockId = 0) {
|
setCurrentGame(gameId, latestBlockId = 0, latestRenderedBlockId = 0, renderedLineCount = 0) {
|
||||||
this.currentGameId = gameId || this.currentGameId;
|
this.currentGameId = gameId || this.currentGameId;
|
||||||
this.nextBlockId = Math.max(1, Number(latestBlockId || 0) + 1);
|
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) {
|
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 = {}) {
|
saveSlot(slot = 1, saveData = {}) {
|
||||||
if (!this.db) return Promise.resolve(false);
|
if (!this.db) return Promise.resolve(false);
|
||||||
const record = {
|
const record = {
|
||||||
@@ -106,6 +183,8 @@ class StoryHistoryModule extends BaseModule {
|
|||||||
...saveData,
|
...saveData,
|
||||||
gameId: saveData.gameId || this.currentGameId,
|
gameId: saveData.gameId || this.currentGameId,
|
||||||
latestBlockId: Math.max(0, this.nextBlockId - 1),
|
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()
|
savedAt: Date.now()
|
||||||
};
|
};
|
||||||
return new Promise((resolve, reject) => {
|
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) {
|
clearGame(gameId = this.currentGameId) {
|
||||||
if (!this.db || !gameId) return Promise.resolve();
|
if (!this.db || !gameId) return Promise.resolve();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -20,9 +20,8 @@ class TextBufferModule extends BaseModule {
|
|||||||
|
|
||||||
// Bind methods using parent's bindMethods utility
|
// Bind methods using parent's bindMethods utility
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
'addText',
|
|
||||||
'addBlock',
|
'addBlock',
|
||||||
'splitIntoParagraphs',
|
'addBlocks',
|
||||||
'processNextFromQueue',
|
'processNextFromQueue',
|
||||||
'processSentences',
|
'processSentences',
|
||||||
'processNextSentence',
|
'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.
|
* Add an already parsed render block to the processing queue.
|
||||||
* Engine protocols should prefer this over re-serializing tags into text markup.
|
* Engine protocols should prefer this over re-serializing tags into text markup.
|
||||||
@@ -173,18 +122,9 @@ class TextBufferModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
addBlocks(blocks = []) {
|
||||||
* Split an incoming narrative fragment into book paragraphs.
|
if (!Array.isArray(blocks)) return;
|
||||||
* Single newlines inside a paragraph are normalized to spaces; blank lines
|
blocks.forEach(block => this.addBlock(block));
|
||||||
* 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class TTSFactoryModule extends BaseModule {
|
|||||||
this.db = null; // Will hold the DB connection
|
this.db = null; // Will hold the DB connection
|
||||||
this.dbName = 'ttsAudioCacheDB';
|
this.dbName = 'ttsAudioCacheDB';
|
||||||
this.storeName = 'audioCacheStore';
|
this.storeName = 'audioCacheStore';
|
||||||
this.dbVersion = 2;
|
this.dbVersion = 3;
|
||||||
this.currentCacheSize = 0; // Track current size in bytes
|
this.currentCacheSize = 0; // Track current size in bytes
|
||||||
this.maxCacheSizeBytes = 100 * 1024 * 1024; // 100 MB by default
|
this.maxCacheSizeBytes = 100 * 1024 * 1024; // 100 MB by default
|
||||||
this.cacheInitialized = false;
|
this.cacheInitialized = false;
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ class UIControllerModule extends BaseModule {
|
|||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
'initialize',
|
'initialize',
|
||||||
'handleCommand',
|
'handleCommand',
|
||||||
'displayText',
|
|
||||||
'setupBookInterface',
|
'setupBookInterface',
|
||||||
'applyBookSizing',
|
'applyBookSizing',
|
||||||
'setupEventListeners',
|
'setupEventListeners',
|
||||||
@@ -264,7 +263,7 @@ class UIControllerModule extends BaseModule {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.addEventListener(document, 'click', (event) => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,10 +667,9 @@ class UIControllerModule extends BaseModule {
|
|||||||
console.log('UI Controller: Command sent successfully');
|
console.log('UI Controller: Command sent successfully');
|
||||||
} else {
|
} else {
|
||||||
console.error('UI Controller: Failed to send command to socket');
|
console.error('UI Controller: Failed to send command to socket');
|
||||||
// Display an error message to the user
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||||
this.displayHandler.displayText('⚠️ Unable to send command. Server connection might be lost.', {
|
detail: { state: 'ready', reason: 'command-send-failed' }
|
||||||
style: { color: '#990000' }
|
}));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('UI Controller: Socket client not available for sending commands');
|
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() {
|
clearDisplay() {
|
||||||
this.displayHandler.clear();
|
this.displayHandler.clear();
|
||||||
}
|
}
|
||||||
@@ -796,3 +790,4 @@ const uiController = new UIControllerModule();
|
|||||||
|
|
||||||
// Export the module
|
// Export the module
|
||||||
export { uiController as UIController };
|
export { uiController as UIController };
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,6 @@ class UIEffectsModule extends BaseModule {
|
|||||||
'applyEffect',
|
'applyEffect',
|
||||||
'applyShakeEffect',
|
'applyShakeEffect',
|
||||||
'applyFlashEffect',
|
'applyFlashEffect',
|
||||||
'applyTextEmphasis',
|
|
||||||
'processCommand',
|
'processCommand',
|
||||||
'handleLightingAnimationEnd'
|
'handleLightingAnimationEnd'
|
||||||
]);
|
]);
|
||||||
@@ -209,8 +208,6 @@ class UIEffectsModule extends BaseModule {
|
|||||||
return this.applyShakeEffect(options);
|
return this.applyShakeEffect(options);
|
||||||
case 'flash':
|
case 'flash':
|
||||||
return this.applyFlashEffect(options);
|
return this.applyFlashEffect(options);
|
||||||
case 'emphasis':
|
|
||||||
return this.applyTextEmphasis(options.text, options);
|
|
||||||
default:
|
default:
|
||||||
console.warn(`Unknown effect: ${effectName}`);
|
console.warn(`Unknown effect: ${effectName}`);
|
||||||
return null;
|
return null;
|
||||||
@@ -288,20 +285,6 @@ class UIEffectsModule extends BaseModule {
|
|||||||
return effectId;
|
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) {
|
processCommand(command) {
|
||||||
switch (command.action) {
|
switch (command.action) {
|
||||||
case 'apply':
|
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
|
// Process player command
|
||||||
socket.on('playerCommand', async (data) => {
|
socket.on('playerCommand', async (data) => {
|
||||||
try {
|
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
|
// Handle disconnection
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
console.log(`Client disconnected: ${socket.id}`);
|
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
|
// Process player command
|
||||||
socket.on('playerCommand', async (data) => {
|
socket.on('playerCommand', async (data) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user