diff --git a/public/js/audio-manager-module.js b/public/js/audio-manager-module.js index e1e15cb..8c0bffb 100644 --- a/public/js/audio-manager-module.js +++ b/public/js/audio-manager-module.js @@ -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) { diff --git a/public/js/game-loop-module.js b/public/js/game-loop-module.js index 6799423..b5e2668 100644 --- a/public/js/game-loop-module.js +++ b/public/js/game-loop-module.js @@ -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; @@ -262,6 +249,48 @@ class GameLoopModule extends BaseModule { this.updateUIState(); } } + + 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 diff --git a/public/js/layout-renderer-module.js b/public/js/layout-renderer-module.js index fbf7864..e928765 100644 --- a/public/js/layout-renderer-module.js +++ b/public/js/layout-renderer-module.js @@ -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 diff --git a/public/js/markup-parser-module.js b/public/js/markup-parser-module.js index cbb920a..f3c6a77 100644 --- a/public/js/markup-parser-module.js +++ b/public/js/markup-parser-module.js @@ -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, '$1'); } + 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') diff --git a/public/js/options-ui-module.js b/public/js/options-ui-module.js index 0d3164e..63a562b 100644 --- a/public/js/options-ui-module.js +++ b/public/js/options-ui-module.js @@ -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 diff --git a/public/js/playback-coordinator-module.js b/public/js/playback-coordinator-module.js index 62376e6..c6abc56 100644 --- a/public/js/playback-coordinator-module.js +++ b/public/js/playback-coordinator-module.js @@ -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' } + })); } } diff --git a/public/js/sentence-queue-module.js b/public/js/sentence-queue-module.js index 4009968..4e02e22 100644 --- a/public/js/sentence-queue-module.js +++ b/public/js/sentence-queue-module.js @@ -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 diff --git a/public/js/ui-controller-module.js b/public/js/ui-controller-module.js index 96d921f..a14f225 100644 --- a/public/js/ui-controller-module.js +++ b/public/js/ui-controller-module.js @@ -661,7 +661,9 @@ class UIControllerModule extends BaseModule { } } - document.body.dataset.gameRunning = state.gameStarted ? 'true' : 'false'; + if (typeof state.gameStarted === 'boolean') { + document.body.dataset.gameRunning = state.gameStarted ? 'true' : 'false'; + } // Update speech toggle button state if (speechToggle) { diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index 51d9653..0446d43 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -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); } diff --git a/public/js/ui-input-handler-module.js b/public/js/ui-input-handler-module.js index 8d05308..0fad2cc 100644 --- a/public/js/ui-input-handler-module.js +++ b/public/js/ui-input-handler-module.js @@ -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}"`); @@ -367,6 +388,17 @@ class UIInputHandlerModule extends BaseModule { // Focus input field 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