Fixed Ducking. Refined UI.

This commit is contained in:
2026-05-14 23:18:30 +02:00
parent b5829ed773
commit 9a6bb009f2
10 changed files with 224 additions and 74 deletions
+25 -2
View File
@@ -18,6 +18,7 @@ class AudioManagerModule extends BaseModule {
this.sfxVolume = 1.0; this.sfxVolume = 1.0;
this.ttsVolume = 1.0; this.ttsVolume = 1.0;
this.musicDuckingFactor = 1.0; this.musicDuckingFactor = 1.0;
this.musicFadeToken = 0;
this.activeTtsPlaybackCount = 0; this.activeTtsPlaybackCount = 0;
this.ttsQueueEmpty = true; this.ttsQueueEmpty = true;
this.pendingMusicPlayback = null; this.pendingMusicPlayback = null;
@@ -106,6 +107,11 @@ class AudioManagerModule extends BaseModule {
this.duckMusicForSpeech(); this.duckMusicForSpeech();
}); });
this.addEventListener(document, 'tts:audio-started', () => {
this.ttsQueueEmpty = false;
this.duckMusicForSpeech();
});
this.addEventListener(document, 'tts:playback-end', () => { this.addEventListener(document, 'tts:playback-end', () => {
this.activeTtsPlaybackCount = Math.max(0, this.activeTtsPlaybackCount - 1); this.activeTtsPlaybackCount = Math.max(0, this.activeTtsPlaybackCount - 1);
this.restoreMusicIfSpeechFinished(); this.restoreMusicIfSpeechFinished();
@@ -116,6 +122,11 @@ class AudioManagerModule extends BaseModule {
this.restoreMusicIfSpeechFinished(); this.restoreMusicIfSpeechFinished();
}); });
this.addEventListener(document, 'tts:speechCompleted', () => {
this.activeTtsPlaybackCount = Math.max(0, this.activeTtsPlaybackCount - 1);
this.restoreMusicIfSpeechFinished();
});
const unlock = () => this.unlockPendingAudio(); const unlock = () => this.unlockPendingAudio();
document.addEventListener('pointerdown', unlock, { passive: true }); document.addEventListener('pointerdown', unlock, { passive: true });
document.addEventListener('keydown', unlock); document.addEventListener('keydown', unlock);
@@ -252,6 +263,14 @@ class AudioManagerModule extends BaseModule {
audio.pause(); audio.pause();
audio.currentTime = 0; audio.currentTime = 0;
}); });
this.stopCurrentMusic();
this.queuedMusic = null;
this.pendingMusicPlayback = null;
this.activeTtsPlaybackCount = 0;
this.ttsQueueEmpty = true;
this.musicDuckingFactor = 1.0;
this.musicFadeToken += 1;
} }
/** /**
@@ -303,7 +322,7 @@ class AudioManagerModule extends BaseModule {
} }
if (this.currentLoop) { if (this.currentLoop) {
this.currentLoop.volume = this.masterVolume * this.musicVolume; this.currentLoop.volume = this.getMusicVolume();
} }
if (this.currentMusic) { if (this.currentMusic) {
@@ -329,7 +348,7 @@ class AudioManagerModule extends BaseModule {
duckMusicForSpeech() { duckMusicForSpeech() {
console.log('AudioManager: Ducking music for TTS playback'); console.log('AudioManager: Ducking music for TTS playback');
this.fadeMusicTo(0.7, 500); this.fadeMusicTo(0.3, 500);
} }
restoreMusicAfterSpeech() { restoreMusicAfterSpeech() {
@@ -350,11 +369,15 @@ class AudioManagerModule extends BaseModule {
} }
const audio = this.currentMusic; const audio = this.currentMusic;
const token = ++this.musicFadeToken;
const startVolume = audio.volume; const startVolume = audio.volume;
const targetVolume = this.getUnduckedMusicVolume() * this.musicDuckingFactor; const targetVolume = this.getUnduckedMusicVolume() * this.musicDuckingFactor;
const start = performance.now(); const start = performance.now();
const tick = () => { const tick = () => {
if (token !== this.musicFadeToken || this.currentMusic !== audio) {
return;
}
const progress = Math.min(1, (performance.now() - start) / duration); const progress = Math.min(1, (performance.now() - start) / duration);
audio.volume = startVolume + ((targetVolume - startVolume) * progress); audio.volume = startVolume + ((targetVolume - startVolume) * progress);
if (progress < 1) { if (progress < 1) {
+46 -17
View File
@@ -9,7 +9,7 @@ class GameLoopModule extends BaseModule {
super('game-loop', 'Game Loop'); super('game-loop', 'Game Loop');
// Dependencies // Dependencies
this.dependencies = ['ui-controller', 'socket-client', 'text-buffer']; this.dependencies = ['ui-controller', 'socket-client', 'text-buffer', 'sentence-queue', 'playback-coordinator', 'animation-queue', 'audio-manager', 'tts-factory', 'ui-input-handler'];
// Game state // Game state
this.gameState = { this.gameState = {
@@ -33,6 +33,7 @@ class GameLoopModule extends BaseModule {
'requestStartGame', 'requestStartGame',
'requestSaveGame', 'requestSaveGame',
'requestLoadGame', 'requestLoadGame',
'resetClientPlaybackAndDisplay',
'addText' 'addText'
]); ]);
} }
@@ -199,14 +200,7 @@ class GameLoopModule extends BaseModule {
const socketClient = this.getModule('socket-client'); const socketClient = this.getModule('socket-client');
if (!socketClient) return; if (!socketClient) return;
const uiController = this.getModule('ui-controller'); await this.resetClientPlaybackAndDisplay();
if (uiController) {
uiController.clearDisplay();
}
const textBuffer = this.getModule('text-buffer');
if (textBuffer && typeof textBuffer.clear === 'function') {
textBuffer.clear();
}
const response = await socketClient.newGame(); const response = await socketClient.newGame();
if (!response?.success) { if (!response?.success) {
console.error('GameLoop: newGame failed', response); console.error('GameLoop: newGame failed', response);
@@ -246,14 +240,7 @@ class GameLoopModule extends BaseModule {
return; return;
} }
const uiController = this.getModule('ui-controller'); await this.resetClientPlaybackAndDisplay();
if (uiController) {
uiController.clearDisplay();
}
const textBuffer = this.getModule('text-buffer');
if (textBuffer && typeof textBuffer.clear === 'function') {
textBuffer.clear();
}
const response = await socketClient.loadGame(1); const response = await socketClient.loadGame(1);
if (response?.success) { if (response?.success) {
this.gameState.started = true; this.gameState.started = true;
@@ -263,6 +250,48 @@ class GameLoopModule extends BaseModule {
} }
} }
async resetClientPlaybackAndDisplay() {
const playbackCoordinator = this.getModule('playback-coordinator');
if (playbackCoordinator && typeof playbackCoordinator.stop === 'function') {
await playbackCoordinator.stop();
}
const animationQueue = this.getModule('animation-queue');
if (animationQueue && typeof animationQueue.clearAll === 'function') {
animationQueue.clearAll();
}
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory && typeof ttsFactory.stop === 'function') {
ttsFactory.stop();
}
const audioManager = this.getModule('audio-manager');
if (audioManager && typeof audioManager.stopAllSounds === 'function') {
audioManager.stopAllSounds();
}
const sentenceQueue = this.getModule('sentence-queue');
if (sentenceQueue && typeof sentenceQueue.clear === 'function') {
sentenceQueue.clear();
}
const textBuffer = this.getModule('text-buffer');
if (textBuffer && typeof textBuffer.clear === 'function') {
textBuffer.clear();
}
const uiController = this.getModule('ui-controller');
if (uiController) {
uiController.clearDisplay();
}
const inputHandler = this.getModule('ui-input-handler');
if (inputHandler && typeof inputHandler.clearHistory === 'function') {
inputHandler.clearHistory();
}
}
/** /**
* Manually add text to the buffer * Manually add text to the buffer
* Useful for testing or adding local messages * Useful for testing or adding local messages
+26 -2
View File
@@ -128,10 +128,18 @@ class LayoutRendererModule extends BaseModule {
for (let i = 1; i < breaks.length; i++) { for (let i = 1; i < breaks.length; i++) {
const lineIndex = i - 1; const lineIndex = i - 1;
const lineWidth = measures[Math.min(lineIndex, measures.length - 1)]; const lineWidth = measures[Math.min(lineIndex, measures.length - 1)];
const lineOffset = maxLineWidth - lineWidth;
const currentBreak = breaks[i]; const currentBreak = breaks[i];
const isFinalLine = i === breaks.length - 1; const isFinalLine = i === breaks.length - 1;
const ratio = isFinalLine ? 0 : (currentBreak.ratio || 0); const isCentered = layoutData.align === 'center' ||
layoutData.role === 'chapter-heading' ||
layoutData.role === 'section-heading';
const ratio = (isFinalLine || isCentered) ? 0 : (currentBreak.ratio || 0);
const naturalLineWidth = isCentered
? this.measureNaturalLineWidth(nodes, breaks[i - 1].position, currentBreak.position)
: lineWidth;
const lineOffset = isCentered
? Math.max(0, (maxLineWidth - naturalLineWidth) / 2)
: maxLineWidth - lineWidth;
let currentLeft = 0; let currentLeft = 0;
lastChild = null; lastChild = null;
@@ -175,6 +183,7 @@ class LayoutRendererModule extends BaseModule {
word.style.left = `${leftPercent}%`; word.style.left = `${leftPercent}%`;
word.style.opacity = '0'; // Hidden until animated word.style.opacity = '0'; // Hidden until animated
word.style.visibility = 'hidden'; word.style.visibility = 'hidden';
word.style.clipPath = 'inset(0 100% 0 0)';
syllable = node.value; syllable = node.value;
word.innerHTML = syllable; word.innerHTML = syllable;
lastChild = word; lastChild = word;
@@ -244,6 +253,7 @@ class LayoutRendererModule extends BaseModule {
word.style.left = `${leftPercent}%`; word.style.left = `${leftPercent}%`;
word.style.opacity = '0'; word.style.opacity = '0';
word.style.visibility = 'hidden'; word.style.visibility = 'hidden';
word.style.clipPath = 'inset(0 100% 0 0)';
word.innerHTML = "-"; word.innerHTML = "-";
stack[stack.length - 1].appendChild(word); stack[stack.length - 1].appendChild(word);
} }
@@ -253,6 +263,20 @@ class LayoutRendererModule extends BaseModule {
return paragraph; return paragraph;
} }
measureNaturalLineWidth(nodes, startPosition, endPosition) {
let width = 0;
for (let j = startPosition; j <= endPosition; j++) {
const node = nodes[j];
if (!node) continue;
if (node.type === 'box' || node.type === 'glue') {
width += node.width || 0;
} else if (node.type === 'penalty' && node.penalty === 100 && j === endPosition) {
width += node.width || 0;
}
}
return width;
}
/** /**
* Paragraph positions are already computed from browser DOM measurements. * Paragraph positions are already computed from browser DOM measurements.
* Keep this hook for callers that still invoke it, but do not reflow the * Keep this hook for callers that still invoke it, but do not reflow the
+20 -5
View File
@@ -20,6 +20,7 @@ class MarkupParserModule extends BaseModule {
'parseInline', 'parseInline',
'parseMusicOptions', 'parseMusicOptions',
'markdownToHtml', 'markdownToHtml',
'markdownToPlainText',
'smartypants', 'smartypants',
'escapeHtml', 'escapeHtml',
'normalizeParagraph', 'normalizeParagraph',
@@ -65,10 +66,11 @@ class MarkupParserModule extends BaseModule {
flushParagraph(); flushParagraph();
const heading = (chapter[1] || chapter[2] || '').trim(); const heading = (chapter[1] || chapter[2] || '').trim();
if (heading) { if (heading) {
const normalizedHeading = this.normalizeParagraph(heading);
blocks.push({ blocks.push({
type: 'heading', type: 'heading',
text: this.normalizeParagraph(heading), text: this.markdownToPlainText(normalizedHeading),
layoutText: this.markdownToHtml(this.normalizeParagraph(heading)), layoutText: this.markdownToHtml(normalizedHeading),
role: 'chapter-heading' role: 'chapter-heading'
}); });
} }
@@ -81,10 +83,11 @@ class MarkupParserModule extends BaseModule {
flushParagraph(); flushParagraph();
const heading = (section[1] || section[2] || '').trim(); const heading = (section[1] || section[2] || '').trim();
if (heading) { if (heading) {
const normalizedHeading = this.normalizeParagraph(heading);
blocks.push({ blocks.push({
type: 'heading', type: 'heading',
text: this.normalizeParagraph(heading), text: this.markdownToPlainText(normalizedHeading),
layoutText: this.markdownToHtml(this.normalizeParagraph(heading)), layoutText: this.markdownToHtml(normalizedHeading),
role: 'section-heading' role: 'section-heading'
}); });
} }
@@ -162,7 +165,7 @@ class MarkupParserModule extends BaseModule {
parseParagraph(rawText) { parseParagraph(rawText) {
const inline = this.parseInline(this.normalizeParagraph(rawText)); const inline = this.parseInline(this.normalizeParagraph(rawText));
return { return {
text: inline.text, text: this.markdownToPlainText(inline.text),
layoutText: this.markdownToHtml(inline.text), layoutText: this.markdownToHtml(inline.text),
cueMarkers: inline.cueMarkers cueMarkers: inline.cueMarkers
}; };
@@ -226,6 +229,18 @@ class MarkupParserModule extends BaseModule {
.replace(/_([^_\s][^_]*?)_/g, '<em>$1</em>'); .replace(/_([^_\s][^_]*?)_/g, '<em>$1</em>');
} }
markdownToPlainText(text) {
const plain = String(text || '')
.replace(/\*\*\*([^*]+?)\*\*\*/g, '$1')
.replace(/___([^_]+?)___/g, '$1')
.replace(/\*\*([^*]+?)\*\*/g, '$1')
.replace(/__([^_]+?)__/g, '$1')
.replace(/\*([^*\s][^*]*?)\*/g, '$1')
.replace(/_([^_\s][^_]*?)_/g, '$1');
return this.smartypants(plain).replace(/\s{2,}/g, ' ').trim();
}
smartypants(text) { smartypants(text) {
return String(text) return String(text)
.replace(/---/g, '\u2014') .replace(/---/g, '\u2014')
+24 -4
View File
@@ -286,6 +286,7 @@ class OptionsUIModule extends BaseModule {
const masterVolumeValue = document.createElement('span'); const masterVolumeValue = document.createElement('span');
masterVolumeValue.className = 'slider-value'; masterVolumeValue.className = 'slider-value';
masterVolumeValue.textContent = '100%'; masterVolumeValue.textContent = '100%';
this.elements.masterVolumeValue = masterVolumeValue;
masterVolumeContainer.appendChild(masterVolumeValue); masterVolumeContainer.appendChild(masterVolumeValue);
this.elements.masterVolume = createUIElement('input', { this.elements.masterVolume = createUIElement('input', {
@@ -299,7 +300,7 @@ class OptionsUIModule extends BaseModule {
// Update displayed value when slider changes // Update displayed value when slider changes
this.elements.masterVolume.addEventListener('input', () => { this.elements.masterVolume.addEventListener('input', () => {
masterVolumeValue.textContent = `${this.elements.masterVolume.value}%`; this.elements.masterVolumeValue.textContent = `${this.elements.masterVolume.value}%`;
}); });
audioSection.appendChild(masterVolumeContainer); audioSection.appendChild(masterVolumeContainer);
@@ -315,6 +316,7 @@ class OptionsUIModule extends BaseModule {
const ttsVolumeValue = document.createElement('span'); const ttsVolumeValue = document.createElement('span');
ttsVolumeValue.className = 'slider-value'; ttsVolumeValue.className = 'slider-value';
ttsVolumeValue.textContent = '100%'; ttsVolumeValue.textContent = '100%';
this.elements.ttsVolumeValue = ttsVolumeValue;
ttsVolumeContainer.appendChild(ttsVolumeValue); ttsVolumeContainer.appendChild(ttsVolumeValue);
this.elements.ttsVolume = createUIElement('input', { this.elements.ttsVolume = createUIElement('input', {
@@ -328,7 +330,7 @@ class OptionsUIModule extends BaseModule {
// Update displayed value when slider changes // Update displayed value when slider changes
this.elements.ttsVolume.addEventListener('input', () => { this.elements.ttsVolume.addEventListener('input', () => {
ttsVolumeValue.textContent = `${this.elements.ttsVolume.value}%`; this.elements.ttsVolumeValue.textContent = `${this.elements.ttsVolume.value}%`;
}); });
audioSection.appendChild(ttsVolumeContainer); audioSection.appendChild(ttsVolumeContainer);
@@ -344,6 +346,7 @@ class OptionsUIModule extends BaseModule {
const musicVolumeValue = document.createElement('span'); const musicVolumeValue = document.createElement('span');
musicVolumeValue.className = 'slider-value'; musicVolumeValue.className = 'slider-value';
musicVolumeValue.textContent = '100%'; musicVolumeValue.textContent = '100%';
this.elements.musicVolumeValue = musicVolumeValue;
musicVolumeContainer.appendChild(musicVolumeValue); musicVolumeContainer.appendChild(musicVolumeValue);
this.elements.musicVolume = createUIElement('input', { this.elements.musicVolume = createUIElement('input', {
@@ -357,7 +360,7 @@ class OptionsUIModule extends BaseModule {
// Update displayed value when slider changes // Update displayed value when slider changes
this.elements.musicVolume.addEventListener('input', () => { this.elements.musicVolume.addEventListener('input', () => {
musicVolumeValue.textContent = `${this.elements.musicVolume.value}%`; this.elements.musicVolumeValue.textContent = `${this.elements.musicVolume.value}%`;
}); });
audioSection.appendChild(musicVolumeContainer); audioSection.appendChild(musicVolumeContainer);
@@ -373,6 +376,7 @@ class OptionsUIModule extends BaseModule {
const sfxVolumeValue = document.createElement('span'); const sfxVolumeValue = document.createElement('span');
sfxVolumeValue.className = 'slider-value'; sfxVolumeValue.className = 'slider-value';
sfxVolumeValue.textContent = '100%'; sfxVolumeValue.textContent = '100%';
this.elements.sfxVolumeValue = sfxVolumeValue;
sfxVolumeContainer.appendChild(sfxVolumeValue); sfxVolumeContainer.appendChild(sfxVolumeValue);
this.elements.sfxVolume = createUIElement('input', { this.elements.sfxVolume = createUIElement('input', {
@@ -386,7 +390,7 @@ class OptionsUIModule extends BaseModule {
// Update displayed value when slider changes // Update displayed value when slider changes
this.elements.sfxVolume.addEventListener('input', () => { this.elements.sfxVolume.addEventListener('input', () => {
sfxVolumeValue.textContent = `${this.elements.sfxVolume.value}%`; this.elements.sfxVolumeValue.textContent = `${this.elements.sfxVolume.value}%`;
}); });
audioSection.appendChild(sfxVolumeContainer); audioSection.appendChild(sfxVolumeContainer);
@@ -839,6 +843,7 @@ class OptionsUIModule extends BaseModule {
this.bindings = persistenceManager.setupBindings('#options-modal'); this.bindings = persistenceManager.setupBindings('#options-modal');
console.log('Options UI: Preference bindings set up', this.bindings.length); console.log('Options UI: Preference bindings set up', this.bindings.length);
this.updateSpeedDisplay(); this.updateSpeedDisplay();
this.updateVolumeDisplays();
// Add event listeners for side effects when preferences change // Add event listeners for side effects when preferences change
document.addEventListener('preference-updated', (event) => { document.addEventListener('preference-updated', (event) => {
@@ -929,6 +934,21 @@ class OptionsUIModule extends BaseModule {
this.elements.ttsSpeedValue.textContent = `${this.elements.ttsSpeed.value}%`; this.elements.ttsSpeedValue.textContent = `${this.elements.ttsSpeed.value}%`;
} }
updateVolumeDisplays() {
if (this.elements.masterVolume && this.elements.masterVolumeValue) {
this.elements.masterVolumeValue.textContent = `${this.elements.masterVolume.value}%`;
}
if (this.elements.ttsVolume && this.elements.ttsVolumeValue) {
this.elements.ttsVolumeValue.textContent = `${this.elements.ttsVolume.value}%`;
}
if (this.elements.musicVolume && this.elements.musicVolumeValue) {
this.elements.musicVolumeValue.textContent = `${this.elements.musicVolume.value}%`;
}
if (this.elements.sfxVolume && this.elements.sfxVolumeValue) {
this.elements.sfxVolumeValue.textContent = `${this.elements.sfxVolume.value}%`;
}
}
} }
// Create the singleton instance // Create the singleton instance
+9 -3
View File
@@ -203,12 +203,13 @@ class PlaybackCoordinatorModule extends BaseModule {
animQueue.schedule(() => { animQueue.schedule(() => {
const word = wordElements[i]; const word = wordElements[i];
const duration = Math.max(0, timing.duration || 0); const duration = Math.max(0, timing.duration || 0);
const transitionDuration = `${duration}ms`; word.style.transition = 'none';
word.style.animation = 'none';
word.style.transition = `opacity ${transitionDuration} linear, transform ${transitionDuration} ease-out`;
word.style.visibility = 'visible'; word.style.visibility = 'visible';
word.style.opacity = '1'; word.style.opacity = '1';
word.style.transform = 'translateY(0)'; word.style.transform = 'translateY(0)';
word.style.clipPath = 'inset(0 100% 0 0)';
word.style.animation = `wordReveal ${duration}ms linear forwards`;
}, timing.delay); }, timing.delay);
} }
}); });
@@ -309,8 +310,10 @@ class PlaybackCoordinatorModule extends BaseModule {
if (this.currentSentence.element) { if (this.currentSentence.element) {
const wordElements = this.currentSentence.element.querySelectorAll('.word'); const wordElements = this.currentSentence.element.querySelectorAll('.word');
wordElements.forEach(word => { wordElements.forEach(word => {
word.style.animation = 'none';
word.style.opacity = '1'; word.style.opacity = '1';
word.style.transform = 'translateY(0)'; word.style.transform = 'translateY(0)';
word.style.clipPath = 'inset(0 0 0 0)';
}); });
} }
} }
@@ -338,6 +341,9 @@ class PlaybackCoordinatorModule extends BaseModule {
this.isPlaying = false; this.isPlaying = false;
this.currentSentence = null; this.currentSentence = null;
document.dispatchEvent(new CustomEvent('tts:playback-end', {
detail: { reason: 'playback-coordinator-stop' }
}));
} }
} }
+24 -6
View File
@@ -31,7 +31,8 @@ class SentenceQueueModule extends BaseModule {
'extractWords', 'extractWords',
'getDropCapText', 'getDropCapText',
'extractDropCapText', 'extractDropCapText',
'calculateAnimationTiming' 'calculateAnimationTiming',
'clear'
]); ]);
} }
@@ -266,7 +267,7 @@ class SentenceQueueModule extends BaseModule {
const metadata = typeof item === 'object' && item !== null ? item : {}; const metadata = typeof item === 'object' && item !== null ? item : {};
try { try {
if (metadata.type && metadata.type !== 'paragraph') { if (metadata.type && !['paragraph', 'heading'].includes(metadata.type)) {
if (metadata.type === 'music') { if (metadata.type === 'music') {
const audioManager = this.getModule('audio-manager'); const audioManager = this.getModule('audio-manager');
if (audioManager && typeof audioManager.playMusic === 'function') { if (audioManager && typeof audioManager.playMusic === 'function') {
@@ -306,10 +307,11 @@ class SentenceQueueModule extends BaseModule {
return { return {
id, id,
kind: metadata.type === 'heading' ? 'heading' : 'paragraph',
text, text,
paragraphIndex: metadata.paragraphIndex ?? null, paragraphIndex: metadata.paragraphIndex ?? null,
isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter), isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter),
role: metadata.role || 'body', role: metadata.role || (metadata.type === 'heading' ? 'chapter-heading' : 'body'),
dropCap: Boolean(metadata.dropCap), dropCap: Boolean(metadata.dropCap),
addTopSpace: Boolean(metadata.addTopSpace), addTopSpace: Boolean(metadata.addTopSpace),
cueMarkers: metadata.cueMarkers || [], cueMarkers: metadata.cueMarkers || [],
@@ -375,14 +377,17 @@ class SentenceQueueModule extends BaseModule {
// Standard book indentation: no indent on the first chapter paragraph, // Standard book indentation: no indent on the first chapter paragraph,
// first-line indent on following paragraphs. // first-line indent on following paragraphs.
const isHeading = metadata.type === 'heading' || metadata.role === 'chapter-heading' || metadata.role === 'section-heading';
const dropCapLines = metadata.dropCap ? 2 : 0; const dropCapLines = metadata.dropCap ? 2 : 0;
const dropCapWidth = metadata.dropCap ? lineHeight * 1.45 : 0; const dropCapWidth = metadata.dropCap ? lineHeight * 1.45 : 0;
const indentWidth = (metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5; const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5;
const layoutText = metadata.layoutText || text; const layoutText = metadata.layoutText || text;
const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText; const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText;
// Measures are consumed in line order by the line breaker. // Measures are consumed in line order by the line breaker.
const measures = metadata.dropCap const measures = isHeading
? [containerWidth]
: metadata.dropCap
? [ ? [
Math.max(120, containerWidth - dropCapWidth), Math.max(120, containerWidth - dropCapWidth),
Math.max(120, containerWidth - dropCapWidth), Math.max(120, containerWidth - dropCapWidth),
@@ -419,7 +424,8 @@ class SentenceQueueModule extends BaseModule {
dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '', dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '',
dropCapLines, dropCapLines,
addTopSpace: Boolean(metadata.addTopSpace), addTopSpace: Boolean(metadata.addTopSpace),
role: metadata.role || 'body', role: metadata.role || (isHeading ? 'chapter-heading' : 'body'),
align: isHeading ? 'center' : 'justify',
fontSize: layout.fontSize, fontSize: layout.fontSize,
fontFamily: layout.fontFamily, fontFamily: layout.fontFamily,
lineHeight: layout.lineHeight, lineHeight: layout.lineHeight,
@@ -535,6 +541,18 @@ class SentenceQueueModule extends BaseModule {
this.processNextSentence(); this.processNextSentence();
} }
} }
clear() {
this.sentenceQueue = [];
this.isProcessing = false;
this.preparedCache.clear();
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
detail: { reason: 'sentence-queue-cleared' }
}));
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'sentence-queue-cleared' }
}));
}
} }
// Create the singleton instance // Create the singleton instance
+2
View File
@@ -661,7 +661,9 @@ class UIControllerModule extends BaseModule {
} }
} }
if (typeof state.gameStarted === 'boolean') {
document.body.dataset.gameRunning = state.gameStarted ? 'true' : 'false'; document.body.dataset.gameRunning = state.gameStarted ? 'true' : 'false';
}
// Update speech toggle button state // Update speech toggle button state
if (speechToggle) { if (speechToggle) {
+14 -33
View File
@@ -33,7 +33,6 @@ class UIDisplayHandlerModule extends BaseModule {
'initializeContainers', 'initializeContainers',
'displayText', 'displayText',
'renderSentence', 'renderSentence',
'renderHeading',
'handleDeferredMediaBlock', 'handleDeferredMediaBlock',
'rerenderStory', 'rerenderStory',
'clear', 'clear',
@@ -356,9 +355,6 @@ class UIDisplayHandlerModule extends BaseModule {
*/ */
async renderSentence(sentence) { async renderSentence(sentence) {
if (!sentence || !sentence.layout) { if (!sentence || !sentence.layout) {
if (sentence && sentence.kind === 'heading') {
return this.renderHeading(sentence);
}
if (sentence && (sentence.kind === 'image' || sentence.kind === 'music')) { if (sentence && (sentence.kind === 'image' || sentence.kind === 'music')) {
return this.handleDeferredMediaBlock(sentence); return this.handleDeferredMediaBlock(sentence);
} }
@@ -387,7 +383,7 @@ class UIDisplayHandlerModule extends BaseModule {
// Store element reference in sentence // Store element reference in sentence
sentence.element = paragraphElement; sentence.element = paragraphElement;
this.renderedItems.push({ this.renderedItems.push({
type: 'paragraph', type: sentence.kind === 'heading' ? 'heading' : 'paragraph',
id: sentence.id, id: sentence.id,
text: sentence.text, text: sentence.text,
metadata: { metadata: {
@@ -401,7 +397,7 @@ class UIDisplayHandlerModule extends BaseModule {
} }
}); });
// Start coordinated playback (animation + TTS) // Start coordinated playback (animation + TTS), including chapter headings.
await this.playbackCoordinator.play(sentence); await this.playbackCoordinator.play(sentence);
// Scroll to bottom // Scroll to bottom
@@ -422,29 +418,6 @@ class UIDisplayHandlerModule extends BaseModule {
} }
} }
async renderHeading(sentence) {
const heading = document.createElement('p');
heading.id = sentence.id;
heading.className = 'story-chapter-heading';
heading.innerHTML = sentence.metadata?.layoutText || sentence.text;
this.renderedItems.push({
type: 'heading',
id: sentence.id,
text: sentence.text,
layoutText: sentence.metadata?.layoutText || sentence.text
});
if (this.paragraphContainer) {
this.paragraphContainer.appendChild(heading);
}
if (sentence.onComplete) {
sentence.onComplete();
}
return heading;
}
async rerenderStory() { async rerenderStory() {
if (!this.paragraphContainer || this.renderedItems.length === 0) return; if (!this.paragraphContainer || this.renderedItems.length === 0) return;
@@ -457,10 +430,16 @@ class UIDisplayHandlerModule extends BaseModule {
for (const item of this.renderedItems) { for (const item of this.renderedItems) {
if (item.type === 'heading') { if (item.type === 'heading') {
const heading = document.createElement('p'); const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {});
heading.id = item.id; const heading = this.layoutRenderer.renderParagraph(layout, { id: item.id });
heading.className = 'story-chapter-heading'; heading.querySelectorAll('.word').forEach(word => {
heading.innerHTML = item.layoutText || item.text; word.style.transition = 'none';
word.style.animation = 'none';
word.style.visibility = 'visible';
word.style.opacity = '1';
word.style.transform = 'translateY(0)';
word.style.clipPath = 'inset(0 0 0 0)';
});
this.paragraphContainer.appendChild(heading); this.paragraphContainer.appendChild(heading);
continue; continue;
} }
@@ -471,9 +450,11 @@ class UIDisplayHandlerModule extends BaseModule {
const paragraph = this.layoutRenderer.renderParagraph(layout, { id: item.id }); const paragraph = this.layoutRenderer.renderParagraph(layout, { id: item.id });
paragraph.querySelectorAll('.word').forEach(word => { paragraph.querySelectorAll('.word').forEach(word => {
word.style.transition = 'none'; word.style.transition = 'none';
word.style.animation = 'none';
word.style.visibility = 'visible'; word.style.visibility = 'visible';
word.style.opacity = '1'; word.style.opacity = '1';
word.style.transform = 'translateY(0)'; word.style.transform = 'translateY(0)';
word.style.clipPath = 'inset(0 0 0 0)';
}); });
this.paragraphContainer.appendChild(paragraph); this.paragraphContainer.appendChild(paragraph);
} }
+33 -1
View File
@@ -31,7 +31,9 @@ class UIInputHandlerModule extends BaseModule {
'formatCommandHistory', 'formatCommandHistory',
'resetCursorPosition', 'resetCursorPosition',
'focusInput', 'focusInput',
'setProcessState' 'setProcessState',
'setInputAvailability',
'clearHistory'
]); ]);
console.log('UIInputHandler: Constructor initialized'); console.log('UIInputHandler: Constructor initialized');
@@ -254,6 +256,24 @@ class UIInputHandlerModule extends BaseModule {
} }
console.log(`Cursor process state: ${nextState}`, detail); console.log(`Cursor process state: ${nextState}`, detail);
this.setInputAvailability(nextState === 'ready');
}
setInputAvailability(enabled) {
this.inputEnabled = Boolean(enabled);
const commandInput = document.getElementById('command_input');
if (commandInput) {
commandInput.classList.toggle('fading', !this.inputEnabled);
commandInput.setAttribute('aria-hidden', this.inputEnabled ? 'false' : 'true');
}
if (this.playerInput) {
this.playerInput.disabled = !this.inputEnabled;
this.playerInput.readOnly = !this.inputEnabled;
if (this.inputEnabled && document.body.dataset.gameRunning === 'true') {
this.focusInput();
}
}
} }
applyMouseCursor(state) { applyMouseCursor(state) {
@@ -344,6 +364,7 @@ class UIInputHandlerModule extends BaseModule {
*/ */
submitCommand() { submitCommand() {
if (!this.playerInput || !this.playerInput.value.trim()) return; if (!this.playerInput || !this.playerInput.value.trim()) return;
if (document.body.dataset.gameRunning !== 'true' || !this.inputEnabled) return;
const command = this.playerInput.value.trim(); const command = this.playerInput.value.trim();
console.log(`UIInputHandler: Submitting command: "${command}"`); console.log(`UIInputHandler: Submitting command: "${command}"`);
@@ -368,6 +389,17 @@ class UIInputHandlerModule extends BaseModule {
this.playerInput.focus(); this.playerInput.focus();
} }
clearHistory() {
this.commandHistory = [];
this.historyIndex = -1;
if (!this.commandHistoryElement) {
this.commandHistoryElement = document.getElementById('command_history');
}
if (this.commandHistoryElement) {
this.commandHistoryElement.innerHTML = '';
}
}
/** /**
* Add command to history * Add command to history
* @param {string} command - Command to add to history * @param {string} command - Command to add to history