Stabilize playback state and cursor feedback

This commit is contained in:
2026-05-18 20:57:20 +02:00
parent 6e908037fb
commit 751ac5f62b
13 changed files with 580 additions and 82 deletions
+31
View File
@@ -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
View File
@@ -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;
+45 -6
View File
@@ -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
View File
@@ -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;
});
} }
/** /**
+7
View File
@@ -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
+140 -14
View File
@@ -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');
+96 -1
View File
@@ -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);
}); });
}); });
} }
+10 -1
View File
@@ -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;
} }
} }
+34 -1
View File
@@ -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) {
+1 -1
View File
@@ -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()
+37 -14
View File
@@ -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>`
}; };
+2 -1
View File
@@ -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."
} }
+2 -1
View File
@@ -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."
} }