Fixed Ducking. Refined UI.
This commit is contained in:
@@ -18,6 +18,7 @@ class AudioManagerModule extends BaseModule {
|
||||
this.sfxVolume = 1.0;
|
||||
this.ttsVolume = 1.0;
|
||||
this.musicDuckingFactor = 1.0;
|
||||
this.musicFadeToken = 0;
|
||||
this.activeTtsPlaybackCount = 0;
|
||||
this.ttsQueueEmpty = true;
|
||||
this.pendingMusicPlayback = null;
|
||||
@@ -106,6 +107,11 @@ class AudioManagerModule extends BaseModule {
|
||||
this.duckMusicForSpeech();
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'tts:audio-started', () => {
|
||||
this.ttsQueueEmpty = false;
|
||||
this.duckMusicForSpeech();
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'tts:playback-end', () => {
|
||||
this.activeTtsPlaybackCount = Math.max(0, this.activeTtsPlaybackCount - 1);
|
||||
this.restoreMusicIfSpeechFinished();
|
||||
@@ -116,6 +122,11 @@ class AudioManagerModule extends BaseModule {
|
||||
this.restoreMusicIfSpeechFinished();
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'tts:speechCompleted', () => {
|
||||
this.activeTtsPlaybackCount = Math.max(0, this.activeTtsPlaybackCount - 1);
|
||||
this.restoreMusicIfSpeechFinished();
|
||||
});
|
||||
|
||||
const unlock = () => this.unlockPendingAudio();
|
||||
document.addEventListener('pointerdown', unlock, { passive: true });
|
||||
document.addEventListener('keydown', unlock);
|
||||
@@ -252,6 +263,14 @@ class AudioManagerModule extends BaseModule {
|
||||
audio.pause();
|
||||
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) {
|
||||
this.currentLoop.volume = this.masterVolume * this.musicVolume;
|
||||
this.currentLoop.volume = this.getMusicVolume();
|
||||
}
|
||||
|
||||
if (this.currentMusic) {
|
||||
@@ -329,7 +348,7 @@ class AudioManagerModule extends BaseModule {
|
||||
|
||||
duckMusicForSpeech() {
|
||||
console.log('AudioManager: Ducking music for TTS playback');
|
||||
this.fadeMusicTo(0.7, 500);
|
||||
this.fadeMusicTo(0.3, 500);
|
||||
}
|
||||
|
||||
restoreMusicAfterSpeech() {
|
||||
@@ -350,11 +369,15 @@ class AudioManagerModule extends BaseModule {
|
||||
}
|
||||
|
||||
const audio = this.currentMusic;
|
||||
const token = ++this.musicFadeToken;
|
||||
const startVolume = audio.volume;
|
||||
const targetVolume = this.getUnduckedMusicVolume() * this.musicDuckingFactor;
|
||||
const start = performance.now();
|
||||
|
||||
const tick = () => {
|
||||
if (token !== this.musicFadeToken || this.currentMusic !== audio) {
|
||||
return;
|
||||
}
|
||||
const progress = Math.min(1, (performance.now() - start) / duration);
|
||||
audio.volume = startVolume + ((targetVolume - startVolume) * progress);
|
||||
if (progress < 1) {
|
||||
|
||||
@@ -9,7 +9,7 @@ class GameLoopModule extends BaseModule {
|
||||
super('game-loop', 'Game Loop');
|
||||
|
||||
// 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
|
||||
this.gameState = {
|
||||
@@ -33,6 +33,7 @@ class GameLoopModule extends BaseModule {
|
||||
'requestStartGame',
|
||||
'requestSaveGame',
|
||||
'requestLoadGame',
|
||||
'resetClientPlaybackAndDisplay',
|
||||
'addText'
|
||||
]);
|
||||
}
|
||||
@@ -199,14 +200,7 @@ class GameLoopModule extends BaseModule {
|
||||
const socketClient = this.getModule('socket-client');
|
||||
if (!socketClient) return;
|
||||
|
||||
const uiController = this.getModule('ui-controller');
|
||||
if (uiController) {
|
||||
uiController.clearDisplay();
|
||||
}
|
||||
const textBuffer = this.getModule('text-buffer');
|
||||
if (textBuffer && typeof textBuffer.clear === 'function') {
|
||||
textBuffer.clear();
|
||||
}
|
||||
await this.resetClientPlaybackAndDisplay();
|
||||
const response = await socketClient.newGame();
|
||||
if (!response?.success) {
|
||||
console.error('GameLoop: newGame failed', response);
|
||||
@@ -246,14 +240,7 @@ class GameLoopModule extends BaseModule {
|
||||
return;
|
||||
}
|
||||
|
||||
const uiController = this.getModule('ui-controller');
|
||||
if (uiController) {
|
||||
uiController.clearDisplay();
|
||||
}
|
||||
const textBuffer = this.getModule('text-buffer');
|
||||
if (textBuffer && typeof textBuffer.clear === 'function') {
|
||||
textBuffer.clear();
|
||||
}
|
||||
await this.resetClientPlaybackAndDisplay();
|
||||
const response = await socketClient.loadGame(1);
|
||||
if (response?.success) {
|
||||
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
|
||||
* Useful for testing or adding local messages
|
||||
|
||||
@@ -128,10 +128,18 @@ class LayoutRendererModule extends BaseModule {
|
||||
for (let i = 1; i < breaks.length; i++) {
|
||||
const lineIndex = i - 1;
|
||||
const lineWidth = measures[Math.min(lineIndex, measures.length - 1)];
|
||||
const lineOffset = maxLineWidth - lineWidth;
|
||||
const currentBreak = breaks[i];
|
||||
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;
|
||||
lastChild = null;
|
||||
@@ -175,6 +183,7 @@ class LayoutRendererModule extends BaseModule {
|
||||
word.style.left = `${leftPercent}%`;
|
||||
word.style.opacity = '0'; // Hidden until animated
|
||||
word.style.visibility = 'hidden';
|
||||
word.style.clipPath = 'inset(0 100% 0 0)';
|
||||
syllable = node.value;
|
||||
word.innerHTML = syllable;
|
||||
lastChild = word;
|
||||
@@ -244,6 +253,7 @@ class LayoutRendererModule extends BaseModule {
|
||||
word.style.left = `${leftPercent}%`;
|
||||
word.style.opacity = '0';
|
||||
word.style.visibility = 'hidden';
|
||||
word.style.clipPath = 'inset(0 100% 0 0)';
|
||||
word.innerHTML = "-";
|
||||
stack[stack.length - 1].appendChild(word);
|
||||
}
|
||||
@@ -253,6 +263,20 @@ class LayoutRendererModule extends BaseModule {
|
||||
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.
|
||||
* Keep this hook for callers that still invoke it, but do not reflow the
|
||||
|
||||
@@ -20,6 +20,7 @@ class MarkupParserModule extends BaseModule {
|
||||
'parseInline',
|
||||
'parseMusicOptions',
|
||||
'markdownToHtml',
|
||||
'markdownToPlainText',
|
||||
'smartypants',
|
||||
'escapeHtml',
|
||||
'normalizeParagraph',
|
||||
@@ -65,10 +66,11 @@ class MarkupParserModule extends BaseModule {
|
||||
flushParagraph();
|
||||
const heading = (chapter[1] || chapter[2] || '').trim();
|
||||
if (heading) {
|
||||
const normalizedHeading = this.normalizeParagraph(heading);
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
text: this.normalizeParagraph(heading),
|
||||
layoutText: this.markdownToHtml(this.normalizeParagraph(heading)),
|
||||
text: this.markdownToPlainText(normalizedHeading),
|
||||
layoutText: this.markdownToHtml(normalizedHeading),
|
||||
role: 'chapter-heading'
|
||||
});
|
||||
}
|
||||
@@ -81,10 +83,11 @@ class MarkupParserModule extends BaseModule {
|
||||
flushParagraph();
|
||||
const heading = (section[1] || section[2] || '').trim();
|
||||
if (heading) {
|
||||
const normalizedHeading = this.normalizeParagraph(heading);
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
text: this.normalizeParagraph(heading),
|
||||
layoutText: this.markdownToHtml(this.normalizeParagraph(heading)),
|
||||
text: this.markdownToPlainText(normalizedHeading),
|
||||
layoutText: this.markdownToHtml(normalizedHeading),
|
||||
role: 'section-heading'
|
||||
});
|
||||
}
|
||||
@@ -162,7 +165,7 @@ class MarkupParserModule extends BaseModule {
|
||||
parseParagraph(rawText) {
|
||||
const inline = this.parseInline(this.normalizeParagraph(rawText));
|
||||
return {
|
||||
text: inline.text,
|
||||
text: this.markdownToPlainText(inline.text),
|
||||
layoutText: this.markdownToHtml(inline.text),
|
||||
cueMarkers: inline.cueMarkers
|
||||
};
|
||||
@@ -226,6 +229,18 @@ class MarkupParserModule extends BaseModule {
|
||||
.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) {
|
||||
return String(text)
|
||||
.replace(/---/g, '\u2014')
|
||||
|
||||
@@ -286,6 +286,7 @@ class OptionsUIModule extends BaseModule {
|
||||
const masterVolumeValue = document.createElement('span');
|
||||
masterVolumeValue.className = 'slider-value';
|
||||
masterVolumeValue.textContent = '100%';
|
||||
this.elements.masterVolumeValue = masterVolumeValue;
|
||||
masterVolumeContainer.appendChild(masterVolumeValue);
|
||||
|
||||
this.elements.masterVolume = createUIElement('input', {
|
||||
@@ -299,7 +300,7 @@ class OptionsUIModule extends BaseModule {
|
||||
|
||||
// Update displayed value when slider changes
|
||||
this.elements.masterVolume.addEventListener('input', () => {
|
||||
masterVolumeValue.textContent = `${this.elements.masterVolume.value}%`;
|
||||
this.elements.masterVolumeValue.textContent = `${this.elements.masterVolume.value}%`;
|
||||
});
|
||||
|
||||
audioSection.appendChild(masterVolumeContainer);
|
||||
@@ -315,6 +316,7 @@ class OptionsUIModule extends BaseModule {
|
||||
const ttsVolumeValue = document.createElement('span');
|
||||
ttsVolumeValue.className = 'slider-value';
|
||||
ttsVolumeValue.textContent = '100%';
|
||||
this.elements.ttsVolumeValue = ttsVolumeValue;
|
||||
ttsVolumeContainer.appendChild(ttsVolumeValue);
|
||||
|
||||
this.elements.ttsVolume = createUIElement('input', {
|
||||
@@ -328,7 +330,7 @@ class OptionsUIModule extends BaseModule {
|
||||
|
||||
// Update displayed value when slider changes
|
||||
this.elements.ttsVolume.addEventListener('input', () => {
|
||||
ttsVolumeValue.textContent = `${this.elements.ttsVolume.value}%`;
|
||||
this.elements.ttsVolumeValue.textContent = `${this.elements.ttsVolume.value}%`;
|
||||
});
|
||||
|
||||
audioSection.appendChild(ttsVolumeContainer);
|
||||
@@ -344,6 +346,7 @@ class OptionsUIModule extends BaseModule {
|
||||
const musicVolumeValue = document.createElement('span');
|
||||
musicVolumeValue.className = 'slider-value';
|
||||
musicVolumeValue.textContent = '100%';
|
||||
this.elements.musicVolumeValue = musicVolumeValue;
|
||||
musicVolumeContainer.appendChild(musicVolumeValue);
|
||||
|
||||
this.elements.musicVolume = createUIElement('input', {
|
||||
@@ -357,7 +360,7 @@ class OptionsUIModule extends BaseModule {
|
||||
|
||||
// Update displayed value when slider changes
|
||||
this.elements.musicVolume.addEventListener('input', () => {
|
||||
musicVolumeValue.textContent = `${this.elements.musicVolume.value}%`;
|
||||
this.elements.musicVolumeValue.textContent = `${this.elements.musicVolume.value}%`;
|
||||
});
|
||||
|
||||
audioSection.appendChild(musicVolumeContainer);
|
||||
@@ -373,6 +376,7 @@ class OptionsUIModule extends BaseModule {
|
||||
const sfxVolumeValue = document.createElement('span');
|
||||
sfxVolumeValue.className = 'slider-value';
|
||||
sfxVolumeValue.textContent = '100%';
|
||||
this.elements.sfxVolumeValue = sfxVolumeValue;
|
||||
sfxVolumeContainer.appendChild(sfxVolumeValue);
|
||||
|
||||
this.elements.sfxVolume = createUIElement('input', {
|
||||
@@ -386,7 +390,7 @@ class OptionsUIModule extends BaseModule {
|
||||
|
||||
// Update displayed value when slider changes
|
||||
this.elements.sfxVolume.addEventListener('input', () => {
|
||||
sfxVolumeValue.textContent = `${this.elements.sfxVolume.value}%`;
|
||||
this.elements.sfxVolumeValue.textContent = `${this.elements.sfxVolume.value}%`;
|
||||
});
|
||||
|
||||
audioSection.appendChild(sfxVolumeContainer);
|
||||
@@ -839,6 +843,7 @@ class OptionsUIModule extends BaseModule {
|
||||
this.bindings = persistenceManager.setupBindings('#options-modal');
|
||||
console.log('Options UI: Preference bindings set up', this.bindings.length);
|
||||
this.updateSpeedDisplay();
|
||||
this.updateVolumeDisplays();
|
||||
|
||||
// Add event listeners for side effects when preferences change
|
||||
document.addEventListener('preference-updated', (event) => {
|
||||
@@ -929,6 +934,21 @@ class OptionsUIModule extends BaseModule {
|
||||
|
||||
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
|
||||
|
||||
@@ -203,12 +203,13 @@ class PlaybackCoordinatorModule extends BaseModule {
|
||||
animQueue.schedule(() => {
|
||||
const word = wordElements[i];
|
||||
const duration = Math.max(0, timing.duration || 0);
|
||||
const transitionDuration = `${duration}ms`;
|
||||
|
||||
word.style.transition = `opacity ${transitionDuration} linear, transform ${transitionDuration} ease-out`;
|
||||
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 100% 0 0)';
|
||||
word.style.animation = `wordReveal ${duration}ms linear forwards`;
|
||||
}, timing.delay);
|
||||
}
|
||||
});
|
||||
@@ -309,8 +310,10 @@ class PlaybackCoordinatorModule extends BaseModule {
|
||||
if (this.currentSentence.element) {
|
||||
const wordElements = this.currentSentence.element.querySelectorAll('.word');
|
||||
wordElements.forEach(word => {
|
||||
word.style.animation = 'none';
|
||||
word.style.opacity = '1';
|
||||
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.currentSentence = null;
|
||||
document.dispatchEvent(new CustomEvent('tts:playback-end', {
|
||||
detail: { reason: 'playback-coordinator-stop' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ class SentenceQueueModule extends BaseModule {
|
||||
'extractWords',
|
||||
'getDropCapText',
|
||||
'extractDropCapText',
|
||||
'calculateAnimationTiming'
|
||||
'calculateAnimationTiming',
|
||||
'clear'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -266,7 +267,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
const metadata = typeof item === 'object' && item !== null ? item : {};
|
||||
|
||||
try {
|
||||
if (metadata.type && metadata.type !== 'paragraph') {
|
||||
if (metadata.type && !['paragraph', 'heading'].includes(metadata.type)) {
|
||||
if (metadata.type === 'music') {
|
||||
const audioManager = this.getModule('audio-manager');
|
||||
if (audioManager && typeof audioManager.playMusic === 'function') {
|
||||
@@ -306,10 +307,11 @@ class SentenceQueueModule extends BaseModule {
|
||||
|
||||
return {
|
||||
id,
|
||||
kind: metadata.type === 'heading' ? 'heading' : 'paragraph',
|
||||
text,
|
||||
paragraphIndex: metadata.paragraphIndex ?? null,
|
||||
isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter),
|
||||
role: metadata.role || 'body',
|
||||
role: metadata.role || (metadata.type === 'heading' ? 'chapter-heading' : 'body'),
|
||||
dropCap: Boolean(metadata.dropCap),
|
||||
addTopSpace: Boolean(metadata.addTopSpace),
|
||||
cueMarkers: metadata.cueMarkers || [],
|
||||
@@ -375,14 +377,17 @@ class SentenceQueueModule extends BaseModule {
|
||||
|
||||
// Standard book indentation: no indent on the first chapter paragraph,
|
||||
// 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 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 layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText;
|
||||
|
||||
// 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),
|
||||
@@ -419,7 +424,8 @@ class SentenceQueueModule extends BaseModule {
|
||||
dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '',
|
||||
dropCapLines,
|
||||
addTopSpace: Boolean(metadata.addTopSpace),
|
||||
role: metadata.role || 'body',
|
||||
role: metadata.role || (isHeading ? 'chapter-heading' : 'body'),
|
||||
align: isHeading ? 'center' : 'justify',
|
||||
fontSize: layout.fontSize,
|
||||
fontFamily: layout.fontFamily,
|
||||
lineHeight: layout.lineHeight,
|
||||
@@ -535,6 +541,18 @@ class SentenceQueueModule extends BaseModule {
|
||||
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
|
||||
|
||||
@@ -661,7 +661,9 @@ class UIControllerModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof state.gameStarted === 'boolean') {
|
||||
document.body.dataset.gameRunning = state.gameStarted ? 'true' : 'false';
|
||||
}
|
||||
|
||||
// Update speech toggle button state
|
||||
if (speechToggle) {
|
||||
|
||||
@@ -33,7 +33,6 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
'initializeContainers',
|
||||
'displayText',
|
||||
'renderSentence',
|
||||
'renderHeading',
|
||||
'handleDeferredMediaBlock',
|
||||
'rerenderStory',
|
||||
'clear',
|
||||
@@ -356,9 +355,6 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
*/
|
||||
async renderSentence(sentence) {
|
||||
if (!sentence || !sentence.layout) {
|
||||
if (sentence && sentence.kind === 'heading') {
|
||||
return this.renderHeading(sentence);
|
||||
}
|
||||
if (sentence && (sentence.kind === 'image' || sentence.kind === 'music')) {
|
||||
return this.handleDeferredMediaBlock(sentence);
|
||||
}
|
||||
@@ -387,7 +383,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
// Store element reference in sentence
|
||||
sentence.element = paragraphElement;
|
||||
this.renderedItems.push({
|
||||
type: 'paragraph',
|
||||
type: sentence.kind === 'heading' ? 'heading' : 'paragraph',
|
||||
id: sentence.id,
|
||||
text: sentence.text,
|
||||
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);
|
||||
|
||||
// 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() {
|
||||
if (!this.paragraphContainer || this.renderedItems.length === 0) return;
|
||||
|
||||
@@ -457,10 +430,16 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
for (const item of this.renderedItems) {
|
||||
if (item.type === 'heading') {
|
||||
const heading = document.createElement('p');
|
||||
heading.id = item.id;
|
||||
heading.className = 'story-chapter-heading';
|
||||
heading.innerHTML = item.layoutText || item.text;
|
||||
const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {});
|
||||
const heading = this.layoutRenderer.renderParagraph(layout, { id: item.id });
|
||||
heading.querySelectorAll('.word').forEach(word => {
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
@@ -471,9 +450,11 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
const paragraph = this.layoutRenderer.renderParagraph(layout, { id: item.id });
|
||||
paragraph.querySelectorAll('.word').forEach(word => {
|
||||
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(paragraph);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ class UIInputHandlerModule extends BaseModule {
|
||||
'formatCommandHistory',
|
||||
'resetCursorPosition',
|
||||
'focusInput',
|
||||
'setProcessState'
|
||||
'setProcessState',
|
||||
'setInputAvailability',
|
||||
'clearHistory'
|
||||
]);
|
||||
|
||||
console.log('UIInputHandler: Constructor initialized');
|
||||
@@ -254,6 +256,24 @@ class UIInputHandlerModule extends BaseModule {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -344,6 +364,7 @@ class UIInputHandlerModule extends BaseModule {
|
||||
*/
|
||||
submitCommand() {
|
||||
if (!this.playerInput || !this.playerInput.value.trim()) return;
|
||||
if (document.body.dataset.gameRunning !== 'true' || !this.inputEnabled) return;
|
||||
|
||||
const command = this.playerInput.value.trim();
|
||||
console.log(`UIInputHandler: Submitting command: "${command}"`);
|
||||
@@ -368,6 +389,17 @@ class UIInputHandlerModule extends BaseModule {
|
||||
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
|
||||
* @param {string} command - Command to add to history
|
||||
|
||||
Reference in New Issue
Block a user