Document markup and improve choice tags
This commit is contained in:
@@ -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' };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 ? '🔊' : '🔇';
|
||||
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}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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 }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user