Document markup and improve choice tags

This commit is contained in:
2026-05-17 15:52:41 +02:00
parent c2fb27b6b8
commit 2c54498ee2
52 changed files with 3485 additions and 377 deletions
+14 -11
View File
@@ -9,7 +9,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
super(id, name);
// Declare proper dependencies according to architecture principles
this.dependencies = ['persistence-manager', 'localization'];
this.dependencies = ['persistence-manager', 'localization', 'game-config'];
// Basic voice options
this.voiceOptions = {
@@ -86,7 +86,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
return;
}
if (['masterVolume', 'ttsVolume', 'master_volume', 'tts_volume'].includes(key)) {
if (['masterVolume', 'ttsVolume', 'masterVolumeEnabled', 'ttsVolumeEnabled', 'master_volume', 'tts_volume'].includes(key)) {
this.applyCurrentVolume();
}
});
@@ -129,9 +129,9 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
*/
async setupVoiceFromPreferences() {
const persistenceManager = this.getModule('persistence-manager');
const localization = this.getModule('localization');
const gameConfig = this.getModule('game-config');
if (!persistenceManager || !localization) {
if (!persistenceManager) {
console.error(`${this.name}: Required dependencies not found`);
return false;
}
@@ -140,7 +140,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
const preferredVoiceId = persistenceManager.getPreference('tts', `${this.id}_voice`, '');
// Get current locale
const currentLocale = localization.getLocale();
const currentLocale = gameConfig?.getLocale?.() || 'en_US';
// If we have a preferred voice ID, use it
if (preferredVoiceId && this.voices.some(v => v.id === preferredVoiceId)) {
@@ -194,7 +194,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
* @param {string} text - The text to synthesize.
* @returns {Promise<Object>} - A promise that resolves with the audio data object.
*/
async generateSpeechAudio(text) {
async generateSpeechAudio(text, options = {}) {
// To be implemented by subclasses
return { success: false, reason: 'not_implemented' };
}
@@ -206,10 +206,11 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
* @returns {Promise<Object>} - Resolves when audio finishes playing
*/
async speakPreloaded(preloadData, callback = null) {
const completionCallback = typeof callback === 'function' ? callback : null;
if (!preloadData || !preloadData.audioData) {
console.error(`${this.name}: Invalid preloaded data`);
const result = { success: false, reason: 'invalid_data' };
if (callback) callback(result);
if (completionCallback) completionCallback(result);
return result;
}
@@ -242,7 +243,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
}
URL.revokeObjectURL(audioUrl);
if (callback) callback(result);
if (completionCallback) completionCallback(result);
resolve(result);
};
this.currentPlaybackFinish = finish;
@@ -284,13 +285,15 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
'masterVolume',
persistenceManager.getPreference('audio', 'master_volume', 1.0)
);
const masterEnabled = persistenceManager.getPreference('audio', 'masterVolumeEnabled', true) !== false;
const ttsVolume = persistenceManager.getPreference(
'audio',
'ttsVolume',
persistenceManager.getPreference('audio', 'tts_volume', 1.0)
);
const ttsEnabled = persistenceManager.getPreference('audio', 'ttsVolumeEnabled', true) !== false;
return Math.max(0, Math.min(1, masterVolume * ttsVolume));
return Math.max(0, Math.min(1, (masterEnabled ? masterVolume : 0) * (ttsEnabled ? ttsVolume : 0)));
}
/**
@@ -418,14 +421,14 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
* @param {string} text - Text to preload
* @returns {Promise<Object>} - Preloaded speech data
*/
async preloadSpeech(text) {
async preloadSpeech(text, options = {}) {
if (!this.isReady) {
return { success: false, reason: 'not_ready' };
}
try {
// Generate speech
const result = await this.generateSpeechAudio(text);
const result = await this.generateSpeechAudio(text, options);
if (!result.success) {
return { success: false, reason: 'generation_failed' };
+78 -7
View File
@@ -10,6 +10,7 @@ class AudioManagerModule extends BaseModule {
this.sounds = new Map();
this.sfxCache = new Map();
this.currentAudio = null;
this.currentAudioRole = null;
this.currentLoop = null;
this.currentMusic = null;
this.queuedMusic = null;
@@ -17,6 +18,12 @@ class AudioManagerModule extends BaseModule {
this.musicVolume = 1.0;
this.sfxVolume = 1.0;
this.ttsVolume = 1.0;
this.masterVolumeEnabled = true;
this.musicVolumeEnabled = true;
this.sfxVolumeEnabled = true;
this.ttsVolumeEnabled = true;
this.musicDuckingAmount = 0.3;
this.musicDuckingEnabled = true;
this.musicDuckingFactor = 1.0;
this.musicFadeToken = 0;
this.activeTtsPlaybackCount = 0;
@@ -79,6 +86,12 @@ class AudioManagerModule extends BaseModule {
this.musicVolume = this.clampVolume(persistenceManager.getPreference('audio', 'musicVolume', this.musicVolume));
this.sfxVolume = this.clampVolume(persistenceManager.getPreference('audio', 'sfxVolume', this.sfxVolume));
this.ttsVolume = this.clampVolume(persistenceManager.getPreference('audio', 'ttsVolume', this.ttsVolume));
this.masterVolumeEnabled = persistenceManager.getPreference('audio', 'masterVolumeEnabled', this.masterVolumeEnabled) !== false;
this.musicVolumeEnabled = persistenceManager.getPreference('audio', 'musicVolumeEnabled', this.musicVolumeEnabled) !== false;
this.sfxVolumeEnabled = persistenceManager.getPreference('audio', 'sfxVolumeEnabled', this.sfxVolumeEnabled) !== false;
this.ttsVolumeEnabled = persistenceManager.getPreference('audio', 'ttsVolumeEnabled', this.ttsVolumeEnabled) !== false;
this.musicDuckingAmount = this.clampVolume(persistenceManager.getPreference('audio', 'musicDuckingAmount', this.musicDuckingAmount));
this.musicDuckingEnabled = persistenceManager.getPreference('audio', 'musicDuckingEnabled', this.musicDuckingEnabled) !== false;
}
setupEventListeners() {
@@ -108,6 +121,12 @@ class AudioManagerModule extends BaseModule {
if (key === 'musicVolume') this.setMusicVolume(value);
if (key === 'sfxVolume') this.setSfxVolume(value);
if (key === 'ttsVolume') this.setTtsVolume(value);
if (key === 'masterVolumeEnabled') this.setVolumeEnabled('master', value);
if (key === 'musicVolumeEnabled') this.setVolumeEnabled('music', value);
if (key === 'sfxVolumeEnabled') this.setVolumeEnabled('sfx', value);
if (key === 'ttsVolumeEnabled') this.setVolumeEnabled('tts', value);
if (key === 'musicDuckingAmount') this.setMusicDuckingAmount(value);
if (key === 'musicDuckingEnabled') this.setMusicDuckingEnabled(value);
});
this.addEventListener(document, 'tts:playback-start', () => {
@@ -204,6 +223,7 @@ class AudioManagerModule extends BaseModule {
this.currentLoop = audio;
} else {
this.currentAudio = audio.cloneNode(true);
this.currentAudioRole = 'sfx';
this.setMediaVolume(this.currentAudio, this.getSfxVolume());
this.currentAudio.play().catch(error => {
console.error('Error playing audio:', error);
@@ -241,6 +261,7 @@ class AudioManagerModule extends BaseModule {
return this.currentLoop;
} else {
this.currentAudio = new Audio(url);
this.currentAudioRole = 'sfx';
this.setMediaVolume(this.currentAudio, this.getSfxVolume());
this.currentAudio.play().catch(error => {
console.error('Error playing audio:', error);
@@ -269,6 +290,7 @@ class AudioManagerModule extends BaseModule {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
this.currentAudio = null;
this.currentAudioRole = null;
}
if (this.currentLoop) {
@@ -306,6 +328,7 @@ class AudioManagerModule extends BaseModule {
*/
setTtsVolume(volume) {
this.ttsVolume = this.clampVolume(volume);
this.updateVolumes();
}
/**
@@ -325,6 +348,33 @@ class AudioManagerModule extends BaseModule {
this.sfxVolume = this.clampVolume(volume);
this.updateVolumes();
}
setVolumeEnabled(kind, enabled) {
const value = enabled !== false;
if (kind === 'master') this.masterVolumeEnabled = value;
if (kind === 'music') this.musicVolumeEnabled = value;
if (kind === 'sfx') this.sfxVolumeEnabled = value;
if (kind === 'tts') this.ttsVolumeEnabled = value;
this.updateVolumes();
}
setMusicDuckingAmount(amount) {
this.musicDuckingAmount = this.clampVolume(amount);
if (this.musicDuckingFactor !== 1.0) {
this.duckMusicForSpeech();
} else {
this.updateVolumes();
}
}
setMusicDuckingEnabled(enabled) {
this.musicDuckingEnabled = enabled !== false;
if (this.musicDuckingFactor !== 1.0) {
this.duckMusicForSpeech();
} else {
this.updateVolumes();
}
}
/**
* Update all volume levels based on current settings
@@ -332,11 +382,11 @@ class AudioManagerModule extends BaseModule {
updateVolumes() {
this.sounds.forEach(audio => {
const isMusic = audio.loop;
this.setMediaVolume(audio, this.masterVolume * (isMusic ? this.musicVolume : this.sfxVolume));
this.setMediaVolume(audio, isMusic ? this.getMusicVolume() : this.getSfxVolume());
});
if (this.currentAudio) {
this.setMediaVolume(this.currentAudio, this.masterVolume * this.sfxVolume);
this.setMediaVolume(this.currentAudio, this.currentAudioRole === 'tts' ? this.getTtsVolume() : this.getSfxVolume());
}
if (this.currentLoop) {
@@ -358,20 +408,29 @@ class AudioManagerModule extends BaseModule {
}
getSfxVolume() {
return this.masterVolume * this.sfxVolume;
return this.getMasterVolume() * (this.sfxVolumeEnabled ? this.sfxVolume : 0);
}
getMusicVolume() {
return this.masterVolume * this.musicVolume * this.musicDuckingFactor;
return this.getUnduckedMusicVolume() * this.musicDuckingFactor;
}
getUnduckedMusicVolume() {
return this.masterVolume * this.musicVolume;
return this.getMasterVolume() * (this.musicVolumeEnabled ? this.musicVolume : 0);
}
getMasterVolume() {
return this.masterVolumeEnabled ? this.masterVolume : 0;
}
getTtsVolume() {
return this.getMasterVolume() * (this.ttsVolumeEnabled ? this.ttsVolume : 0);
}
duckMusicForSpeech() {
console.log('AudioManager: Ducking music for TTS playback');
this.fadeMusicTo(0.3, 500);
const factor = this.musicDuckingEnabled ? 1 - this.musicDuckingAmount : 1;
this.fadeMusicTo(factor, 500);
}
restoreMusicAfterSpeech() {
@@ -564,6 +623,7 @@ class AudioManagerModule extends BaseModule {
const audio = template.cloneNode(true);
this.setMediaVolume(audio, this.getSfxVolume());
this.currentAudio = audio;
this.currentAudioRole = 'sfx';
const maxDuration = Math.max(0, Number(options.maxDurationSeconds || options.maxDuration || 0)) * 1000;
const endMode = String(options.endMode || options.mode || 'stop').toLowerCase().startsWith('fade') ? 'fade' : 'stop';
const fadeDuration = Math.max(100, Number(options.fadeDurationSeconds || options.fadeDuration || 2) * 1000);
@@ -572,6 +632,7 @@ class AudioManagerModule extends BaseModule {
if (maxTimer) clearTimeout(maxTimer);
if (this.currentAudio === audio) {
this.currentAudio = null;
this.currentAudioRole = null;
}
}, { once: true });
await audio.play();
@@ -588,6 +649,7 @@ class AudioManagerModule extends BaseModule {
audio.pause();
audio.currentTime = 0;
if (this.currentAudio === audio) this.currentAudio = null;
if (this.currentAudio === null) this.currentAudioRole = null;
}
}, timeoutDuration);
}
@@ -614,6 +676,7 @@ class AudioManagerModule extends BaseModule {
audio.pause();
audio.currentTime = 0;
if (this.currentAudio === audio) this.currentAudio = null;
if (this.currentAudio === null) this.currentAudioRole = null;
resolve(true);
};
requestAnimationFrame(step);
@@ -832,6 +895,10 @@ class AudioManagerModule extends BaseModule {
audio.pause();
audio.currentTime = 0;
this.setMediaVolume(audio, initialVolume); // Reset volume for future use
if (this.currentAudio === audio) {
this.currentAudio = null;
this.currentAudioRole = null;
}
resolve();
} else {
this.setMediaVolume(audio, currentVolume);
@@ -860,6 +927,7 @@ class AudioManagerModule extends BaseModule {
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
this.currentAudioRole = null;
}
// Create new audio element
@@ -880,13 +948,14 @@ class AudioManagerModule extends BaseModule {
}
// Apply master volume and speech volume
this.setMediaVolume(audio, this.masterVolume * speechVolume * this.ttsVolume);
this.setMediaVolume(audio, speechVolume * this.getTtsVolume());
// Set up cleanup
audio.onended = () => {
URL.revokeObjectURL(audioUrl);
if (this.currentAudio === audio) {
this.currentAudio = null;
this.currentAudioRole = null;
}
if (options.onComplete && typeof options.onComplete === 'function') {
options.onComplete();
@@ -902,6 +971,7 @@ class AudioManagerModule extends BaseModule {
URL.revokeObjectURL(audioUrl);
if (this.currentAudio === audio) {
this.currentAudio = null;
this.currentAudioRole = null;
}
if (options.onError && typeof options.onError === 'function') {
options.onError(error);
@@ -915,6 +985,7 @@ class AudioManagerModule extends BaseModule {
// Store as current audio
this.currentAudio = audio;
this.currentAudioRole = 'tts';
// Play the audio
await audio.play();
+31 -9
View File
@@ -9,7 +9,7 @@ export class BrowserTTSModule extends TTSHandlerModule {
super('browser-tts', 'Browser TTS');
// Declare proper dependencies according to architecture principles
this.dependencies = ['persistence-manager', 'localization'];
this.dependencies = ['persistence-manager', 'localization', 'game-config'];
// Voice options
this.voiceOptions = {
@@ -57,6 +57,13 @@ export class BrowserTTSModule extends TTSHandlerModule {
console.error('Browser TTS: Localization dependency not found');
return false;
}
this.addEventListener(document, 'preference-updated', (event) => {
const { category, key } = event.detail || {};
if (category === 'audio' && ['masterVolume', 'ttsVolume', 'masterVolumeEnabled', 'ttsVolumeEnabled'].includes(key) && this.currentUtterance) {
this.currentUtterance.volume = this.getPlaybackVolume();
}
});
// Check if browser supports speech synthesis
if (!window.speechSynthesis) {
@@ -163,9 +170,9 @@ export class BrowserTTSModule extends TTSHandlerModule {
*/
async setupVoiceFromPreferences() {
const persistenceManager = this.getModule('persistence-manager');
const localization = this.getModule('localization');
const gameConfig = this.getModule('game-config');
if (!persistenceManager || !localization || this.voices.length === 0) {
if (!persistenceManager || this.voices.length === 0) {
return false;
}
@@ -173,7 +180,7 @@ export class BrowserTTSModule extends TTSHandlerModule {
const preferredVoiceId = persistenceManager.getPreference('tts', 'browser_voice', '');
// Get current locale
const currentLocale = localization.getLocale();
const currentLocale = gameConfig?.getLocale?.() || 'en_US';
// If we have a preferred voice ID, use it
if (preferredVoiceId && this.voices.some(v => v.id === preferredVoiceId)) {
@@ -200,11 +207,11 @@ export class BrowserTTSModule extends TTSHandlerModule {
return this.selectDefaultVoice();
}
// Extract language code from locale (e.g., 'en-US' -> 'en')
const langCode = locale.split('-')[0].toLowerCase();
const normalizedLocale = String(locale).replace('_', '-').toLowerCase();
const langCode = normalizedLocale.split('-')[0];
// First try to find a voice that exactly matches the locale
let matchedVoice = this.voices.find(v => v.language && v.language.toLowerCase() === locale.toLowerCase());
let matchedVoice = this.voices.find(v => v.language && v.language.toLowerCase() === normalizedLocale);
// If not found, try to find a voice for the language
if (!matchedVoice && this.voicesByLang[langCode]) {
@@ -220,6 +227,21 @@ export class BrowserTTSModule extends TTSHandlerModule {
// Fall back to default voice
return this.selectDefaultVoice();
}
getPlaybackVolume() {
const persistenceManager = this.getModule('persistence-manager');
if (!persistenceManager) {
return this.voiceOptions.volume || 1.0;
}
const masterVolume = persistenceManager.getPreference('audio', 'masterVolume', 1.0);
const ttsVolume = persistenceManager.getPreference('audio', 'ttsVolume', 1.0);
const masterEnabled = persistenceManager.getPreference('audio', 'masterVolumeEnabled', true) !== false;
const ttsEnabled = persistenceManager.getPreference('audio', 'ttsVolumeEnabled', true) !== false;
const configuredVolume = this.voiceOptions.volume || 1.0;
return Math.max(0, Math.min(1, configuredVolume * (masterEnabled ? masterVolume : 0) * (ttsEnabled ? ttsVolume : 0)));
}
/**
* Select a default voice
@@ -342,7 +364,7 @@ export class BrowserTTSModule extends TTSHandlerModule {
utterance.rate = this.voiceOptions.speed || 1.0;
utterance.pitch = this.voiceOptions.pitch || 1.0;
utterance.volume = this.voiceOptions.volume || 1.0;
utterance.volume = this.getPlaybackVolume();
// Set up event handlers
utterance.onstart = this.utteranceHandlers.start;
@@ -484,7 +506,7 @@ export class BrowserTTSModule extends TTSHandlerModule {
* @returns {boolean} - Success status (always false)
*/
speakPreloaded(preloadData, callback = null) {
if (callback) {
if (typeof callback === 'function') {
callback({ success: false, reason: 'not_supported' });
}
return false;
+31 -6
View File
@@ -8,8 +8,9 @@ class ChoiceDisplayModule extends BaseModule {
constructor() {
super('choice-display', 'Choice Display');
this.dependencies = ['socket-client'];
this.dependencies = ['socket-client', 'markup-parser'];
this.socketClient = null;
this.markupParser = null;
this.container = null;
this.choices = [];
this.inputMode = 'text';
@@ -37,12 +38,14 @@ class ChoiceDisplayModule extends BaseModule {
'assignLetters',
'selectChoice',
'getTagValue',
'getTemplateCell'
'getTemplateCell',
'renderChoiceText'
]);
}
async initialize() {
this.socketClient = this.getModule('socket-client');
this.markupParser = this.getModule('markup-parser');
this.setupContainer();
this.addEventListener(document, 'story:choices', (event) => {
@@ -157,9 +160,14 @@ class ChoiceDisplayModule extends BaseModule {
.trim()
.charAt(0)
.toUpperCase();
if (alphabet.includes(explicit) && !reserved.has(explicit)) {
choice.letter = explicit;
reserved.add(explicit);
const keyExplicit = String(choice.letter || this.getTagValue(choice.tags, 'key') || '')
.trim()
.charAt(0)
.toUpperCase();
const reservedLetter = explicit || keyExplicit;
if (alphabet.includes(reservedLetter) && !reserved.has(reservedLetter)) {
choice.letter = reservedLetter;
reserved.add(reservedLetter);
}
});
@@ -225,7 +233,7 @@ class ChoiceDisplayModule extends BaseModule {
const button = document.createElement('button');
button.type = 'button';
button.className = 'choice-button';
button.innerHTML = `<kbd>${choice.letter}</kbd><span>${this.escapeHtml(choice.text)}</span>`;
button.innerHTML = `<kbd>${this.escapeHtml(choice.letter)}</kbd><span>${this.renderChoiceText(choice.text)}</span>`;
button.addEventListener('click', () => this.selectChoice(choice.index));
item.appendChild(button);
list.appendChild(item);
@@ -265,6 +273,23 @@ class ChoiceDisplayModule extends BaseModule {
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
renderChoiceText(text) {
if (!this.markupParser) {
this.markupParser = this.getModule('markup-parser');
}
if (this.markupParser && typeof this.markupParser.markdownToHtml === 'function') {
return this.markupParser.markdownToHtml(String(text || ''));
}
return this.escapeHtml(text)
.replace(/\*\*\*([^*]+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/___([^_]+?)___/g, '<strong><em>$1</em></strong>')
.replace(/\*\*([^*]+?)\*\*/g, '<strong>$1</strong>')
.replace(/__([^_]+?)__/g, '<strong>$1</strong>')
.replace(/\*([^*\s][^*]*?)\*/g, '<em>$1</em>')
.replace(/_([^_\s][^_]*?)_/g, '<em>$1</em>');
}
}
const choiceDisplay = new ChoiceDisplayModule();
+3 -2
View File
@@ -177,7 +177,7 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
* @param {string} text - Text to generate speech for
* @returns {Promise<Object>} - Audio data object
*/
async generateSpeechAudio(text) {
async generateSpeechAudio(text, options = {}) {
// Don't attempt to call the API if no API key is set or text is empty
if (!text || !this.apiKey) {
return { success: false, reason: 'missing_api_key_or_text' };
@@ -208,7 +208,8 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
'xi-api-key': this.apiKey,
'Accept': 'audio/mpeg'
},
body: JSON.stringify(payload)
body: JSON.stringify(payload),
signal: options.signal
});
if (!response.ok) {
+1 -6
View File
@@ -24,11 +24,6 @@ class GameConfigModule extends BaseModule {
this.reportProgress(20, 'Loading game configuration');
this.config = await this.loadConfig();
const localization = this.getModule('localization');
if (localization && this.config?.locale) {
await localization.applyServerLocale(this.config.locale);
}
this.applyDocumentMetadata();
document.dispatchEvent(new CustomEvent('game:config', {
detail: this.config
@@ -88,7 +83,7 @@ class GameConfigModule extends BaseModule {
}
getLocale() {
return this.config?.locale || 'en_US';
return this.config?.metadata?.language || this.config?.locale || 'en_US';
}
}
+35 -2
View File
@@ -14,6 +14,8 @@ class GameLoopModule extends BaseModule {
// Game state
this.gameState = {
started: false,
startedOnce: false,
ended: false,
canLoad: false,
currentRoom: null,
inventory: [],
@@ -71,6 +73,15 @@ class GameLoopModule extends BaseModule {
document.addEventListener('ui:game:restart', () => this.requestStartGame());
document.addEventListener('ui:game:save', () => this.requestSaveGame());
document.addEventListener('ui:game:load', () => this.requestLoadGame());
document.addEventListener('story:input-mode', (event) => {
if (event.detail !== 'end') {
return;
}
this.gameState.started = false;
this.gameState.ended = true;
this.gameState.canSave = false;
this.updateUIState();
});
}
setupSocketEventListeners() {
@@ -142,6 +153,10 @@ class GameLoopModule extends BaseModule {
]);
this.gameState.started = Boolean(running?.result);
if (this.gameState.started) {
this.gameState.startedOnce = true;
this.gameState.ended = false;
}
this.gameState.canSave = this.gameState.started;
this.gameState.canLoad = Boolean(hasSave?.result);
this.updateUIState();
@@ -177,9 +192,9 @@ class GameLoopModule extends BaseModule {
// Update UI components based on game state
const state = {
canRestart: true,
canSave: Boolean(this.gameState.started),
canSave: Boolean(this.gameState.canSave && this.gameState.started),
canLoad: Boolean(this.gameState.canLoad),
gameStarted: Boolean(this.gameState.started)
gameStarted: Boolean(this.gameState.started || this.gameState.startedOnce || this.gameState.ended)
};
document.body.dataset.gameRunning = state.gameStarted ? 'true' : 'false';
uiController.updateButtonStates(state);
@@ -192,6 +207,11 @@ class GameLoopModule extends BaseModule {
const socketClient = this.getModule('socket-client');
if (!socketClient) return;
this.gameState.started = true;
this.gameState.startedOnce = true;
this.gameState.ended = false;
this.gameState.canSave = true;
this.updateUIState();
await this.resetClientPlaybackAndDisplay();
const storyHistory = this.getModule('story-history');
if (storyHistory && typeof storyHistory.startNewGame === 'function') {
@@ -200,9 +220,14 @@ class GameLoopModule extends BaseModule {
const response = await socketClient.newGame();
if (!response?.success) {
console.error('GameLoop: newGame failed', response);
this.gameState.started = false;
this.gameState.canSave = false;
this.updateUIState();
return;
}
this.gameState.started = true;
this.gameState.startedOnce = true;
this.gameState.ended = false;
this.gameState.canSave = true;
this.gameState.canLoad = Boolean(response.canLoad);
this.updateUIState();
@@ -250,6 +275,12 @@ class GameLoopModule extends BaseModule {
return;
}
this.gameState.started = true;
this.gameState.startedOnce = true;
this.gameState.ended = false;
this.gameState.canSave = true;
this.gameState.canLoad = true;
this.updateUIState();
await this.resetClientPlaybackAndDisplay();
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: true, reason: 'load-game' }
@@ -285,6 +316,8 @@ class GameLoopModule extends BaseModule {
}
if (response?.success) {
this.gameState.started = true;
this.gameState.startedOnce = true;
this.gameState.ended = false;
this.gameState.canSave = true;
this.gameState.canLoad = true;
this.updateUIState();
+35 -13
View File
@@ -9,7 +9,7 @@ export class KokoroTTSModule extends TTSHandlerModule {
super('kokoro-tts', 'Kokoro TTS');
// Declare proper dependencies according to architecture principles
this.dependencies = ['persistence-manager', 'localization'];
this.dependencies = ['persistence-manager', 'localization', 'game-config'];
// State
this.iframe = null;
@@ -59,6 +59,13 @@ export class KokoroTTSModule extends TTSHandlerModule {
return false;
}
this.addEventListener(document, 'preference-updated', (event) => {
const { category, key } = event.detail || {};
if (category === 'audio' && ['masterVolume', 'ttsVolume', 'masterVolumeEnabled', 'ttsVolumeEnabled'].includes(key) && this.currentAudio) {
this.currentAudio.volume = this.getPlaybackVolume();
}
});
const ttsEnabled = persistenceManager.getPreference('tts', 'enabled', false);
const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler', 'none');
if (!ttsEnabled || preferredHandler !== this.id) {
@@ -256,8 +263,8 @@ export class KokoroTTSModule extends TTSHandlerModule {
}
// Get current locale
const localization = this.getModule('localization');
const locale = localization ? localization.getLocale() : null;
const gameConfig = this.getModule('game-config');
const locale = gameConfig?.getLocale?.() || 'en_US';
// Get preferred voice from preferences
const preferredVoiceId = persistenceManager.getPreference('tts', 'kokoro_voice', '');
@@ -367,6 +374,20 @@ export class KokoroTTSModule extends TTSHandlerModule {
this.setOptions({ volume: Math.max(0, Math.min(1, options.volume)) });
}
}
getPlaybackVolume() {
const persistenceManager = this.getModule('persistence-manager');
if (!persistenceManager) {
return this.options.volume;
}
const masterVolume = persistenceManager.getPreference('audio', 'masterVolume', 1.0);
const ttsVolume = persistenceManager.getPreference('audio', 'ttsVolume', 1.0);
const masterEnabled = persistenceManager.getPreference('audio', 'masterVolumeEnabled', true) !== false;
const ttsEnabled = persistenceManager.getPreference('audio', 'ttsVolumeEnabled', true) !== false;
return Math.max(0, Math.min(1, this.options.volume * (masterEnabled ? masterVolume : 0) * (ttsEnabled ? ttsVolume : 0)));
}
/**
* Get available voices
@@ -431,9 +452,10 @@ export class KokoroTTSModule extends TTSHandlerModule {
* @returns {boolean} - Success status
*/
speakPreloaded(preloadData, callback = null) {
const completionCallback = typeof callback === 'function' ? callback : null;
if (!this.isReady || !preloadData || !preloadData.audioData) {
if (callback) {
callback({ success: false, reason: 'invalid_data' });
if (completionCallback) {
completionCallback({ success: false, reason: 'invalid_data' });
}
return false;
}
@@ -446,22 +468,22 @@ export class KokoroTTSModule extends TTSHandlerModule {
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.volume = this.options.volume;
audio.volume = this.getPlaybackVolume();
audio.playbackRate = this.options.rate;
// Set up event handlers
audio.onended = () => {
this.isSpeaking = false;
if (callback) {
callback({ success: true });
if (completionCallback) {
completionCallback({ success: true });
}
URL.revokeObjectURL(audioUrl);
};
audio.onerror = (error) => {
this.isSpeaking = false;
if (callback) {
callback({ success: false, reason: 'playback_error', error });
if (completionCallback) {
completionCallback({ success: false, reason: 'playback_error', error });
}
URL.revokeObjectURL(audioUrl);
};
@@ -475,8 +497,8 @@ export class KokoroTTSModule extends TTSHandlerModule {
}));
}).catch(error => {
this.isSpeaking = false;
if (callback) {
callback({ success: false, reason: 'playback_error', error });
if (completionCallback) {
completionCallback({ success: false, reason: 'playback_error', error });
}
URL.revokeObjectURL(audioUrl);
});
@@ -513,7 +535,7 @@ export class KokoroTTSModule extends TTSHandlerModule {
// Create and play audio
const audio = new Audio(audioUrl);
audio.volume = this.options.volume;
audio.volume = this.getPlaybackVolume();
audio.playbackRate = this.options.rate;
// Set up event handlers
-11
View File
@@ -26,7 +26,6 @@ class LocalizationModule extends BaseModule {
// Bind methods
this.bindMethods([
'setLocale',
'applyServerLocale',
'normalizeLocale',
'getLocale',
'translate',
@@ -145,7 +144,6 @@ class LocalizationModule extends BaseModule {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('app', 'locale', normalizedLocale);
persistenceManager.updatePreference('tts', 'language', normalizedLocale);
if (userInitiated) {
persistenceManager.updatePreference('app', 'localeUserOverride', true);
}
@@ -171,15 +169,6 @@ class LocalizationModule extends BaseModule {
}
}
async applyServerLocale(locale) {
const persistenceManager = this.getModule('persistence-manager');
const userOverride = persistenceManager?.getPreference('app', 'localeUserOverride', false);
if (userOverride) {
return false;
}
return this.setLocale(locale, { userInitiated: false });
}
normalizeLocale(locale) {
const normalized = String(locale || this.defaultLocale).trim().replace('-', '_').toLowerCase();
if (normalized.startsWith('de')) return 'de_DE';
+31 -2
View File
@@ -7,7 +7,7 @@ import { BaseModule } from './base-module.js';
class MarkupParserModule extends BaseModule {
constructor() {
super('markup-parser', 'Markup Parser');
this.dependencies = [];
this.dependencies = ['game-config'];
this.assetRoots = {
images: '/images/',
music: '/music/',
@@ -24,6 +24,9 @@ class MarkupParserModule extends BaseModule {
'markdownToHtml',
'markdownToPlainText',
'smartypants',
'applyLocaleTypography',
'getTypographyLocale',
'normalizeDialogueQuotes',
'escapeHtml',
'normalizeParagraph',
'buildParagraphBlock',
@@ -225,12 +228,38 @@ class MarkupParserModule extends BaseModule {
}
smartypants(text) {
return String(text)
const result = String(text)
.replace(/---/g, '\u2014')
.replace(/--/g, '\u2013')
.replace(/\.\.\./g, '\u2026')
.replace(/(^|[\s([{\u2014])"([^"]*)"/g, '$1\u201c$2\u201d')
.replace(/(^|[\s([{\u2014])'([^']*)'/g, '$1\u2018$2\u2019');
return this.applyLocaleTypography(result);
}
applyLocaleTypography(text) {
const locale = this.getTypographyLocale();
if (locale.startsWith('de')) {
return this.normalizeDialogueQuotes(text);
}
return text;
}
getTypographyLocale() {
const gameConfig = this.getModule('game-config') || window.GameConfig;
const locale = gameConfig?.getLocale?.()
|| gameConfig?.getConfig?.()?.metadata?.language
|| 'en_US';
return String(locale).trim().toLowerCase().replace('_', '-');
}
normalizeDialogueQuotes(text) {
return String(text || '')
.replace(/&(ldquo|bdquo|laquo|raquo);([^&\n]+?)&(rdquo|ldquo|laquo|raquo);/gi, '»$2«')
.replace(/["\u201c\u201e\u201d\u00ab\u00bb]([^"\u201c\u201e\u201d\u00ab\u00bb\n]+?)["\u201c\u201d\u201e\u00ab\u00bb]/g, '»$1«');
}
escapeHtml(text) {
+3 -2
View File
@@ -173,7 +173,7 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
* @param {string} text - Text to generate speech for
* @returns {Promise<Object>} - Audio data object
*/
async generateSpeechAudio(text) {
async generateSpeechAudio(text, options = {}) {
if (!this.isReady || !this.apiKey) {
return { success: false, reason: 'not_ready' };
}
@@ -198,7 +198,8 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
},
body: JSON.stringify(payload)
body: JSON.stringify(payload),
signal: options.signal
});
if (!response.ok) {
+110 -128
View File
@@ -17,7 +17,8 @@ class OptionsUIModule extends BaseModule {
'persistence-manager',
'localization',
'tts-factory',
'audio-manager'
'audio-manager',
'game-config'
];
// Modal element
@@ -38,6 +39,9 @@ class OptionsUIModule extends BaseModule {
'populateVoices',
'populateLanguages',
'loadPreferences',
'createVolumeControl',
'updateVolumeToggleButtons',
'updateVolumeToggleButton',
'showReloadNotice',
'toggle',
'setupEventListeners',
@@ -170,6 +174,7 @@ class OptionsUIModule extends BaseModule {
// Create body
const body = document.createElement('div');
body.className = 'modal-body';
const localization = this.getModule('localization');
// Create sections
// App Settings Section (Language and Speed)
@@ -193,6 +198,23 @@ class OptionsUIModule extends BaseModule {
}, null, languageContainer);
appSettingsSection.appendChild(languageContainer);
const gameLanguageContainer = document.createElement('div');
gameLanguageContainer.className = 'option-item';
const gameLanguageLabel = document.createElement('label');
gameLanguageLabel.textContent = this.t('options.gameLanguage') + ':';
gameLanguageContainer.appendChild(gameLanguageLabel);
const gameLanguageValue = document.createElement('span');
gameLanguageValue.className = 'game-language-value';
const gameConfig = this.getModule('game-config');
const gameLocale = gameConfig?.getLocale?.() || 'en_US';
gameLanguageValue.textContent = localization?.getLanguageName?.(gameLocale) || gameLocale;
this.elements.gameLanguage = gameLanguageValue;
gameLanguageContainer.appendChild(gameLanguageValue);
appSettingsSection.appendChild(gameLanguageContainer);
// Speed
const speedContainer = document.createElement('div');
@@ -296,125 +318,11 @@ class OptionsUIModule extends BaseModule {
audioTitle.textContent = this.t('options.audio');
audioSection.appendChild(audioTitle);
// Master Volume
const masterVolumeContainer = document.createElement('div');
masterVolumeContainer.className = 'option-item';
const masterVolumeLabel = document.createElement('label');
masterVolumeLabel.textContent = this.t('options.masterVolume') + ':';
masterVolumeContainer.appendChild(masterVolumeLabel);
const masterVolumeValue = document.createElement('span');
masterVolumeValue.className = 'slider-value';
masterVolumeValue.textContent = '100%';
this.elements.masterVolumeValue = masterVolumeValue;
masterVolumeContainer.appendChild(masterVolumeValue);
this.elements.masterVolume = createUIElement('input', {
type: 'range',
min: 0,
max: 100,
value: 100,
'data-pref-bind': 'audio.masterVolume',
'data-pref-transform': 'range:0,1'
}, null, masterVolumeContainer);
// Update displayed value when slider changes
this.elements.masterVolume.addEventListener('input', () => {
this.elements.masterVolumeValue.textContent = `${this.elements.masterVolume.value}%`;
});
audioSection.appendChild(masterVolumeContainer);
// Speech Volume
const ttsVolumeContainer = document.createElement('div');
ttsVolumeContainer.className = 'option-item';
const ttsVolumeLabel = document.createElement('label');
ttsVolumeLabel.textContent = this.t('options.speechVolume') + ':';
ttsVolumeContainer.appendChild(ttsVolumeLabel);
const ttsVolumeValue = document.createElement('span');
ttsVolumeValue.className = 'slider-value';
ttsVolumeValue.textContent = '100%';
this.elements.ttsVolumeValue = ttsVolumeValue;
ttsVolumeContainer.appendChild(ttsVolumeValue);
this.elements.ttsVolume = createUIElement('input', {
type: 'range',
min: 0,
max: 100,
value: 100,
'data-pref-bind': 'audio.ttsVolume',
'data-pref-transform': 'range:0,1'
}, null, ttsVolumeContainer);
// Update displayed value when slider changes
this.elements.ttsVolume.addEventListener('input', () => {
this.elements.ttsVolumeValue.textContent = `${this.elements.ttsVolume.value}%`;
});
audioSection.appendChild(ttsVolumeContainer);
// Music Volume
const musicVolumeContainer = document.createElement('div');
musicVolumeContainer.className = 'option-item';
const musicVolumeLabel = document.createElement('label');
musicVolumeLabel.textContent = this.t('options.musicVolume') + ':';
musicVolumeContainer.appendChild(musicVolumeLabel);
const musicVolumeValue = document.createElement('span');
musicVolumeValue.className = 'slider-value';
musicVolumeValue.textContent = '100%';
this.elements.musicVolumeValue = musicVolumeValue;
musicVolumeContainer.appendChild(musicVolumeValue);
this.elements.musicVolume = createUIElement('input', {
type: 'range',
min: 0,
max: 100,
value: 70,
'data-pref-bind': 'audio.musicVolume',
'data-pref-transform': 'range:0,1'
}, null, musicVolumeContainer);
// Update displayed value when slider changes
this.elements.musicVolume.addEventListener('input', () => {
this.elements.musicVolumeValue.textContent = `${this.elements.musicVolume.value}%`;
});
audioSection.appendChild(musicVolumeContainer);
// SFX Volume
const sfxVolumeContainer = document.createElement('div');
sfxVolumeContainer.className = 'option-item';
const sfxVolumeLabel = document.createElement('label');
sfxVolumeLabel.textContent = this.t('options.sfxVolume') + ':';
sfxVolumeContainer.appendChild(sfxVolumeLabel);
const sfxVolumeValue = document.createElement('span');
sfxVolumeValue.className = 'slider-value';
sfxVolumeValue.textContent = '100%';
this.elements.sfxVolumeValue = sfxVolumeValue;
sfxVolumeContainer.appendChild(sfxVolumeValue);
this.elements.sfxVolume = createUIElement('input', {
type: 'range',
min: 0,
max: 100,
value: 100,
'data-pref-bind': 'audio.sfxVolume',
'data-pref-transform': 'range:0,1'
}, null, sfxVolumeContainer);
// Update displayed value when slider changes
this.elements.sfxVolume.addEventListener('input', () => {
this.elements.sfxVolumeValue.textContent = `${this.elements.sfxVolume.value}%`;
});
audioSection.appendChild(sfxVolumeContainer);
audioSection.appendChild(this.createVolumeControl('masterVolume', 'masterVolumeEnabled', 'options.masterVolume', 'options.muteMasterVolume', 'options.unmuteMasterVolume', 100));
audioSection.appendChild(this.createVolumeControl('ttsVolume', 'ttsVolumeEnabled', 'options.speechVolume', 'options.muteSpeechVolume', 'options.unmuteSpeechVolume', 100));
audioSection.appendChild(this.createVolumeControl('musicVolume', 'musicVolumeEnabled', 'options.musicVolume', 'options.muteMusicVolume', 'options.unmuteMusicVolume', 70));
audioSection.appendChild(this.createVolumeControl('sfxVolume', 'sfxVolumeEnabled', 'options.sfxVolume', 'options.muteSfxVolume', 'options.unmuteSfxVolume', 100));
audioSection.appendChild(this.createVolumeControl('musicDuckingAmount', 'musicDuckingEnabled', 'options.musicDucking', 'options.disableMusicDucking', 'options.enableMusicDucking', 30));
body.appendChild(audioSection);
@@ -437,6 +345,69 @@ class OptionsUIModule extends BaseModule {
// Add modal to document
document.body.appendChild(this.modal);
}
createVolumeControl(valueKey, enabledKey, labelKey, muteTitleKey, unmuteTitleKey, defaultPercent) {
const container = document.createElement('div');
container.className = 'option-item volume-option';
const label = document.createElement('label');
label.textContent = this.t(labelKey) + ':';
container.appendChild(label);
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'volume-toggle';
toggle.dataset.prefCategory = 'audio';
toggle.dataset.prefKey = enabledKey;
toggle.dataset.muteTitleKey = muteTitleKey;
toggle.dataset.unmuteTitleKey = unmuteTitleKey;
toggle.addEventListener('click', () => {
const current = this.getPreference('audio', enabledKey, true) !== false;
this.updatePreference('audio', enabledKey, !current);
this.updateVolumeToggleButton(toggle);
});
container.appendChild(toggle);
const value = document.createElement('span');
value.className = 'slider-value';
value.textContent = `${defaultPercent}%`;
this.elements[`${valueKey}Value`] = value;
container.appendChild(value);
const slider = createUIElement('input', {
type: 'range',
min: 0,
max: 100,
value: defaultPercent,
'data-pref-bind': `audio.${valueKey}`,
'data-pref-transform': 'range:0,1'
}, null, container);
this.elements[valueKey] = slider;
slider.addEventListener('input', () => {
value.textContent = `${slider.value}%`;
});
this.updateVolumeToggleButton(toggle);
return container;
}
updateVolumeToggleButtons() {
if (!this.modal) return;
this.modal.querySelectorAll('.volume-toggle').forEach(button => {
this.updateVolumeToggleButton(button);
});
}
updateVolumeToggleButton(button) {
if (!button) return;
const enabled = this.getPreference(button.dataset.prefCategory, button.dataset.prefKey, true) !== false;
button.classList.toggle('is-muted', !enabled);
button.innerHTML = enabled ? '&#128266;' : '&#128263;';
const titleKey = enabled ? button.dataset.muteTitleKey : button.dataset.unmuteTitleKey;
const title = this.t(titleKey);
button.title = title;
button.setAttribute('aria-label', title);
}
/**
* Create API settings controls
@@ -707,7 +678,7 @@ class OptionsUIModule extends BaseModule {
languageOptions,
'code',
'name',
this.getPreference('app', 'locale', 'en-us')
this.getPreference('app', 'locale', localization.getLocale?.() || 'en_US')
);
}
@@ -883,7 +854,22 @@ class OptionsUIModule extends BaseModule {
audioManager.setSfxVolume(value);
} else if (key === 'ttsVolume') {
audioManager.setTtsVolume(value);
} else if (key === 'masterVolumeEnabled') {
audioManager.setVolumeEnabled('master', value);
} else if (key === 'musicVolumeEnabled') {
audioManager.setVolumeEnabled('music', value);
} else if (key === 'sfxVolumeEnabled') {
audioManager.setVolumeEnabled('sfx', value);
} else if (key === 'ttsVolumeEnabled') {
audioManager.setVolumeEnabled('tts', value);
} else if (key === 'musicDuckingAmount') {
audioManager.setMusicDuckingAmount(value);
} else if (key === 'musicDuckingEnabled') {
audioManager.setMusicDuckingEnabled(value);
}
this.updateVolumeDisplays();
this.updateVolumeToggleButtons();
}
// Handle TTS settings side effects
@@ -905,8 +891,6 @@ class OptionsUIModule extends BaseModule {
ttsFactory.configure({ voice: value });
} else if (key === 'speed') {
ttsFactory.configure({ speed: value });
} else if (key === 'language') {
ttsFactory.configure({ language: value });
} else if (key === 'enabled') {
if (!value) {
ttsFactory.disableAfterCurrentPlayback();
@@ -939,11 +923,6 @@ class OptionsUIModule extends BaseModule {
if (localization) {
localization.setLocale(value);
}
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
ttsFactory.configure({ language: value });
}
this.updatePreference('tts', 'language', value);
}
});
}
@@ -969,6 +948,9 @@ class OptionsUIModule extends BaseModule {
if (this.elements.sfxVolume && this.elements.sfxVolumeValue) {
this.elements.sfxVolumeValue.textContent = `${this.elements.sfxVolume.value}%`;
}
if (this.elements.musicDuckingAmount && this.elements.musicDuckingAmountValue) {
this.elements.musicDuckingAmountValue.textContent = `${this.elements.musicDuckingAmount.value}%`;
}
}
}
+6
View File
@@ -42,9 +42,15 @@ class PersistenceManagerModule extends BaseModule {
},
audio: {
masterVolume: 1.0,
masterVolumeEnabled: true,
ttsVolume: 1.0,
ttsVolumeEnabled: true,
musicVolume: 0.7,
musicVolumeEnabled: true,
sfxVolume: 1.0,
sfxVolumeEnabled: true,
musicDuckingAmount: 0.3,
musicDuckingEnabled: true,
},
app: {
locale: null,
+134 -7
View File
@@ -4,6 +4,8 @@
*/
import { BaseModule } from './base-module.js';
const TTS_GENERATION_TIMEOUT_MS = 60000;
class SentenceQueueModule extends BaseModule {
constructor() {
super('sentence-queue', 'Sentence Queue');
@@ -22,6 +24,8 @@ class SentenceQueueModule extends BaseModule {
this.inputMode = 'text';
this.lastContinueAt = 0;
this.pauseBeforeNextReason = null;
this.ttsGenerationTimeoutMs = TTS_GENERATION_TIMEOUT_MS;
this.generationRequests = new Map();
// Bind methods
this.bindMethods([
@@ -34,6 +38,11 @@ class SentenceQueueModule extends BaseModule {
'getCacheKey',
'getPreparedSentence',
'prefetchAhead',
'prepareSpeechMetadata',
'normalizeTtsText',
'runTtsPreloadWithTimeout',
'cancelBlockingGeneration',
'cancelGenerationRequests',
'isSpeechItem',
'getMediaPauseSeconds',
'readFirstFiniteNumber',
@@ -86,6 +95,7 @@ class SentenceQueueModule extends BaseModule {
this.addEventListener(document, 'ui:command', (event) => {
if (event.detail?.type === 'continue') {
this.lastContinueAt = performance.now();
this.cancelBlockingGeneration('user-fast-forward');
}
});
return true;
@@ -200,13 +210,21 @@ class SentenceQueueModule extends BaseModule {
* @param {string} text - Text to prepare speech for
* @returns {Promise<Object>} - Speech metadata object
*/
async prepareSpeechMetadata(text) {
async prepareSpeechMetadata(text, context = {}) {
const ttsFactory = this.getModule('tts-factory');
if (!ttsFactory) {
throw new Error("TTS dependencies not found");
}
const ttsText = this.normalizeTtsText(text);
if (!ttsText) {
console.warn('SentenceQueue: Empty TTS text after normalization, using estimated silent timing', {
sentenceId: context.sentenceId || null
});
return this.estimateSpeechDuration(text);
}
// Check if TTS is enabled via active handler
const activeHandler = ttsFactory.getActiveHandler();
const isTtsEnabled = activeHandler !== null;
@@ -218,20 +236,28 @@ class SentenceQueueModule extends BaseModule {
try {
// Preload the speech to get metadata
const result = await ttsFactory.preloadSpeech(text);
const result = await this.runTtsPreloadWithTimeout(ttsFactory, ttsText, context);
if (!result.success) {
console.warn("SentenceQueue: Speech preload failed, using estimated duration");
console.warn("SentenceQueue: Speech preload failed, using estimated duration", {
reason: result.reason || 'unknown',
sentenceId: context.sentenceId || null,
textPreview: ttsText.slice(0, 80)
});
return this.estimateSpeechDuration(text);
}
// Create a speech metadata object
return {
text: text,
text: ttsText,
duration: result.duration || this.estimateSpeechDuration(text).duration,
handler: ttsFactory.getActiveHandler() ? ttsFactory.getActiveHandler().id : null,
audioData: result.audioData || null,
play: async () => {
return ttsFactory.speak(text);
if (result.audioData && typeof ttsFactory.speakPreloaded === 'function') {
return ttsFactory.speakPreloaded(result);
}
return ttsFactory.speak(ttsText);
},
stop: () => {
return ttsFactory.stop();
@@ -243,6 +269,94 @@ class SentenceQueueModule extends BaseModule {
return this.estimateSpeechDuration(text);
}
}
normalizeTtsText(text) {
return String(text || '')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
runTtsPreloadWithTimeout(ttsFactory, text, context = {}) {
const sentenceId = context.sentenceId || context.id || `tts-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const requestId = `${sentenceId}:${context.prefetch ? 'prefetch' : 'blocking'}:${Date.now()}`;
const controller = new AbortController();
const startedAt = performance.now();
return new Promise((resolve) => {
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
this.generationRequests.delete(requestId);
resolve(result);
};
const timeoutId = setTimeout(() => {
console.warn('SentenceQueue: TTS generation timed out; continuing without audio', {
sentenceId,
timeoutMs: this.ttsGenerationTimeoutMs,
textPreview: text.slice(0, 120)
});
controller.abort('tts-generation-timeout');
finish({ success: false, reason: 'tts_generation_timeout', timedOut: true });
}, this.ttsGenerationTimeoutMs);
this.generationRequests.set(requestId, {
controller,
sentenceId,
blocking: context.blocking !== false,
startedAt,
textPreview: text.slice(0, 120),
finish
});
Promise.resolve(ttsFactory.preloadSpeech(text, { signal: controller.signal }))
.then(result => finish(result || { success: false, reason: 'empty_tts_result' }))
.catch(error => {
if (controller.signal.aborted) {
console.warn('SentenceQueue: TTS generation cancelled; continuing without audio', {
sentenceId,
reason: controller.signal.reason || 'aborted',
elapsedMs: Math.round(performance.now() - startedAt)
});
finish({ success: false, reason: 'tts_generation_aborted', error });
} else {
console.warn('SentenceQueue: TTS generation failed; continuing without audio', {
sentenceId,
error
});
finish({ success: false, reason: 'tts_generation_error', error });
}
});
});
}
cancelBlockingGeneration(reason = 'cancelled') {
this.cancelGenerationRequests(reason, request => request.blocking === true);
}
cancelGenerationRequests(reason = 'cancelled', predicate = () => true) {
for (const [requestId, request] of this.generationRequests.entries()) {
if (!predicate(request)) continue;
console.warn('SentenceQueue: Cancelling TTS generation request', {
requestId,
sentenceId: request.sentenceId,
reason,
elapsedMs: Math.round(performance.now() - request.startedAt),
textPreview: request.textPreview
});
try {
request.controller.abort(reason);
} catch (error) {
console.warn('SentenceQueue: Failed to abort TTS generation request', { requestId, error });
}
if (typeof request.finish === 'function') {
request.finish({ success: false, reason: 'tts_generation_cancelled' });
}
}
}
/**
* Estimate speech duration based on character count
@@ -314,7 +428,12 @@ class SentenceQueueModule extends BaseModule {
await audioManager.preloadMediaCues(metadata.cueMarkers || []);
}
const ttsData = await this.prepareSpeechMetadata(text);
const ttsData = await this.prepareSpeechMetadata(text, {
sentenceId: id,
blockId: metadata.blockId ?? null,
turnId: metadata.turnId ?? null,
blocking: true
});
console.log(`SentenceQueue: Prepared speech "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms`);
@@ -557,7 +676,14 @@ class SentenceQueueModule extends BaseModule {
console.log(`Process state: ${state}`, { reason: 'prefetch-start', sentenceId: nextItem.id, queueIndex: index });
const promise = (this.isSpeechItem(nextItem)
? this.prepareSpeechMetadata(nextItem.text || '')
? this.prepareSpeechMetadata(nextItem.text || '', {
sentenceId: nextItem.id,
blockId: nextItem.blockId ?? null,
turnId: nextItem.turnId ?? null,
queueIndex: index,
prefetch: true,
blocking: false
})
: Promise.resolve(null))
.then(() => {
console.log('SentenceQueue: Prefetched queued speech/media', { sentenceId: nextItem.id, queueIndex: index });
@@ -781,6 +907,7 @@ class SentenceQueueModule extends BaseModule {
clear() {
this.sentenceQueue = [];
this.isProcessing = false;
this.cancelGenerationRequests('sentence-queue-cleared');
this.prefetchingSpeech.clear();
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
detail: { reason: 'sentence-queue-cleared' }
+16 -2
View File
@@ -214,10 +214,20 @@ class SocketClientModule extends BaseModule {
this.receivedParagraphCounter = 0;
}
if (Array.isArray(data.globalTags) && data.globalTags.length > 0) {
const globalTags = Array.isArray(data.globalTags) ? data.globalTags : [];
const endState = data.gameState?.endState || null;
if (endState && !globalTags.some((tag) => tag?.key === 'score' || tag?.key === 'error')) {
globalTags.push({
key: endState.type === 'error' ? 'error' : 'score',
value: endState.message || ''
});
}
if (globalTags.length > 0) {
document.dispatchEvent(new CustomEvent('story:global-tags', {
detail: data.globalTags
detail: globalTags
}));
this.dispatchTurnTags(globalTags, null);
}
document.dispatchEvent(new CustomEvent('story:turn-start', {
@@ -245,6 +255,10 @@ class SocketClientModule extends BaseModule {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'choice-only-turn', turnId }
}));
} else if (turnBlocks.length === 0 && inputMode === 'end') {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'empty-end-turn', turnId }
}));
}
}
+37 -26
View File
@@ -13,8 +13,7 @@ class TextProcessorModule extends BaseModule {
this.hyphenatorReady = false;
this.locale = 'en-us';
// Add localization as a dependency
this.dependencies = ['localization'];
this.dependencies = ['localization', 'game-config'];
// Bind methods using parent's bindMethods utility
this.bindMethods([
@@ -24,9 +23,11 @@ class TextProcessorModule extends BaseModule {
'isHyphenationAvailable',
'hyphenate',
'setLocale',
'handleLocaleChanged',
'loadHyphenopolyLoader',
'normalizeHyphenationLocale'
'normalizeHyphenationLocale',
'applyLocaleTypography',
'getTypographyLocale',
'normalizeDialogueQuotes'
]);
}
@@ -38,18 +39,15 @@ class TextProcessorModule extends BaseModule {
try {
this.reportProgress(10, "Initializing text processor");
// Get locale from Localization module if available
const localizationModule = this.getModule('localization');
if (!localizationModule) {
console.error("Localization module not found, required dependency missing");
this.reportProgress(100, "Text processor initialization failed - missing localization");
return false;
}
this.locale = localizationModule.getLocale();
// Register for locale changes using the proper event pattern
this.addEventListener(document, 'locale-changed', this.handleLocaleChanged);
const gameConfig = this.getModule('game-config');
this.locale = gameConfig?.getLocale?.() || 'en_US';
this.addEventListener(document, 'game:config', (event) => {
const gameLocale = event.detail?.metadata?.language || event.detail?.locale;
if (gameLocale) {
this.setLocale(gameLocale);
}
});
this.reportProgress(30, `Locale set to ${this.locale}`);
@@ -92,16 +90,6 @@ class TextProcessorModule extends BaseModule {
}
}
/**
* Handle locale changed event
* @param {CustomEvent} event - The locale-changed event
*/
handleLocaleChanged(event) {
if (event && event.detail && event.detail.locale) {
this.setLocale(event.detail.locale);
}
}
/**
* Set the locale for the text processor
* @param {string} locale - The locale to set
@@ -299,6 +287,10 @@ class TextProcessorModule extends BaseModule {
result = this.smartyPants(result);
}
if (opts.smartypants) {
result = this.applyLocaleTypography(result);
}
// Apply hyphenation if available and requested
if (opts.hyphenate && this.isHyphenationAvailable()) {
result = this.hyphenate(result, opts.hyphenSelector);
@@ -306,6 +298,25 @@ class TextProcessorModule extends BaseModule {
return result;
}
applyLocaleTypography(text) {
const locale = this.getTypographyLocale();
if (locale.startsWith('de')) {
return this.normalizeDialogueQuotes(text);
}
return text;
}
getTypographyLocale() {
return String(this.locale || 'en_US').trim().toLowerCase().replace('_', '-');
}
normalizeDialogueQuotes(text) {
return String(text || '')
.replace(/&(ldquo|bdquo|laquo|raquo);([^&\n]+?)&(rdquo|ldquo|laquo|raquo);/gi, '»$2«')
.replace(/["\u201c\u201e\u201d\u00ab\u00bb]([^"\u201c\u201e\u201d\u00ab\u00bb\n]+?)["\u201c\u201d\u201e\u00ab\u00bb]/g, '»$1«');
}
}
// Create the singleton instance
+22 -15
View File
@@ -14,6 +14,7 @@ class TTSFactoryModule extends BaseModule {
this.dependencies = [
'persistence-manager',
'localization',
'game-config',
'browser-tts', // Browser TTS handler
'kokoro-tts', // Kokoro TTS handler
'elevenlabs-tts',// ElevenLabs TTS handler
@@ -24,7 +25,7 @@ class TTSFactoryModule extends BaseModule {
this.activeHandler = null;
this.ttsAvailable = false;
this.speed = 1; // Speech speed multiplier. 1.0 is normal speed.
this.language = 'en-us';
this.language = 'en_US';
this.voice = '';
this.volume = 1.0;
@@ -224,9 +225,10 @@ class TTSFactoryModule extends BaseModule {
}
});
document.addEventListener('locale-changed', (event) => {
if (event.detail?.locale) {
this.configure({ language: event.detail.locale });
document.addEventListener('game:config', (event) => {
const language = event.detail?.metadata?.language || event.detail?.locale;
if (language) {
this.configure({ language, persistLanguage: false });
}
});
@@ -403,7 +405,7 @@ class TTSFactoryModule extends BaseModule {
'preferred_handler': 'none', // Development default: TTS disabled
'enabled': false, // TTS disabled by default
'voice': '', // Empty default - will be selected based on handler
'language': 'en-US', // Default language
'language': 'en_US', // Legacy stored value; game metadata now owns active TTS language
'volume': 1.0, // Default volume
'elevenlabs_api_key': '', // Empty API key by default
'elevenlabs_api_url': 'https://api.elevenlabs.io/v1', // Default ElevenLabs API URL
@@ -433,7 +435,8 @@ class TTSFactoryModule extends BaseModule {
// Load other preferences we need for initialization
const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler');
console.log(`TTS Factory: Loaded preferred handler: ${preferredHandler || 'none'}`);
this.language = persistenceManager.getPreference('tts', 'language', defaults.language);
const gameConfig = this.getModule('game-config');
this.language = String(gameConfig?.getLocale?.() || defaults.language).replace('_', '-').toLowerCase();
this.voice = persistenceManager.getPreference('tts', 'voice', defaults.voice);
this.volume = persistenceManager.getPreference('tts', 'volume', defaults.volume);
@@ -698,7 +701,7 @@ class TTSFactoryModule extends BaseModule {
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'preferred_handler', id);
this.voice = persistenceManager.getPreference('tts', 'voice', this.voice || '');
this.language = persistenceManager.getPreference('tts', 'language', this.language || 'en-us');
this.language = String(this.getModule('game-config')?.getLocale?.() || this.language || 'en_US').replace('_', '-').toLowerCase();
this.speed = persistenceManager.getPreference('tts', 'speed', this.speed || 1.0);
}
@@ -788,7 +791,7 @@ class TTSFactoryModule extends BaseModule {
// Not cached, generate and cache
if (typeof handler.preloadSpeech === 'function') {
console.log(`TTS Factory: Generating and caching speech for hash ${hash}`);
const preloadData = await handler.preloadSpeech(text);
const preloadData = await handler.preloadSpeech(text, options);
if (preloadData && preloadData.success) {
// Cache the speech
await this.cacheSpeech(hash, preloadData.audioData, preloadData.duration);
@@ -822,7 +825,7 @@ class TTSFactoryModule extends BaseModule {
* @param {number} [priority=5] - Priority for preloading (1-10, higher is more important)
* @returns {Promise<Object>} - Preloaded speech data
*/
async preloadSpeech(text, priority = 5) {
async preloadSpeech(text, options = {}) {
// Check if we have an active handler
if (!this.activeHandler || !this.ttsAvailable) {
console.warn('TTS Factory: Cannot preload speech - no active handler or TTS not available');
@@ -855,7 +858,7 @@ class TTSFactoryModule extends BaseModule {
// If the handler has a preloadSpeech method, use it
if (typeof this.handlers[this.activeHandler].preloadSpeech === 'function') {
const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text);
const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text, options);
// Cache the generated speech data (extract audioData from result object)
if (preloadData && preloadData.audioData) {
@@ -1169,9 +1172,9 @@ class TTSFactoryModule extends BaseModule {
}
if (typeof options.language === 'string' && options.language) {
this.language = options.language.toLowerCase();
this.language = options.language.replace('_', '-').toLowerCase();
voiceOptions.language = this.language;
if (persistenceManager) {
if (persistenceManager && options.persistLanguage === true) {
persistenceManager.updatePreference('tts', 'language', this.language);
}
}
@@ -1215,7 +1218,7 @@ class TTSFactoryModule extends BaseModule {
* @param {string} text - Text to preload
* @returns {Promise<Object>} - Resolves with preloaded speech data
*/
async preloadSpeech(text) {
async preloadSpeech(text, options = {}) {
if (!this.activeHandler) {
console.warn("TTS Factory: No active TTS handler for preload");
return null;
@@ -1243,7 +1246,7 @@ class TTSFactoryModule extends BaseModule {
// If the handler has a preloadSpeech method, use it
if (typeof this.handlers[this.activeHandler].preloadSpeech === 'function') {
const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text);
const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text, options);
// Cache the generated speech data (extract audioData from result object)
if (preloadData && preloadData.audioData) {
@@ -1313,7 +1316,11 @@ class TTSFactoryModule extends BaseModule {
// If the handler has a speakPreloaded method, use it
if (typeof this.handlers[this.activeHandler].speakPreloaded === 'function') {
return await this.handlers[this.activeHandler].speakPreloaded(preloadData, options);
return await this.handlers[this.activeHandler].speakPreloaded(preloadData, result => {
document.dispatchEvent(new CustomEvent('tts:speechCompleted', {
detail: { success: result?.success === true, error: result?.error }
}));
});
} else {
console.warn(`TTS Factory: Handler ${this.activeHandler} does not support speaking preloaded data`);
return false;
+279 -2
View File
@@ -48,6 +48,10 @@ class UIDisplayHandlerModule extends BaseModule {
this.lastManualScrollAt = 0;
this.layoutFlowLine = 0;
this.layoutExclusions = [];
this.notificationQueue = [];
this.notificationActive = false;
this.pendingTerminalNotifications = [];
this.latestInputMode = 'text';
// Resources to preload
this.cssPath = '/css/style.css';
@@ -121,7 +125,19 @@ class UIDisplayHandlerModule extends BaseModule {
'measureText',
'loadCSS',
'showChoices',
'preloadImages'
'preloadImages',
'createCreditsDialog',
'openCreditsDialog',
'closeCreditsDialog',
'loadCreditsText',
'createNotificationDialog',
'handleStoryTag',
'getTagMessage',
'showNotification',
'displayNextNotification',
'queueTerminalNotification',
'flushTerminalNotifications',
'closeNotification'
]);
console.log('UIDisplayHandler: Constructor initialized');
@@ -173,6 +189,15 @@ class UIDisplayHandlerModule extends BaseModule {
this.addEventListener(document, 'story:history-updated', (event) => {
this.updateStoryScrollbar(event.detail || {});
});
this.addEventListener(document, 'story:tag', (event) => {
this.handleStoryTag(event.detail);
});
this.addEventListener(document, 'story:turn-start', () => {
this.latestInputMode = 'text';
});
this.addEventListener(document, 'story:input-mode', (event) => {
this.latestInputMode = event.detail || 'text';
});
this.addEventListener(document, 'wheel', this.handleHistoryWheel, { passive: false });
this.addEventListener(document, 'keydown', (event) => {
const tagName = String(event.target?.tagName || '').toLowerCase();
@@ -213,6 +238,9 @@ class UIDisplayHandlerModule extends BaseModule {
? this.t('title.continueHint')
: this.t('title.fastForwardHint');
}
if (state === 'ready' && this.latestInputMode === 'end') {
this.flushTerminalNotifications();
}
});
if (window.ResizeObserver && this.paragraphContainer) {
@@ -472,6 +500,9 @@ class UIDisplayHandlerModule extends BaseModule {
lighting.id = 'lighting';
document.body.appendChild(lighting);
}
this.createCreditsDialog();
this.createNotificationDialog();
console.log('UIDisplayHandler: All containers initialized');
this.applyGameConfig(this.gameConfig?.getConfig?.());
@@ -497,7 +528,27 @@ class UIDisplayHandlerModule extends BaseModule {
metadata.version ? this.t('title.version', { version: metadata.version }) : '',
metadata.copyright || ''
].filter(Boolean);
legalElement.textContent = items.join(' · ');
legalElement.innerHTML = '';
const legalText = document.createElement('span');
legalText.id = 'game_legal_text';
legalText.textContent = items.join(' | ');
legalElement.appendChild(legalText);
const creditsButton = document.createElement('button');
creditsButton.id = 'credits_button';
creditsButton.type = 'button';
creditsButton.textContent = this.t('credits.button');
creditsButton.title = this.t('credits.buttonTitle');
creditsButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
this.openCreditsDialog();
});
if (items.length > 0) {
legalElement.appendChild(document.createTextNode(' | '));
}
legalElement.appendChild(creditsButton);
}
}
@@ -522,6 +573,9 @@ class UIDisplayHandlerModule extends BaseModule {
setText('options', 'topbar.options');
setText('remark_text', 'title.fastForwardHint');
setText('start_prompt', 'title.startPrompt');
setText('credits_dialog_title', 'credits.title');
setText('credits_close', 'credits.close');
setText('story_popup_ok', 'popup.ok');
setTitle('speech', 'topbar.speechTitle');
setTitle('autoplay', 'topbar.autoplayTitle');
setTitle('rewind', 'topbar.newGameTitle');
@@ -533,6 +587,224 @@ class UIDisplayHandlerModule extends BaseModule {
if (input) input.setAttribute('placeholder', this.t('input.placeholder'));
this.applyGameConfig(this.gameConfig?.getConfig?.());
}
createCreditsDialog() {
if (document.getElementById('credits_modal')) {
return;
}
const modal = document.createElement('div');
modal.id = 'credits_modal';
modal.className = 'credits-modal';
modal.setAttribute('aria-hidden', 'true');
modal.innerHTML = `
<div class="credits-dialog" role="dialog" aria-modal="true" aria-labelledby="credits_dialog_title">
<div class="credits-dialog-header">
<h2 id="credits_dialog_title"></h2>
<button type="button" id="credits_close"></button>
</div>
<div class="credits-logo-row" aria-label="Credits links">
<a href="https://openai.com/" target="_blank" rel="noreferrer"><img src="https://cdn.simpleicons.org/openai/2b2218" alt="OpenAI"></a>
<a href="https://www.inklestudios.com/ink/" target="_blank" rel="noreferrer" class="credits-wordmark">ink</a>
<a href="https://mnater.github.io/Hyphenopoly/" target="_blank" rel="noreferrer" class="credits-wordmark">Hyphenopoly</a>
<a href="https://github.com/hexgrad/kokoro" target="_blank" rel="noreferrer" class="credits-wordmark">Kokoro</a>
<a href="https://suno.com/" target="_blank" rel="noreferrer" class="credits-wordmark">Suno</a>
</div>
<pre id="credits_content" class="credits-content"></pre>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener('click', (event) => {
if (event.target === modal) {
this.closeCreditsDialog();
}
});
const closeButton = document.getElementById('credits_close');
if (closeButton) {
closeButton.addEventListener('click', () => this.closeCreditsDialog());
}
}
async openCreditsDialog() {
const modal = document.getElementById('credits_modal');
const content = document.getElementById('credits_content');
if (!modal || !content) {
return;
}
modal.classList.add('visible');
modal.setAttribute('aria-hidden', 'false');
if (!content.dataset.loaded) {
content.textContent = this.t('credits.loading');
content.textContent = await this.loadCreditsText();
content.dataset.loaded = 'true';
}
}
closeCreditsDialog() {
const modal = document.getElementById('credits_modal');
if (!modal) {
return;
}
modal.classList.remove('visible');
modal.setAttribute('aria-hidden', 'true');
}
async loadCreditsText() {
try {
const response = await fetch('/THIRD_PARTY_NOTICES.md', { cache: 'no-cache' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.text();
} catch (error) {
console.warn('UIDisplayHandler: Failed to load credits notices', error);
return this.t('credits.loadFailed');
}
}
createNotificationDialog() {
if (document.getElementById('story_popup_modal')) {
return;
}
const modal = document.createElement('div');
modal.id = 'story_popup_modal';
modal.className = 'story-popup-modal';
modal.setAttribute('aria-hidden', 'true');
modal.innerHTML = `
<div class="story-popup-dialog" role="dialog" aria-modal="true" aria-labelledby="story_popup_title">
<h2 id="story_popup_title"></h2>
<div id="story_popup_message"></div>
<button type="button" id="story_popup_ok"></button>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (event.target === modal) {
this.closeNotification();
}
});
modal.addEventListener('pointerdown', (event) => {
event.stopPropagation();
});
const okButton = document.getElementById('story_popup_ok');
if (okButton) {
okButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
this.closeNotification();
});
}
}
handleStoryTag(tag) {
const key = String(tag?.key || '').toLowerCase();
if (!['score', 'error', 'achievement', 'alert'].includes(key)) {
return;
}
const message = this.getTagMessage(tag);
if (key === 'score') {
this.queueTerminalNotification(
'ending',
this.t('popup.endingTitle'),
message || this.t('popup.defaultEnding')
);
} else if (key === 'error') {
this.queueTerminalNotification(
'error',
this.t('popup.errorTitle'),
message || this.t('popup.defaultError')
);
} else if (key === 'achievement') {
this.showNotification(
'achievement',
this.t('popup.achievementTitle'),
message || this.t('popup.defaultAchievement')
);
} else if (key === 'alert') {
this.showNotification(
'alert',
this.t('popup.alertTitle'),
message || this.t('popup.defaultAlert')
);
}
}
getTagMessage(tag) {
return [tag?.value, tag?.param]
.map((part) => String(part || '').trim())
.filter(Boolean)
.join('\n');
}
showNotification(kind, title, message) {
this.notificationQueue.push({ kind, title, message });
this.displayNextNotification();
}
queueTerminalNotification(kind, title, message) {
this.pendingTerminalNotifications.push({ kind, title, message });
if (this.latestInputMode === 'end') {
this.flushTerminalNotifications();
}
}
flushTerminalNotifications() {
if (this.pendingTerminalNotifications.length === 0) {
return;
}
this.pendingTerminalNotifications.splice(0).forEach((notification) => {
this.showNotification(notification.kind, notification.title, notification.message);
});
}
displayNextNotification() {
if (this.notificationActive || this.notificationQueue.length === 0) {
return;
}
const next = this.notificationQueue.shift();
const modal = document.getElementById('story_popup_modal');
const title = document.getElementById('story_popup_title');
const message = document.getElementById('story_popup_message');
const okButton = document.getElementById('story_popup_ok');
if (!modal || !title || !message) {
return;
}
modal.dataset.kind = next.kind;
title.textContent = next.title;
message.textContent = next.message;
if (okButton) {
okButton.textContent = this.t('popup.ok');
setTimeout(() => okButton.focus(), 0);
}
this.notificationActive = true;
modal.classList.add('visible');
modal.setAttribute('aria-hidden', 'false');
}
closeNotification() {
const modal = document.getElementById('story_popup_modal');
if (!modal) {
this.notificationActive = false;
return;
}
modal.classList.remove('visible');
modal.setAttribute('aria-hidden', 'true');
this.notificationActive = false;
setTimeout(() => this.displayNextNotification(), 0);
}
/**
* Measure text width using canvas
@@ -1927,6 +2199,11 @@ class UIDisplayHandlerModule extends BaseModule {
this.container.appendChild(this.paragraphContainer);
}
this.renderedItems = [];
this.notificationQueue = [];
this.pendingTerminalNotifications = [];
this.notificationActive = false;
document.getElementById('story_popup_modal')?.classList.remove('visible');
document.getElementById('story_popup_modal')?.setAttribute('aria-hidden', 'true');
this.historyWindowStartId = 1;
this.historyWindowEndId = 0;
this.storyTopLine = 0;