Stabilize playback state and cursor feedback
This commit is contained in:
@@ -1352,6 +1352,37 @@ html[data-process-state="playing-ready"] body {
|
|||||||
cursor: var(--process-cursor, progress) !important;
|
cursor: var(--process-cursor, progress) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html body.modal-open {
|
||||||
|
cursor: var(--default-cursor, default) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-process-state="command-waiting"] a:not([aria-disabled="true"]),
|
||||||
|
html[data-process-state="command-waiting"] button:not([disabled]),
|
||||||
|
html[data-process-state="command-waiting"] input:not([disabled]),
|
||||||
|
html[data-process-state="command-waiting"] textarea:not([disabled]),
|
||||||
|
html[data-process-state="command-waiting"] select:not([disabled]),
|
||||||
|
html[data-process-state="command-waiting"] [role="button"],
|
||||||
|
html[data-process-state="waiting-generating"] a:not([aria-disabled="true"]),
|
||||||
|
html[data-process-state="waiting-generating"] button:not([disabled]),
|
||||||
|
html[data-process-state="waiting-generating"] input:not([disabled]),
|
||||||
|
html[data-process-state="waiting-generating"] textarea:not([disabled]),
|
||||||
|
html[data-process-state="waiting-generating"] select:not([disabled]),
|
||||||
|
html[data-process-state="waiting-generating"] [role="button"],
|
||||||
|
html[data-process-state="playing-generating"] a:not([aria-disabled="true"]),
|
||||||
|
html[data-process-state="playing-generating"] button:not([disabled]),
|
||||||
|
html[data-process-state="playing-generating"] input:not([disabled]),
|
||||||
|
html[data-process-state="playing-generating"] textarea:not([disabled]),
|
||||||
|
html[data-process-state="playing-generating"] select:not([disabled]),
|
||||||
|
html[data-process-state="playing-generating"] [role="button"],
|
||||||
|
html[data-process-state="playing-ready"] a:not([aria-disabled="true"]),
|
||||||
|
html[data-process-state="playing-ready"] button:not([disabled]),
|
||||||
|
html[data-process-state="playing-ready"] input:not([disabled]),
|
||||||
|
html[data-process-state="playing-ready"] textarea:not([disabled]),
|
||||||
|
html[data-process-state="playing-ready"] select:not([disabled]),
|
||||||
|
html[data-process-state="playing-ready"] [role="button"] {
|
||||||
|
cursor: var(--pointer-cursor, pointer) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Placeholder styling - lighter and italic, with padding to avoid cursor overlap */
|
/* Placeholder styling - lighter and italic, with padding to avoid cursor overlap */
|
||||||
#player_input::placeholder {
|
#player_input::placeholder {
|
||||||
color: var(--ink-disabled);
|
color: var(--ink-disabled);
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: rgba(0, 0, 0, 0.85);
|
background-color: #000;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class AudioManagerModule extends BaseModule {
|
|||||||
this.ttsQueueEmpty = true;
|
this.ttsQueueEmpty = true;
|
||||||
this.pendingMusicPlayback = null;
|
this.pendingMusicPlayback = null;
|
||||||
this.currentMusicState = null;
|
this.currentMusicState = null;
|
||||||
|
this.mediaPreloadTimeoutMs = 60000;
|
||||||
this.assetRoots = {
|
this.assetRoots = {
|
||||||
images: '/images/',
|
images: '/images/',
|
||||||
music: '/music/',
|
music: '/music/',
|
||||||
@@ -493,6 +494,10 @@ class AudioManagerModule extends BaseModule {
|
|||||||
.then(audio => {
|
.then(audio => {
|
||||||
this.setMediaVolume(audio, this.getSfxVolume());
|
this.setMediaVolume(audio, this.getSfxVolume());
|
||||||
return audio;
|
return audio;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.sfxCache.delete(url);
|
||||||
|
throw error;
|
||||||
});
|
});
|
||||||
this.sfxCache.set(url, promise);
|
this.sfxCache.set(url, promise);
|
||||||
return promise;
|
return promise;
|
||||||
@@ -505,6 +510,10 @@ class AudioManagerModule extends BaseModule {
|
|||||||
.then(audio => {
|
.then(audio => {
|
||||||
this.setMediaVolume(audio, this.getMusicVolume());
|
this.setMediaVolume(audio, this.getMusicVolume());
|
||||||
return audio;
|
return audio;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.musicCache.delete(url);
|
||||||
|
throw error;
|
||||||
});
|
});
|
||||||
this.musicCache.set(url, promise);
|
this.musicCache.set(url, promise);
|
||||||
return promise;
|
return promise;
|
||||||
@@ -517,14 +526,24 @@ class AudioManagerModule extends BaseModule {
|
|||||||
const finish = (result, error = null) => {
|
const finish = (result, error = null) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
audio.removeEventListener('canplaythrough', onReady);
|
audio.removeEventListener('canplaythrough', onReady);
|
||||||
audio.removeEventListener('loadeddata', onReady);
|
audio.removeEventListener('loadeddata', onReady);
|
||||||
audio.removeEventListener('error', onError);
|
audio.removeEventListener('error', onError);
|
||||||
if (error) reject(error);
|
if (error) {
|
||||||
else resolve(result);
|
audio.pause();
|
||||||
|
audio.removeAttribute('src');
|
||||||
|
audio.load();
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const onReady = () => finish(audio);
|
const onReady = () => finish(audio);
|
||||||
const onError = () => finish(null, new Error(`Failed to preload ${label}: ${url}`));
|
const onError = () => finish(null, new Error(`Failed to preload ${label}: ${url}`));
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
finish(null, new Error(`Timed out preloading ${label}: ${url}`));
|
||||||
|
}, this.mediaPreloadTimeoutMs);
|
||||||
audio.preload = 'auto';
|
audio.preload = 'auto';
|
||||||
audio.addEventListener('canplaythrough', onReady, { once: true });
|
audio.addEventListener('canplaythrough', onReady, { once: true });
|
||||||
audio.addEventListener('loadeddata', onReady, { once: true });
|
audio.addEventListener('loadeddata', onReady, { once: true });
|
||||||
@@ -538,16 +557,36 @@ class AudioManagerModule extends BaseModule {
|
|||||||
if (this.imageCache.has(url)) return this.imageCache.get(url);
|
if (this.imageCache.has(url)) return this.imageCache.get(url);
|
||||||
const promise = new Promise((resolve, reject) => {
|
const promise = new Promise((resolve, reject) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
|
let settled = false;
|
||||||
|
const finish = (result, error = null) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
image.onload = null;
|
||||||
|
image.onerror = null;
|
||||||
|
if (error) {
|
||||||
|
image.src = '';
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
image.decoding = 'async';
|
image.decoding = 'async';
|
||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
if (typeof image.decode === 'function') {
|
if (typeof image.decode === 'function') {
|
||||||
image.decode().catch(() => null).then(() => resolve(image));
|
image.decode().catch(() => null).then(() => finish(image));
|
||||||
} else {
|
} else {
|
||||||
resolve(image);
|
finish(image);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
image.onerror = () => reject(new Error(`Failed to preload image: ${url}`));
|
image.onerror = () => finish(null, new Error(`Failed to preload image: ${url}`));
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
finish(null, new Error(`Timed out preloading image: ${url}`));
|
||||||
|
}, this.mediaPreloadTimeoutMs);
|
||||||
image.src = url;
|
image.src = url;
|
||||||
|
}).catch(error => {
|
||||||
|
this.imageCache.delete(url);
|
||||||
|
throw error;
|
||||||
});
|
});
|
||||||
this.imageCache.set(url, promise);
|
this.imageCache.set(url, promise);
|
||||||
return promise;
|
return promise;
|
||||||
@@ -571,7 +610,7 @@ class AudioManagerModule extends BaseModule {
|
|||||||
throw error;
|
throw error;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await Promise.all(tasks);
|
return Promise.all(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMediaCue(cue) {
|
handleMediaCue(cue) {
|
||||||
|
|||||||
+159
-26
@@ -44,6 +44,7 @@ const ModuleLoader = (function() {
|
|||||||
let gameLoopModule = null; // Add variable to hold game loop instance
|
let gameLoopModule = null; // Add variable to hold game loop instance
|
||||||
let moduleTimings = {}; // Track timing data for modules
|
let moduleTimings = {}; // Track timing data for modules
|
||||||
let finalizationTimer = null;
|
let finalizationTimer = null;
|
||||||
|
let moduleExitAnimations = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the loader
|
* Initialize the loader
|
||||||
@@ -598,6 +599,7 @@ const ModuleLoader = (function() {
|
|||||||
// If no overlay exists in the HTML, create a minimal one
|
// If no overlay exists in the HTML, create a minimal one
|
||||||
loadingOverlay = document.createElement('div');
|
loadingOverlay = document.createElement('div');
|
||||||
loadingOverlay.className = 'loading-overlay';
|
loadingOverlay.className = 'loading-overlay';
|
||||||
|
loadingOverlay.style.backgroundColor = '#000';
|
||||||
loadingOverlay.style.transition = 'opacity 0.5s ease-out';
|
loadingOverlay.style.transition = 'opacity 0.5s ease-out';
|
||||||
document.body.appendChild(loadingOverlay);
|
document.body.appendChild(loadingOverlay);
|
||||||
|
|
||||||
@@ -639,6 +641,7 @@ const ModuleLoader = (function() {
|
|||||||
modulesList = loadingOverlay.querySelector('#modules-list');
|
modulesList = loadingOverlay.querySelector('#modules-list');
|
||||||
|
|
||||||
// Ensure transition is set
|
// Ensure transition is set
|
||||||
|
loadingOverlay.style.backgroundColor = '#000';
|
||||||
loadingOverlay.style.transition = 'opacity 0.5s ease-out';
|
loadingOverlay.style.transition = 'opacity 0.5s ease-out';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -728,16 +731,7 @@ const ModuleLoader = (function() {
|
|||||||
// if (areAllModulesComplete()) {
|
// if (areAllModulesComplete()) {
|
||||||
// hideLoadingOverlay();
|
// hideLoadingOverlay();
|
||||||
// }
|
// }
|
||||||
const moduleItem = document.getElementById(`module-${moduleId}`);
|
animateModuleItemExit(moduleId);
|
||||||
if (moduleItem) {
|
|
||||||
// Ensure module-finished class is added with a small delay to avoid race conditions
|
|
||||||
setTimeout(() => {
|
|
||||||
moduleItem.classList.add('module-finished');
|
|
||||||
moduleItem.addEventListener('animationend', () => {
|
|
||||||
moduleItem.remove();
|
|
||||||
}, { once: true });
|
|
||||||
}, 120);
|
|
||||||
}
|
|
||||||
} else if (state === ModuleState.ERROR) {
|
} else if (state === ModuleState.ERROR) {
|
||||||
moduleProgress[moduleId] = 100;
|
moduleProgress[moduleId] = 100;
|
||||||
}
|
}
|
||||||
@@ -810,42 +804,40 @@ const ModuleLoader = (function() {
|
|||||||
/**
|
/**
|
||||||
* Finalize the loading process
|
* Finalize the loading process
|
||||||
*/
|
*/
|
||||||
function finalizeLoading() {
|
async function finalizeLoading() {
|
||||||
console.log('Loading completed. Finalizing...');
|
console.log('Loading completed. Finalizing...');
|
||||||
try {
|
try {
|
||||||
// Display timing data
|
// Display timing data
|
||||||
displayModuleTimings();
|
displayModuleTimings();
|
||||||
|
|
||||||
completeFinalization();
|
await completeFinalization();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during finalization:', error);
|
console.error('Error during finalization:', error);
|
||||||
// Force hide the overlay even if there was an error
|
await hideOverlay();
|
||||||
hideOverlay();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete the finalization process
|
* Complete the finalization process
|
||||||
*/
|
*/
|
||||||
function completeFinalization() {
|
async function completeFinalization() {
|
||||||
isLoadingComplete = true;
|
isLoadingComplete = true;
|
||||||
|
|
||||||
// Call the start method on the game loop module directly
|
// Call the start method on the game loop module directly
|
||||||
// Ensure the game loop module was found during initialization
|
// Ensure the game loop module was found during initialization
|
||||||
if (gameLoopModule && typeof gameLoopModule.start === 'function') {
|
if (gameLoopModule && typeof gameLoopModule.start === 'function') {
|
||||||
// Hide the overlay first, then start the game loop
|
// Hide the overlay first, then start the game loop
|
||||||
hideOverlay(() => {
|
await hideOverlay();
|
||||||
console.log("Loader: Overlay hidden, starting Game Loop.");
|
console.log("Loader: Overlay hidden, starting Game Loop.");
|
||||||
try {
|
try {
|
||||||
gameLoopModule.start();
|
gameLoopModule.start();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error starting Game Loop:", error);
|
console.error("Error starting Game Loop:", error);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
console.error("Loader: Game Loop module not found or start method missing.");
|
console.error("Loader: Game Loop module not found or start method missing.");
|
||||||
// Hide overlay anyway, but log error
|
// Hide overlay anyway, but log error
|
||||||
hideOverlay();
|
await hideOverlay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,19 +876,19 @@ const ModuleLoader = (function() {
|
|||||||
* Then completely remove it from the DOM
|
* Then completely remove it from the DOM
|
||||||
* @param {Function} [callback] - Optional callback to execute after fade completes
|
* @param {Function} [callback] - Optional callback to execute after fade completes
|
||||||
*/
|
*/
|
||||||
function hideOverlay(callback) { // Added callback parameter
|
async function hideOverlay(callback) { // Added callback parameter
|
||||||
if (!loadingOverlay) {
|
if (!loadingOverlay) {
|
||||||
if (callback) callback(); // Call callback immediately if no overlay
|
if (callback) callback(); // Call callback immediately if no overlay
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await waitForProgressIndicatorsToExit();
|
||||||
|
|
||||||
// Set opacity to 0 to trigger the fade-out transition
|
// Set opacity to 0 to trigger the fade-out transition
|
||||||
loadingOverlay.style.opacity = '0';
|
loadingOverlay.style.opacity = '0';
|
||||||
|
|
||||||
// Use transition event listener to remove from DOM after fade completes
|
await waitForTransition(loadingOverlay, 'opacity');
|
||||||
loadingOverlay.addEventListener('transitionend', function handler(e) {
|
|
||||||
// Only handle the opacity transition
|
|
||||||
if (e.propertyName === 'opacity') {
|
|
||||||
console.log('Module Loader: Removing overlay from DOM');
|
console.log('Module Loader: Removing overlay from DOM');
|
||||||
|
|
||||||
// Remove from DOM completely
|
// Remove from DOM completely
|
||||||
@@ -904,17 +896,158 @@ const ModuleLoader = (function() {
|
|||||||
loadingOverlay.parentNode.removeChild(loadingOverlay);
|
loadingOverlay.parentNode.removeChild(loadingOverlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the event listener to prevent memory leaks
|
|
||||||
loadingOverlay.removeEventListener('transitionend', handler);
|
|
||||||
|
|
||||||
// Set to null to allow garbage collection
|
// Set to null to allow garbage collection
|
||||||
loadingOverlay = null;
|
loadingOverlay = null;
|
||||||
|
|
||||||
// Execute the callback if provided
|
// Execute the callback if provided
|
||||||
if (callback) callback();
|
if (callback) callback();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animate one module progress row out and resolve only after its own
|
||||||
|
* fade/collapse animation has finished.
|
||||||
|
* @param {string} moduleId - Module ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
function animateModuleItemExit(moduleId) {
|
||||||
|
if (moduleExitAnimations.has(moduleId)) {
|
||||||
|
return moduleExitAnimations.get(moduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleItem = document.getElementById(`module-${moduleId}`);
|
||||||
|
if (!moduleItem) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitPromise = new Promise(resolve => {
|
||||||
|
let settled = false;
|
||||||
|
let timeoutId = null;
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
}
|
||||||
|
moduleItem.removeEventListener('animationend', handleAnimationEnd);
|
||||||
|
if (moduleItem.parentNode) {
|
||||||
|
moduleItem.parentNode.removeChild(moduleItem);
|
||||||
|
}
|
||||||
|
moduleExitAnimations.delete(moduleId);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnimationEnd = event => {
|
||||||
|
if (event.target === moduleItem && event.animationName === 'fadeOutModule') {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Let the finished status paint briefly before the row collapses.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!moduleItem.isConnected) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleItem.addEventListener('animationend', handleAnimationEnd);
|
||||||
|
moduleItem.classList.add('module-finished');
|
||||||
|
|
||||||
|
const animationTime = getLongestCssTime(moduleItem, 'animation');
|
||||||
|
timeoutId = setTimeout(finish, Math.max(animationTime + 80, 80));
|
||||||
|
}, 120);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
moduleExitAnimations.set(moduleId, exitPromise);
|
||||||
|
return exitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make every remaining progress row leave, then wait for all of them.
|
||||||
|
* This keeps the overlay fade from racing the final row animations.
|
||||||
|
*/
|
||||||
|
async function waitForProgressIndicatorsToExit() {
|
||||||
|
if (modulesList) {
|
||||||
|
modulesList.querySelectorAll('.module-item').forEach(moduleItem => {
|
||||||
|
const moduleId = moduleItem.id.replace(/^module-/, '');
|
||||||
|
animateModuleItemExit(moduleId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moduleExitAnimations.size > 0) {
|
||||||
|
await Promise.allSettled([...moduleExitAnimations.values()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a CSS transition on an element. The timeout is derived from
|
||||||
|
* computed CSS duration/delay so non-animated cases resolve immediately.
|
||||||
|
* @param {Element} element - Element that is transitioning
|
||||||
|
* @param {string} propertyName - CSS property to wait for
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
function waitForTransition(element, propertyName) {
|
||||||
|
const transitionTime = getLongestCssTime(element, 'transition');
|
||||||
|
|
||||||
|
if (transitionTime <= 0) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
element.removeEventListener('transitionend', handleTransitionEnd);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const handleTransitionEnd = event => {
|
||||||
|
if (event.target === element && event.propertyName === propertyName) {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const timeoutId = setTimeout(finish, transitionTime + 80);
|
||||||
|
element.addEventListener('transitionend', handleTransitionEnd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the longest duration+delay pair from computed transition/animation CSS.
|
||||||
|
* @param {Element} element - Element to inspect
|
||||||
|
* @param {'transition'|'animation'} kind - CSS timing group
|
||||||
|
* @returns {number} milliseconds
|
||||||
|
*/
|
||||||
|
function getLongestCssTime(element, kind) {
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
const durations = parseCssTimeList(style[`${kind}Duration`]);
|
||||||
|
const delays = parseCssTimeList(style[`${kind}Delay`]);
|
||||||
|
const count = Math.max(durations.length, delays.length);
|
||||||
|
let longest = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const duration = durations[i % durations.length] || 0;
|
||||||
|
const delay = delays[i % delays.length] || 0;
|
||||||
|
longest = Math.max(longest, duration + delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
return longest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a comma separated CSS time list into milliseconds.
|
||||||
|
* @param {string} value - CSS time list
|
||||||
|
* @returns {number[]}
|
||||||
|
*/
|
||||||
|
function parseCssTimeList(value) {
|
||||||
|
return String(value || '0s').split(',').map(part => {
|
||||||
|
const text = part.trim();
|
||||||
|
const amount = Number.parseFloat(text);
|
||||||
|
if (!Number.isFinite(amount)) return 0;
|
||||||
|
return text.endsWith('ms') ? amount : amount * 1000;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ class PlaybackCoordinatorModule extends BaseModule {
|
|||||||
|
|
||||||
this.isPlaying = true;
|
this.isPlaying = true;
|
||||||
this.currentSentence = sentence;
|
this.currentSentence = sentence;
|
||||||
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||||
|
detail: {
|
||||||
|
state: 'playing-ready',
|
||||||
|
reason: 'playback-start',
|
||||||
|
sentenceId: sentence?.id ?? null
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Start TTS first, then begin text animation when the audio element
|
// Start TTS first, then begin text animation when the audio element
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
import { BaseModule } from './base-module.js';
|
import { BaseModule } from './base-module.js';
|
||||||
|
|
||||||
const TTS_GENERATION_TIMEOUT_MS = 60000;
|
const TTS_GENERATION_TIMEOUT_MS = 60000;
|
||||||
|
const ASSET_PRELOAD_TIMEOUT_MS = 60000;
|
||||||
|
const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000;
|
||||||
|
|
||||||
class SentenceQueueModule extends BaseModule {
|
class SentenceQueueModule extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -25,7 +27,9 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
this.lastContinueAt = 0;
|
this.lastContinueAt = 0;
|
||||||
this.pauseBeforeNextReason = null;
|
this.pauseBeforeNextReason = null;
|
||||||
this.ttsGenerationTimeoutMs = TTS_GENERATION_TIMEOUT_MS;
|
this.ttsGenerationTimeoutMs = TTS_GENERATION_TIMEOUT_MS;
|
||||||
|
this.assetPreloadTimeoutMs = ASSET_PRELOAD_TIMEOUT_MS;
|
||||||
this.generationRequests = new Map();
|
this.generationRequests = new Map();
|
||||||
|
this.assetPreloadRequests = new Map();
|
||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
@@ -44,6 +48,8 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
'runTtsPreloadWithTimeout',
|
'runTtsPreloadWithTimeout',
|
||||||
'cancelBlockingGeneration',
|
'cancelBlockingGeneration',
|
||||||
'cancelGenerationRequests',
|
'cancelGenerationRequests',
|
||||||
|
'cancelBlockingAssetPreloads',
|
||||||
|
'cancelAssetPreloads',
|
||||||
'isSpeechItem',
|
'isSpeechItem',
|
||||||
'getMediaPauseSeconds',
|
'getMediaPauseSeconds',
|
||||||
'readFirstFiniteNumber',
|
'readFirstFiniteNumber',
|
||||||
@@ -96,7 +102,12 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
this.addEventListener(document, 'ui:command', (event) => {
|
this.addEventListener(document, 'ui:command', (event) => {
|
||||||
if (event.detail?.type === 'continue') {
|
if (event.detail?.type === 'continue') {
|
||||||
this.lastContinueAt = performance.now();
|
this.lastContinueAt = performance.now();
|
||||||
this.cancelBlockingGeneration('user-fast-forward');
|
this.cancelBlockingGeneration('user-fast-forward', {
|
||||||
|
minWaitMs: USER_CANCEL_BLOCKING_WAIT_MIN_MS
|
||||||
|
});
|
||||||
|
this.cancelBlockingAssetPreloads('user-fast-forward', {
|
||||||
|
minWaitMs: USER_CANCEL_BLOCKING_WAIT_MIN_MS
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -159,6 +170,16 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
await this.waitForManualContinue(reason);
|
await this.waitForManualContinue(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||||
|
detail: {
|
||||||
|
state: 'waiting-generating',
|
||||||
|
reason: 'preparing-next-block',
|
||||||
|
sentenceId: item?.id || null,
|
||||||
|
blockId: item?.blockId || null,
|
||||||
|
kind: item?.kind || item?.type || 'paragraph'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
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
|
||||||
@@ -189,6 +210,12 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("SentenceQueue: Error processing sentence:", error);
|
console.error("SentenceQueue: Error processing sentence:", error);
|
||||||
|
const failedItem = this.sentenceQueue.shift();
|
||||||
|
console.warn('SentenceQueue: Dropped failed queue item so playback can continue', {
|
||||||
|
sentenceId: failedItem?.id || item?.id || null,
|
||||||
|
blockId: failedItem?.blockId || item?.blockId || null,
|
||||||
|
error
|
||||||
|
});
|
||||||
if (item.callback) item.callback({ success: false, error });
|
if (item.callback) item.callback({ success: false, error });
|
||||||
} finally {
|
} finally {
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
@@ -334,8 +361,12 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelBlockingGeneration(reason = 'cancelled') {
|
cancelBlockingGeneration(reason = 'cancelled', options = {}) {
|
||||||
this.cancelGenerationRequests(reason, request => request.blocking === true);
|
const minWaitMs = Math.max(0, Number(options.minWaitMs || 0));
|
||||||
|
this.cancelGenerationRequests(reason, request =>
|
||||||
|
request.blocking === true &&
|
||||||
|
(performance.now() - request.startedAt) >= minWaitMs
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelGenerationRequests(reason = 'cancelled', predicate = () => true) {
|
cancelGenerationRequests(reason = 'cancelled', predicate = () => true) {
|
||||||
@@ -359,6 +390,30 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelBlockingAssetPreloads(reason = 'cancelled', options = {}) {
|
||||||
|
const minWaitMs = Math.max(0, Number(options.minWaitMs || 0));
|
||||||
|
this.cancelAssetPreloads(reason, request =>
|
||||||
|
request.blocking === true &&
|
||||||
|
(performance.now() - request.startedAt) >= minWaitMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAssetPreloads(reason = 'cancelled', predicate = () => true) {
|
||||||
|
for (const [requestId, request] of this.assetPreloadRequests.entries()) {
|
||||||
|
if (!predicate(request)) continue;
|
||||||
|
console.warn('SentenceQueue: Cancelling asset preload request', {
|
||||||
|
requestId,
|
||||||
|
sentenceId: request.sentenceId,
|
||||||
|
reason,
|
||||||
|
elapsedMs: Math.round(performance.now() - request.startedAt),
|
||||||
|
assetType: request.assetType
|
||||||
|
});
|
||||||
|
if (typeof request.finish === 'function') {
|
||||||
|
request.finish({ success: false, reason: 'asset_preload_cancelled', cancelled: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Estimate speech duration based on character count
|
* Estimate speech duration based on character count
|
||||||
* @param {string} text - Text to estimate duration for
|
* @param {string} text - Text to estimate duration for
|
||||||
@@ -517,7 +572,7 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
const layoutText = metadata.layoutText || text;
|
const layoutText = metadata.layoutText || text;
|
||||||
const dropCapText = metadata.dropCap ? this.getDropCapText(layoutText) : '';
|
const dropCapText = metadata.dropCap ? this.getDropCapText(layoutText) : '';
|
||||||
const dropCapWidth = metadata.dropCap
|
const dropCapWidth = metadata.dropCap
|
||||||
? this.measureDropCapReservation(storyElement, dropCapText, lineHeight)
|
? await this.measureDropCapReservation(storyElement, dropCapText, lineHeight)
|
||||||
: 0;
|
: 0;
|
||||||
const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText;
|
const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText;
|
||||||
const measures = Array.isArray(metadata.measures) && metadata.measures.length > 0
|
const measures = Array.isArray(metadata.measures) && metadata.measures.length > 0
|
||||||
@@ -598,7 +653,7 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
|
|
||||||
async preloadAssetsForItem(item = {}, context = {}) {
|
async preloadAssetsForItem(item = {}, context = {}) {
|
||||||
const audioManager = this.getModule('audio-manager');
|
const audioManager = this.getModule('audio-manager');
|
||||||
if (!audioManager) return;
|
if (!audioManager) return { success: true, reason: 'audio_manager_unavailable' };
|
||||||
|
|
||||||
const tasks = [];
|
const tasks = [];
|
||||||
const type = String(item.type || item.kind || '').toLowerCase();
|
const type = String(item.type || item.kind || '').toLowerCase();
|
||||||
@@ -610,28 +665,82 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pending = tasks.filter(Boolean);
|
const pending = tasks.filter(Boolean);
|
||||||
if (pending.length === 0) return;
|
if (pending.length === 0) return { success: true, reason: 'no_assets' };
|
||||||
|
|
||||||
const state = context.blocking ? 'waiting-generating' : 'playing-generating';
|
const state = context.blocking ? 'waiting-generating' : 'playing-generating';
|
||||||
|
const sentenceId = context.sentenceId || item.id || null;
|
||||||
|
const requestId = `${sentenceId || 'asset'}:${context.prefetch ? 'prefetch' : 'blocking'}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const startedAt = performance.now();
|
||||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||||
detail: {
|
detail: {
|
||||||
state,
|
state,
|
||||||
reason: 'asset-preload-start',
|
reason: 'asset-preload-start',
|
||||||
sentenceId: context.sentenceId || item.id || null,
|
sentenceId,
|
||||||
assetType: type || 'cue'
|
assetType: type || 'cue'
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await Promise.all(pending);
|
const result = await new Promise(resolve => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (value) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.assetPreloadRequests.delete(requestId);
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
console.warn('SentenceQueue: Asset preload timed out; continuing without confirmed asset', {
|
||||||
|
sentenceId,
|
||||||
|
timeoutMs: this.assetPreloadTimeoutMs,
|
||||||
|
assetType: type || 'cue'
|
||||||
|
});
|
||||||
|
finish({ success: false, reason: 'asset_preload_timeout', timedOut: true });
|
||||||
|
}, this.assetPreloadTimeoutMs);
|
||||||
|
|
||||||
|
this.assetPreloadRequests.set(requestId, {
|
||||||
|
blocking: context.blocking !== false,
|
||||||
|
sentenceId,
|
||||||
|
assetType: type || 'cue',
|
||||||
|
startedAt,
|
||||||
|
finish
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.allSettled(pending)
|
||||||
|
.then(results => {
|
||||||
|
const failures = results.filter(entry => entry.status === 'rejected');
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.warn('SentenceQueue: Some assets failed to preload; continuing without them', {
|
||||||
|
sentenceId,
|
||||||
|
assetType: type || 'cue',
|
||||||
|
failures: failures.map(entry => entry.reason)
|
||||||
|
});
|
||||||
|
finish({ success: false, reason: 'asset_preload_failed', failures });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish({ success: true, reason: 'asset_preload_complete' });
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.warn('SentenceQueue: Asset preload failed unexpectedly; continuing', {
|
||||||
|
sentenceId,
|
||||||
|
assetType: type || 'cue',
|
||||||
|
error
|
||||||
|
});
|
||||||
|
finish({ success: false, reason: 'asset_preload_error', error });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||||
detail: {
|
detail: {
|
||||||
state: 'playing-ready',
|
state: 'playing-ready',
|
||||||
reason: 'asset-preload-complete',
|
reason: result.success ? 'asset-preload-complete' : result.reason,
|
||||||
sentenceId: context.sentenceId || item.id || null,
|
sentenceId,
|
||||||
assetType: type || 'cue'
|
assetType: type || 'cue',
|
||||||
|
degraded: !result.success
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldPauseAfterSentence(sentence) {
|
shouldPauseAfterSentence(sentence) {
|
||||||
@@ -688,7 +797,7 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
async getPreparedSentence(item) {
|
async getPreparedSentence(item) {
|
||||||
const pending = this.prefetchingSpeech.get(this.getCacheKey(item));
|
const pending = this.prefetchingSpeech.get(this.getCacheKey(item));
|
||||||
if (pending) {
|
if (pending) {
|
||||||
await pending.catch(() => null);
|
pending.catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prepareSentence(item);
|
return this.prepareSentence(item);
|
||||||
@@ -882,7 +991,7 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
return String(text).replace(dropCap, '').trimStart();
|
return String(text).replace(dropCap, '').trimStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
measureDropCapReservation(container, dropCapText, lineHeight) {
|
async measureDropCapReservation(container, dropCapText, lineHeight) {
|
||||||
if (!container || !dropCapText) {
|
if (!container || !dropCapText) {
|
||||||
return lineHeight * 1.34;
|
return lineHeight * 1.34;
|
||||||
}
|
}
|
||||||
@@ -905,8 +1014,25 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
probeParagraph.appendChild(probe);
|
probeParagraph.appendChild(probe);
|
||||||
container.appendChild(probeParagraph);
|
container.appendChild(probeParagraph);
|
||||||
|
|
||||||
const rect = probe.getBoundingClientRect();
|
|
||||||
const computed = window.getComputedStyle(probe);
|
const computed = window.getComputedStyle(probe);
|
||||||
|
if (document.fonts && typeof document.fonts.load === 'function') {
|
||||||
|
const fontDescriptor = [
|
||||||
|
computed.fontStyle,
|
||||||
|
computed.fontVariant,
|
||||||
|
computed.fontWeight,
|
||||||
|
computed.fontSize,
|
||||||
|
computed.fontFamily
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
try {
|
||||||
|
await document.fonts.load(fontDescriptor, dropCapText);
|
||||||
|
await document.fonts.ready;
|
||||||
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('SentenceQueue: Drop-cap font load check failed; measuring current font state', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = probe.getBoundingClientRect();
|
||||||
let inkRight = 0;
|
let inkRight = 0;
|
||||||
try {
|
try {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
import { BaseModule } from './base-module.js';
|
import { BaseModule } from './base-module.js';
|
||||||
|
|
||||||
|
const GAME_API_TIMEOUT_MS = 60000;
|
||||||
|
const PLAYER_COMMAND_TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
class SocketClientModule extends BaseModule {
|
class SocketClientModule extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('socket-client', 'Socket Client');
|
super('socket-client', 'Socket Client');
|
||||||
@@ -23,6 +26,10 @@ class SocketClientModule extends BaseModule {
|
|||||||
this.defaultHost = 'localhost:3000';
|
this.defaultHost = 'localhost:3000';
|
||||||
this.receivedBlockCounter = 0;
|
this.receivedBlockCounter = 0;
|
||||||
this.receivedParagraphCounter = 0;
|
this.receivedParagraphCounter = 0;
|
||||||
|
this.pendingCommandTimer = null;
|
||||||
|
this.pendingCommand = null;
|
||||||
|
this.gameApiTimeoutMs = GAME_API_TIMEOUT_MS;
|
||||||
|
this.playerCommandTimeoutMs = PLAYER_COMMAND_TIMEOUT_MS;
|
||||||
|
|
||||||
// Bind methods using parent's bindMethods utility
|
// Bind methods using parent's bindMethods utility
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
@@ -54,6 +61,9 @@ class SocketClientModule extends BaseModule {
|
|||||||
'cueMarkersFromTags',
|
'cueMarkersFromTags',
|
||||||
'dispatchChoices',
|
'dispatchChoices',
|
||||||
'dispatchInputMode',
|
'dispatchInputMode',
|
||||||
|
'handleServerError',
|
||||||
|
'clearPendingCommand',
|
||||||
|
'translate',
|
||||||
'isStructuralTag',
|
'isStructuralTag',
|
||||||
'blocksFromTags',
|
'blocksFromTags',
|
||||||
'enqueueStructuredBlock',
|
'enqueueStructuredBlock',
|
||||||
@@ -191,9 +201,14 @@ class SocketClientModule extends BaseModule {
|
|||||||
|
|
||||||
// Special handling for narrative text
|
// Special handling for narrative text
|
||||||
this.socket.on('narrativeResponse', (data) => {
|
this.socket.on('narrativeResponse', (data) => {
|
||||||
|
this.clearPendingCommand('narrative-response');
|
||||||
this.processTurnResult(data);
|
this.processTurnResult(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.socket.on('error', (error) => {
|
||||||
|
this.handleServerError(error);
|
||||||
|
});
|
||||||
|
|
||||||
this.socket.on('gameConfig', (data) => {
|
this.socket.on('gameConfig', (data) => {
|
||||||
document.dispatchEvent(new CustomEvent('game:config', {
|
document.dispatchEvent(new CustomEvent('game:config', {
|
||||||
detail: data
|
detail: data
|
||||||
@@ -300,6 +315,45 @@ class SocketClientModule extends BaseModule {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleServerError(error) {
|
||||||
|
const message = String(error?.message || error?.error || error || 'The game server reported an error.');
|
||||||
|
console.error('Socket Client: Server error event:', error);
|
||||||
|
this.clearPendingCommand('server-error');
|
||||||
|
document.dispatchEvent(new CustomEvent('story:tag', {
|
||||||
|
detail: {
|
||||||
|
key: 'error',
|
||||||
|
value: message,
|
||||||
|
source: 'server'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||||
|
detail: { state: 'ready', reason: 'server-error', message }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPendingCommand(reason = 'cleared') {
|
||||||
|
if (this.pendingCommandTimer) {
|
||||||
|
clearTimeout(this.pendingCommandTimer);
|
||||||
|
this.pendingCommandTimer = null;
|
||||||
|
}
|
||||||
|
if (this.pendingCommand) {
|
||||||
|
console.log('Socket Client: Command wait cleared', {
|
||||||
|
reason,
|
||||||
|
command: this.pendingCommand
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.pendingCommand = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
translate(key, fallback, params = {}) {
|
||||||
|
const localization = this.getModule('localization');
|
||||||
|
if (localization && typeof localization.translate === 'function') {
|
||||||
|
const translated = localization.translate(key, params);
|
||||||
|
if (translated && translated !== key) return translated;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
processParagraphResult(paragraph, turnId, pendingParagraph = null) {
|
processParagraphResult(paragraph, turnId, pendingParagraph = null) {
|
||||||
const pending = pendingParagraph && typeof pendingParagraph === 'object'
|
const pending = pendingParagraph && typeof pendingParagraph === 'object'
|
||||||
? pendingParagraph
|
? pendingParagraph
|
||||||
@@ -652,7 +706,30 @@ class SocketClientModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
this.clearPendingCommand('new-command');
|
||||||
this.socket.emit('playerCommand', { command });
|
this.socket.emit('playerCommand', { command });
|
||||||
|
this.pendingCommand = command;
|
||||||
|
this.pendingCommandTimer = setTimeout(() => {
|
||||||
|
const timedOutCommand = this.pendingCommand;
|
||||||
|
this.clearPendingCommand('timeout');
|
||||||
|
console.warn('Socket Client: Player command timed out', {
|
||||||
|
timeoutMs: this.playerCommandTimeoutMs,
|
||||||
|
command: timedOutCommand
|
||||||
|
});
|
||||||
|
document.dispatchEvent(new CustomEvent('story:tag', {
|
||||||
|
detail: {
|
||||||
|
key: 'alert',
|
||||||
|
value: this.translate(
|
||||||
|
'popup.commandTimeout',
|
||||||
|
'The game server did not answer in time. You can try again.'
|
||||||
|
),
|
||||||
|
source: 'client-timeout'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||||
|
detail: { state: 'ready', reason: 'command-timeout', command: timedOutCommand }
|
||||||
|
}));
|
||||||
|
}, this.playerCommandTimeoutMs);
|
||||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||||
detail: { state: 'command-waiting', reason: 'command-sent', command }
|
detail: { state: 'command-waiting', reason: 'command-sent', command }
|
||||||
}));
|
}));
|
||||||
@@ -677,8 +754,26 @@ class SocketClientModule extends BaseModule {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.socket.emit('gameApi', { method, args }, (response) => {
|
let settled = false;
|
||||||
|
const finish = (response) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
resolve(response || { success: false, error: 'empty_response' });
|
resolve(response || { success: false, error: 'empty_response' });
|
||||||
|
};
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
console.warn('Socket Client: gameApi call timed out', {
|
||||||
|
method,
|
||||||
|
timeoutMs: this.gameApiTimeoutMs
|
||||||
|
});
|
||||||
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||||
|
detail: { state: 'ready', reason: 'game-api-timeout', method }
|
||||||
|
}));
|
||||||
|
finish({ success: false, error: 'timeout', method });
|
||||||
|
}, this.gameApiTimeoutMs);
|
||||||
|
|
||||||
|
this.socket.emit('gameApi', { method, args }, (response) => {
|
||||||
|
finish(response);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1224,9 +1224,11 @@ class TTSFactoryModule extends BaseModule {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hash = null;
|
||||||
|
let generationStarted = false;
|
||||||
try {
|
try {
|
||||||
// Generate a hash for this speech request
|
// Generate a hash for this speech request
|
||||||
const hash = await this.generateSpeechHash(text);
|
hash = await this.generateSpeechHash(text);
|
||||||
|
|
||||||
// Check if we have this audio in cache
|
// Check if we have this audio in cache
|
||||||
const cachedData = await this.getCachedSpeech(hash);
|
const cachedData = await this.getCachedSpeech(hash);
|
||||||
@@ -1242,6 +1244,7 @@ class TTSFactoryModule extends BaseModule {
|
|||||||
|
|
||||||
// Cache miss - need to generate new speech data
|
// Cache miss - need to generate new speech data
|
||||||
this.cacheMisses++;
|
this.cacheMisses++;
|
||||||
|
generationStarted = true;
|
||||||
this.emitProcessState('waiting-generating', { reason: 'tts-cache-miss', hash });
|
this.emitProcessState('waiting-generating', { reason: 'tts-cache-miss', hash });
|
||||||
|
|
||||||
// If the handler has a preloadSpeech method, use it
|
// If the handler has a preloadSpeech method, use it
|
||||||
@@ -1253,15 +1256,21 @@ class TTSFactoryModule extends BaseModule {
|
|||||||
await this.cacheSpeech(hash, preloadData.audioData, preloadData.duration);
|
await this.cacheSpeech(hash, preloadData.audioData, preloadData.duration);
|
||||||
console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.currentCacheSize}/${this.maxCacheSizeBytes})`);
|
console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.currentCacheSize}/${this.maxCacheSizeBytes})`);
|
||||||
this.emitProcessState('playing-ready', { reason: 'tts-generated', hash });
|
this.emitProcessState('playing-ready', { reason: 'tts-generated', hash });
|
||||||
|
} else if (generationStarted) {
|
||||||
|
this.emitProcessState('playing-ready', { reason: 'tts-generation-unavailable', hash });
|
||||||
}
|
}
|
||||||
|
|
||||||
return preloadData;
|
return preloadData;
|
||||||
} else {
|
} else {
|
||||||
console.warn(`TTS Factory: Handler ${this.activeHandler} does not support preloading`);
|
console.warn(`TTS Factory: Handler ${this.activeHandler} does not support preloading`);
|
||||||
|
this.emitProcessState('playing-ready', { reason: 'tts-preload-unsupported', hash });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("TTS Factory: Error preloading speech:", error);
|
console.error("TTS Factory: Error preloading speech:", error);
|
||||||
|
if (generationStarted || hash) {
|
||||||
|
this.emitProcessState('playing-ready', { reason: 'tts-generation-error', hash, error });
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class UIControllerModule extends BaseModule {
|
|||||||
'hideUI',
|
'hideUI',
|
||||||
'clearDisplay',
|
'clearDisplay',
|
||||||
'sendCommand',
|
'sendCommand',
|
||||||
|
'isInteractiveClickTarget',
|
||||||
'updateButtonStates'
|
'updateButtonStates'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -263,7 +264,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, #story_scrollbar')) {
|
if (this.isInteractiveClickTarget(event.target)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,6 +637,38 @@ class UIControllerModule extends BaseModule {
|
|||||||
console.log('UIController: SentenceQueue pipeline configured');
|
console.log('UIController: SentenceQueue pipeline configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isInteractiveClickTarget(target) {
|
||||||
|
if (!target || typeof target.closest !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(target.closest([
|
||||||
|
'a',
|
||||||
|
'button',
|
||||||
|
'input',
|
||||||
|
'textarea',
|
||||||
|
'select',
|
||||||
|
'label',
|
||||||
|
'[role="button"]',
|
||||||
|
'[role="link"]',
|
||||||
|
'[data-control]',
|
||||||
|
'#controls',
|
||||||
|
'#player_input',
|
||||||
|
'#command_input',
|
||||||
|
'#story_scrollbar',
|
||||||
|
'#story_choices',
|
||||||
|
'#options-modal',
|
||||||
|
'.modal',
|
||||||
|
'.modal-content',
|
||||||
|
'.credits-modal',
|
||||||
|
'.credits-dialog',
|
||||||
|
'.story-popup-modal',
|
||||||
|
'.story-popup-dialog',
|
||||||
|
'.choice-button',
|
||||||
|
'.volume-toggle'
|
||||||
|
].join(',')));
|
||||||
|
}
|
||||||
|
|
||||||
handleCommand(command) {
|
handleCommand(command) {
|
||||||
// Route commands to appropriate handlers
|
// Route commands to appropriate handlers
|
||||||
switch (command.type) {
|
switch (command.type) {
|
||||||
|
|||||||
@@ -1485,7 +1485,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
|||||||
const dropCapText = typeof sentenceQueue.getDropCapText === 'function'
|
const dropCapText = typeof sentenceQueue.getDropCapText === 'function'
|
||||||
? sentenceQueue.getDropCapText(metadata.layoutText || item.text || '')
|
? sentenceQueue.getDropCapText(metadata.layoutText || item.text || '')
|
||||||
: String(metadata.layoutText || item.text || '').trim().charAt(0);
|
: String(metadata.layoutText || item.text || '').trim().charAt(0);
|
||||||
metadata.dropCapWidth = sentenceQueue.measureDropCapReservation(
|
metadata.dropCapWidth = await sentenceQueue.measureDropCapReservation(
|
||||||
this.container || this.paragraphContainer || document.getElementById('story'),
|
this.container || this.paragraphContainer || document.getElementById('story'),
|
||||||
dropCapText,
|
dropCapText,
|
||||||
this.measureStoryLineHeight()
|
this.measureStoryLineHeight()
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class UIInputHandlerModule extends BaseModule {
|
|||||||
'installMouseCursors',
|
'installMouseCursors',
|
||||||
'startMouseCursorAnimation',
|
'startMouseCursorAnimation',
|
||||||
'stopMouseCursorAnimation',
|
'stopMouseCursorAnimation',
|
||||||
|
'normalizeProcessState',
|
||||||
'clearHistory'
|
'clearHistory'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -264,12 +265,14 @@ class UIInputHandlerModule extends BaseModule {
|
|||||||
setProcessState(state, detail = {}) {
|
setProcessState(state, detail = {}) {
|
||||||
const knownStates = [
|
const knownStates = [
|
||||||
'ready',
|
'ready',
|
||||||
|
'paused',
|
||||||
'command-waiting',
|
'command-waiting',
|
||||||
'waiting-generating',
|
'waiting-generating',
|
||||||
'playing-generating',
|
'playing-generating',
|
||||||
'playing-ready'
|
'playing-ready'
|
||||||
];
|
];
|
||||||
const nextState = knownStates.includes(state) ? state : 'ready';
|
const requestedState = knownStates.includes(state) ? state : 'ready';
|
||||||
|
const nextState = this.normalizeProcessState(requestedState);
|
||||||
|
|
||||||
this.applyMouseCursor(nextState);
|
this.applyMouseCursor(nextState);
|
||||||
|
|
||||||
@@ -302,6 +305,21 @@ class UIInputHandlerModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalizeProcessState(state) {
|
||||||
|
const playbackCoordinator = this.getModule('playback-coordinator');
|
||||||
|
const isPlaying = Boolean(playbackCoordinator?.isPlaying);
|
||||||
|
|
||||||
|
if (isPlaying && state === 'ready') {
|
||||||
|
return 'playing-ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlaying && state === 'waiting-generating') {
|
||||||
|
return 'playing-generating';
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
applyTextInputAttributes(playerInput) {
|
applyTextInputAttributes(playerInput) {
|
||||||
if (!playerInput) return;
|
if (!playerInput) return;
|
||||||
|
|
||||||
@@ -358,13 +376,15 @@ class UIInputHandlerModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getMouseCursor(state) {
|
getMouseCursor(state) {
|
||||||
if (state === 'ready') {
|
if (state === 'ready' || state === 'paused') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallback = state === 'command-waiting' || state === 'waiting-generating' ? 'progress' : 'default';
|
if (state === 'command-waiting' || state === 'waiting-generating') {
|
||||||
const usesArrowBase = state === 'command-waiting' || state === 'waiting-generating';
|
return this.buildMouseCursor(state, 'progress', 16, 16, this.cursorAnimationFrame);
|
||||||
return this.buildMouseCursor(state, fallback, usesArrowBase ? 4 : 5, usesArrowBase ? 3 : 24, this.cursorAnimationFrame);
|
}
|
||||||
|
|
||||||
|
return this.buildMouseCursor(state, 'default', 5, 24, this.cursorAnimationFrame);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildMouseCursor(state, fallback = 'default', hotspotX = 12, hotspotY = 12, frame = 0) {
|
buildMouseCursor(state, fallback = 'default', hotspotX = 12, hotspotY = 12, frame = 0) {
|
||||||
@@ -401,27 +421,30 @@ class UIInputHandlerModule extends BaseModule {
|
|||||||
getMouseCursorSvg(state, frame = 0) {
|
getMouseCursorSvg(state, frame = 0) {
|
||||||
const stroke = '#2a1b10';
|
const stroke = '#2a1b10';
|
||||||
const common = `xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none" stroke="${stroke}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"`;
|
const common = `xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none" stroke="${stroke}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"`;
|
||||||
const spinnerSpokes = Array.from({ length: 8 }, (_, index) => {
|
const makeSpinner = (centerX, centerY, innerRadius, outerRadius, strokeWidth = 1.8) => Array.from({ length: 8 }, (_, index) => {
|
||||||
const opacity = 0.25 + (((index + frame) % 8) / 7) * 0.75;
|
const opacity = 0.25 + (((index + frame) % 8) / 7) * 0.75;
|
||||||
const angle = (index * 45) * Math.PI / 180;
|
const angle = (index * 45) * Math.PI / 180;
|
||||||
const x1 = 24 + Math.cos(angle) * 3;
|
const x1 = centerX + Math.cos(angle) * innerRadius;
|
||||||
const y1 = 24 + Math.sin(angle) * 3;
|
const y1 = centerY + Math.sin(angle) * innerRadius;
|
||||||
const x2 = 24 + Math.cos(angle) * 5;
|
const x2 = centerX + Math.cos(angle) * outerRadius;
|
||||||
const y2 = 24 + Math.sin(angle) * 5;
|
const y2 = centerY + Math.sin(angle) * outerRadius;
|
||||||
return `<path opacity="${opacity.toFixed(2)}" d="M${x1.toFixed(2)} ${y1.toFixed(2)} ${x2.toFixed(2)} ${y2.toFixed(2)}"/>`;
|
return `<path opacity="${opacity.toFixed(2)}" stroke-width="${strokeWidth}" d="M${x1.toFixed(2)} ${y1.toFixed(2)} ${x2.toFixed(2)} ${y2.toFixed(2)}"/>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
const spinnerSpokes = makeSpinner(24, 24, 3, 5);
|
||||||
|
const largeSpinnerSpokes = makeSpinner(16, 16, 5.2, 10.2, 2.2);
|
||||||
const arrow = '<path fill="#f6efe2" d="M4 3l9 21 2.4-8.5L24 13z"/><path d="M15.4 15.5 21 21"/>';
|
const arrow = '<path fill="#f6efe2" d="M4 3l9 21 2.4-8.5L24 13z"/><path d="M15.4 15.5 21 21"/>';
|
||||||
const pointer = '<path fill="#f6efe2" d="M9 14V5a2.2 2.2 0 0 1 4.4 0v7.5"/><path d="M13.4 12V8.5a2.1 2.1 0 0 1 4.2 0v5"/><path d="M17.6 14v-1.7a2.1 2.1 0 0 1 4.2 0v4.2A8.1 8.1 0 0 1 13.7 24h-1.2a6.8 6.8 0 0 1-5.5-3L3.6 15a2.1 2.1 0 0 1 3.4-2.4l2 2.5"/>';
|
const pointer = '<path fill="#f6efe2" d="M9 14.8V5a2.2 2.2 0 0 1 4.4 0v6.8V8.5a2.1 2.1 0 0 1 4.2 0V13v-.7a2.1 2.1 0 0 1 4.2 0v4.2A8.1 8.1 0 0 1 13.7 24h-1.2a6.8 6.8 0 0 1-5.5-3L3.6 15a2.1 2.1 0 0 1 3.4-2.4L9 14.8z"/><path d="M9 14.8V5a2.2 2.2 0 0 1 4.4 0v7.5"/><path d="M13.4 12V8.5a2.1 2.1 0 0 1 4.2 0v5"/><path d="M17.6 14v-1.7a2.1 2.1 0 0 1 4.2 0v4.2A8.1 8.1 0 0 1 13.7 24h-1.2a6.8 6.8 0 0 1-5.5-3L3.6 15a2.1 2.1 0 0 1 3.4-2.4l2 2.5"/>';
|
||||||
const feather = '<path fill="#f6efe2" d="M5 26c5.8-1.7 12.5-7.9 18.4-20.7 2.3 7.6-.2 16.1-11.8 19.9"/><path d="M5 26c4.8-4.5 8.7-9.2 13-15"/><path d="M12 25.2 5 26"/>';
|
const feather = '<path fill="#f6efe2" d="M5 26c5.8-1.7 12.5-7.9 18.4-20.7 2.3 7.6-.2 16.1-11.8 19.9"/><path d="M5 26c4.8-4.5 8.7-9.2 13-15"/><path d="M12 25.2 5 26"/>';
|
||||||
const speaker = '<g transform="translate(20 2) scale(.48)"><path fill="#f6efe2" d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/></g>';
|
const speaker = '<g transform="translate(20 2) scale(.48)"><path fill="#f6efe2" d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/></g>';
|
||||||
const spinner = `<g>${spinnerSpokes}</g>`;
|
const spinner = `<g>${spinnerSpokes}</g>`;
|
||||||
|
const largeSpinner = `<g>${largeSpinnerSpokes}</g>`;
|
||||||
const hourglassSand = frame % 4 < 2 ? '<path d="M5.7 4.7h4.6"/><path d="M6.7 7h2.6"/>' : '<path d="M6.7 12h2.6"/><path d="M5.7 14.3h4.6"/>';
|
const hourglassSand = frame % 4 < 2 ? '<path d="M5.7 4.7h4.6"/><path d="M6.7 7h2.6"/>' : '<path d="M6.7 12h2.6"/><path d="M5.7 14.3h4.6"/>';
|
||||||
const hourglass = `<g transform="translate(1 17) scale(.82)"><path fill="#f6efe2" d="M4 2h8M4 16h8M10.8 2v3.1a2 2 0 0 1-.6 1.4L8 8.5l-2.2-2A2 2 0 0 1 5.2 5.1V2M5.2 16v-3.1a2 2 0 0 1 .6-1.4L8 9.5l2.2 2a2 2 0 0 1 .6 1.4V16"/>${hourglassSand}</g>`;
|
const hourglass = `<g transform="translate(1 17) scale(.82)"><path fill="#f6efe2" d="M4 2h8M4 16h8M10.8 2v3.1a2 2 0 0 1-.6 1.4L8 8.5l-2.2-2A2 2 0 0 1 5.2 5.1V2M5.2 16v-3.1a2 2 0 0 1 .6-1.4L8 9.5l2.2 2a2 2 0 0 1 .6 1.4V16"/>${hourglassSand}</g>`;
|
||||||
const icons = {
|
const icons = {
|
||||||
'default': `<svg ${common}>${arrow}</svg>`,
|
'default': `<svg ${common}>${arrow}</svg>`,
|
||||||
'pointer': `<svg ${common}>${pointer}</svg>`,
|
'pointer': `<svg ${common}>${pointer}</svg>`,
|
||||||
'command-waiting': `<svg ${common}>${arrow}${hourglass}</svg>`,
|
'command-waiting': `<svg ${common}>${largeSpinner}${hourglass}</svg>`,
|
||||||
'waiting-generating': `<svg ${common}>${arrow}${spinner}</svg>`,
|
'waiting-generating': `<svg ${common}>${largeSpinner}</svg>`,
|
||||||
'playing-generating': `<svg ${common}>${feather}${speaker}${spinner}</svg>`,
|
'playing-generating': `<svg ${common}>${feather}${speaker}${spinner}</svg>`,
|
||||||
'playing-ready': `<svg ${common}>${feather}${speaker}</svg>`
|
'playing-ready': `<svg ${common}>${feather}${speaker}</svg>`
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,5 +63,6 @@
|
|||||||
"popup.defaultEnding": "Du hast ein Ende erreicht.",
|
"popup.defaultEnding": "Du hast ein Ende erreicht.",
|
||||||
"popup.defaultError": "Das Spiel wurde wegen eines nicht behebbaren Fehlers beendet.",
|
"popup.defaultError": "Das Spiel wurde wegen eines nicht behebbaren Fehlers beendet.",
|
||||||
"popup.defaultAchievement": "Errungenschaft freigeschaltet.",
|
"popup.defaultAchievement": "Errungenschaft freigeschaltet.",
|
||||||
"popup.defaultAlert": "Hinweis"
|
"popup.defaultAlert": "Hinweis",
|
||||||
|
"popup.commandTimeout": "Der Spielserver hat nicht rechtzeitig geantwortet. Du kannst es noch einmal versuchen."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,5 +63,6 @@
|
|||||||
"popup.defaultEnding": "You reached an ending.",
|
"popup.defaultEnding": "You reached an ending.",
|
||||||
"popup.defaultError": "The game ended because of an unrecoverable error.",
|
"popup.defaultError": "The game ended because of an unrecoverable error.",
|
||||||
"popup.defaultAchievement": "Achievement unlocked.",
|
"popup.defaultAchievement": "Achievement unlocked.",
|
||||||
"popup.defaultAlert": "Hint"
|
"popup.defaultAlert": "Hint",
|
||||||
|
"popup.commandTimeout": "The game server did not answer in time. You can try again."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user