import { BaseModule } from './base-module.js'; import { ModuleEvent } from './base-module.js'; class UIControllerModule extends BaseModule { constructor() { super('ui-controller', 'UI Controller'); // Remove 'tts' from direct dependencies to break circular dependency // UI Controller will access TTS through the Game Loop instead this.dependencies = ['animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects', 'text-buffer', 'socket-client', 'sentence-queue', 'playback-coordinator']; // References to sub-modules this.displayHandler = null; this.inputHandler = null; this.effects = null; // UI state this.isReady = false; this.isVisible = false; // Book interface elements this.bookElement = null; this.leftPage = null; this.rightPage = null; this.storyElement = null; // Additional module references this.textBuffer = null; this.ttsHandler = null; this.socketClient = null; this.animationQueue = null; // Add TTS toggle state this.ttsEnabled = false; this.ttsAvailable = true; // Add TTS availability state // Bind methods using the parent class bindMethods utility this.bindMethods([ 'initialize', 'handleCommand', 'displayText', 'setupBookInterface', 'applyBookSizing', 'setupEventListeners', 'setupMainUI', 'bindTopControls', 'syncTopControls', 'getStoredTtsPreference', 'setStoredTtsPreference', 'sliderValueFromSpeed', 'speedFromSliderValue', 'initializeTextBuffer', 'showUI', 'hideUI', 'clearDisplay', 'sendCommand', 'updateButtonStates' ]); } async initialize() { try { this.reportProgress(0, 'Initializing UI Controller'); this.reportProgress(20, 'Setting up book interface'); // Set up book interface this.setupBookInterface(); this.reportProgress(30, 'Getting module dependencies'); // Get module references using parent's getModule method this.displayHandler = this.getModule('ui-display-handler'); this.inputHandler = this.getModule('ui-input-handler'); this.effects = this.getModule('ui-effects'); this.textBuffer = this.getModule('text-buffer'); this.socketClient = this.getModule('socket-client'); this.animationQueue = this.getModule('animation-queue'); // Check for required UI modules if (!this.displayHandler) { console.error('UI Controller: Display handler module not found'); return false; } if (!this.inputHandler) { console.error('UI Controller: Input handler module not found'); return false; } if (!this.effects) { console.error('UI Controller: UI effects module not found'); return false; } // Check for other required modules if (!this.textBuffer) { console.error('UI Controller: Text buffer module not found'); return false; } if (!this.socketClient) { console.error('UI Controller: Socket client module not found'); return false; } if (!this.animationQueue) { console.error('UI Controller: Animation queue module not found'); return false; } this.reportProgress(50, 'Setting up main UI'); // Initialize main UI container await this.setupMainUI(); this.reportProgress(70, 'Setting up event listeners'); // Set up event listeners after the display handler has created controls this.setupEventListeners(); this.bindTopControls(); this.syncTopControls(); requestAnimationFrame(() => { this.bindTopControls(); this.syncTopControls(); }); setTimeout(() => { this.bindTopControls(); this.syncTopControls(); }, 250); this.reportProgress(80, 'Initializing text buffer'); // Initialize text buffer handler this.initializeTextBuffer(); this.reportProgress(100, 'UI Controller ready'); this.isReady = true; this.isVisible = true; this.dispatchEvent(new ModuleEvent('ui:ready', { controller: this })); // Start ambient effects this.effects.startAmbientEffects(); return true; } catch (error) { console.error('Error initializing UI Controller:', error); this.changeState('ERROR'); return false; } } setupBookInterface() { // Create or get the book interface elements this.bookElement = document.getElementById('book'); this.leftPage = document.getElementById('page_left'); this.rightPage = document.getElementById('page_right'); this.storyElement = document.getElementById('story'); // Apply book sizing based on viewport this.applyBookSizing(); // Set up window resize handler const handleViewportResize = () => this.applyBookSizing(); window.addEventListener('resize', handleViewportResize); if (window.visualViewport) { window.visualViewport.addEventListener('resize', handleViewportResize); } if (window.ResizeObserver && document.body) { this.bodyResizeObserver = new ResizeObserver(handleViewportResize); this.bodyResizeObserver.observe(document.body); } } applyBookSizing() { const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const viewportAspectRatio = viewportWidth / viewportHeight; const bookWidth = 2727; const bookHeight = 1691; const bookScale = Math.min(viewportWidth / bookWidth, viewportHeight / bookHeight); document.documentElement.style.setProperty('--book-width', `${bookWidth}px`); document.documentElement.style.setProperty('--book-height', `${bookHeight}px`); document.documentElement.style.setProperty('--book-scale', bookScale); document.documentElement.style.setProperty('--viewport-aspect-ratio', viewportAspectRatio); document.documentElement.style.setProperty( '--viewport-dimension', viewportWidth / viewportHeight > bookWidth / bookHeight ? 'vw' : 'vh' ); document.dispatchEvent(new CustomEvent('book:scaled', { detail: { bookWidth, bookHeight, bookScale, displayWidth: bookWidth * bookScale, displayHeight: bookHeight * bookScale, viewportWidth, viewportHeight } })); } setupEventListeners() { // Set up event listeners for menu buttons const saveButton = document.getElementById('save'); const loadButton = document.getElementById('reload'); const restartButton = document.getElementById('rewind'); const optionsButton = document.getElementById('options'); // Get persistence manager module const persistenceManager = this.getModule('persistence-manager'); const ttsFactory = this.getModule('tts-factory'); // Set up save button if (saveButton) { saveButton.addEventListener('click', (event) => { event.preventDefault(); if (saveButton.getAttribute('disabled') === 'disabled') return; document.dispatchEvent(new CustomEvent('ui:game:save')); }); } // Set up load button if (loadButton) { loadButton.addEventListener('click', (event) => { event.preventDefault(); if (loadButton.getAttribute('disabled') === 'disabled') return; document.dispatchEvent(new CustomEvent('ui:game:load')); }); } // Set up restart button if (restartButton) { restartButton.addEventListener('click', (event) => { event.preventDefault(); event.__newGameHandled = true; document.dispatchEvent(new CustomEvent('ui:game:restart')); }); } this.addEventListener(document, 'click', (event) => { if (event.target && event.target.closest && event.target.closest('#rewind')) { event.preventDefault(); if (event.__newGameHandled) return; document.dispatchEvent(new CustomEvent('ui:game:restart')); } }); // Set up options button if (optionsButton) { optionsButton.addEventListener('click', () => { document.dispatchEvent(new CustomEvent('ui:options:toggle')); }); } this.addEventListener(document, 'ui:command', (event) => { if (!event.detail || event.detail.moduleId === this.id) return; this.handleCommand(event.detail); }); this.addEventListener(document, 'click', (event) => { if (event.target && event.target.closest && event.target.closest('#options-modal, #controls, #player_input, #command_input')) { return; } const playbackCoordinator = this.getModule('playback-coordinator'); if (playbackCoordinator && playbackCoordinator.isPlaying) { this.handleCommand({ type: 'continue', source: 'book-click' }); } if (this.inputHandler && typeof this.inputHandler.focusInput === 'function') { this.inputHandler.focusInput(); } }); // Listen for book events document.addEventListener('book:ready', () => { this.bindTopControls(); this.syncTopControls(); this.updateButtonStates({ canSave: true, canLoad: true, canRestart: true }); }); // Listen for restart events document.addEventListener('story:restart', () => { this.updateButtonStates({ canSave: true, canLoad: false, canRestart: false }); }); // Listen for save events document.addEventListener('story:save', () => { this.updateButtonStates({ canSave: true, canLoad: true, canRestart: true }); }); // Listen for TTS availability changes document.addEventListener('tts:availability', (event) => { if (event.detail && typeof event.detail.available === 'boolean') { this.ttsAvailable = event.detail.available; this.updateButtonStates(); } }); // Listen for TTS state changes (from options UI or TTS player) document.addEventListener('tts:stateChange', (event) => { if (event.detail && typeof event.detail.enabled === 'boolean') { this.ttsEnabled = event.detail.enabled; this.updateButtonStates(); // Ensure persistence is updated const currentPersistenceManager = this.getModule('persistence-manager'); if (currentPersistenceManager) { currentPersistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled); } } }); // Listen for TTS engine changes document.addEventListener('tts:engine:change', (event) => { // Update button states since TTS engine changed this.updateButtonStates(); }); // Listen for TTS toggle events from other components document.addEventListener('tts:enabled:change', (event) => { if (event.detail && typeof event.detail.enabled === 'boolean') { this.ttsEnabled = event.detail.enabled; this.updateButtonStates(); // Ensure persistence is updated const currentPersistenceManager = this.getModule('persistence-manager'); if (currentPersistenceManager) { currentPersistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled); } } }); document.addEventListener('preference-updated', (event) => { const { category, key, value } = event.detail || {}; if (category !== 'tts') { return; } if (key === 'enabled') { this.ttsEnabled = value === true; this.syncTopControls(); } else if (key === 'speed') { this.syncTopControls(); } }); // Listen for speed change events from other components document.addEventListener('tts:speed:change', (event) => { if (event.detail && typeof event.detail.speed === 'number') { // Update the main UI speed slider const speedSlider = document.getElementById('speed'); if (speedSlider) { speedSlider.value = this.sliderValueFromSpeed(event.detail.speed); } // Save to persistence manager const currentPersistenceManager = this.getModule('persistence-manager'); if (currentPersistenceManager) { currentPersistenceManager.updatePreference('tts', 'speed', event.detail.speed); } } }); } sliderValueFromSpeed(speed) { const value = Number.isFinite(Number(speed)) ? Number(speed) : 1; return Math.round((Math.max(0.5, Math.min(2.0, value)) * 50) + 50); } speedFromSliderValue(value) { const sliderValue = Number.isFinite(Number(value)) ? Number(value) : 50; return Math.max(0.5, Math.min(2.0, (sliderValue - 50) / 50)); } bindTopControls() { const speechToggle = document.getElementById('speech'); const speedSlider = document.getElementById('speed'); const speedReset = document.getElementById('speed_reset'); if (speechToggle && speechToggle.dataset.uiControllerBound !== 'true') { speechToggle.dataset.uiControllerBound = 'true'; speechToggle.removeAttribute('disabled'); speechToggle.addEventListener('click', async (event) => { event.preventDefault(); event.stopPropagation(); const persistenceManager = this.getModule('persistence-manager'); const ttsFactory = this.getModule('tts-factory'); const currentEnabled = this.getStoredTtsPreference('enabled', this.ttsEnabled); const nextEnabled = !currentEnabled; this.ttsEnabled = nextEnabled; console.log(`UIController: Top speech toggle set to ${nextEnabled ? 'enabled' : 'disabled'}`); this.setStoredTtsPreference('enabled', nextEnabled); if (ttsFactory) { if (nextEnabled) { const preferredHandler = persistenceManager?.getPreference('tts', 'preferred_handler', 'none') || 'none'; if (preferredHandler !== 'none') { await ttsFactory.setActiveHandler(preferredHandler); } } else { await ttsFactory.disableAfterCurrentPlayback(); } } this.syncTopControls(); document.dispatchEvent(new CustomEvent('tts:enabled:change', { detail: { enabled: nextEnabled, source: 'topbar' } })); }); } if (speedSlider && speedSlider.dataset.uiControllerBound !== 'true') { speedSlider.dataset.uiControllerBound = 'true'; speedSlider.min = speedSlider.min || '50'; speedSlider.max = speedSlider.max || '150'; speedSlider.addEventListener('input', (event) => { const persistenceManager = this.getModule('persistence-manager'); const speed = this.speedFromSliderValue(event.target.value); document.dispatchEvent(new CustomEvent('animation:speed:change', { detail: { speed: 1 } })); document.dispatchEvent(new CustomEvent('tts:speed:change', { detail: { speed } })); this.setStoredTtsPreference('speed', speed); }); } if (speedReset && speedReset.dataset.uiControllerBound !== 'true') { speedReset.dataset.uiControllerBound = 'true'; speedReset.addEventListener('click', () => { const slider = document.getElementById('speed'); if (slider) { slider.value = this.sliderValueFromSpeed(1); slider.dispatchEvent(new Event('input')); } }); } } syncTopControls() { this.bindTopControls(); this.ttsEnabled = this.getStoredTtsPreference('enabled', this.ttsEnabled) === true; const speedSlider = document.getElementById('speed'); if (speedSlider) { const speed = this.getStoredTtsPreference('speed', 1); const value = String(this.sliderValueFromSpeed(speed)); if (speedSlider.value !== value) { speedSlider.value = value; } } this.updateButtonStates(); } getStoredTtsPreference(key, defaultValue) { const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager && typeof persistenceManager.getPreference === 'function') { const value = persistenceManager.getPreference('tts', key, undefined); if (typeof value !== 'undefined' && value !== null) { return value; } } try { const raw = localStorage.getItem('ai-interactive-fiction-preferences'); if (raw) { const prefs = JSON.parse(raw); if (prefs && prefs.tts && Object.prototype.hasOwnProperty.call(prefs.tts, key)) { return prefs.tts[key]; } } } catch (error) { console.warn('UIController: Failed to read TTS preference fallback:', error); } return defaultValue; } setStoredTtsPreference(key, value) { const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager && typeof persistenceManager.updatePreference === 'function') { persistenceManager.updatePreference('tts', key, value); } try { const storageKey = 'ai-interactive-fiction-preferences'; const raw = localStorage.getItem(storageKey); const prefs = raw ? JSON.parse(raw) : {}; prefs.tts = prefs.tts || {}; prefs.tts[key] = value; localStorage.setItem(storageKey, JSON.stringify(prefs)); } catch (error) { console.warn('UIController: Failed to write TTS preference fallback:', error); } } async setupMainUI() { // Ensure all UI components exist if (!this.bookElement || !this.leftPage || !this.rightPage || !this.storyElement) { console.log('UI Controller: Creating missing UI elements'); this.displayHandler.setupBookStructure(); // Re-get elements this.bookElement = document.getElementById('book'); this.leftPage = document.getElementById('page_left'); this.rightPage = document.getElementById('page_right'); this.storyElement = document.getElementById('story'); } if (this.inputHandler && typeof this.inputHandler.focusInput === 'function') { requestAnimationFrame(() => this.inputHandler.focusInput()); } } initializeTextBuffer() { // Connect SentenceQueue to UIDisplayHandler const sentenceQueue = this.getModule('sentence-queue'); const displayHandler = this.getModule('ui-display-handler'); if (!sentenceQueue || !displayHandler) { console.error('UIController: Required modules not found (sentence-queue or ui-display-handler)'); return; } console.log('UIController: Setting up SentenceQueue → UIDisplayHandler pipeline'); // Set up callback for when sentences are ready to display sentenceQueue.setOnSentenceReady(async (sentence, callback) => { try { console.log(`UIController: Rendering sentence ${sentence.id}`); await displayHandler.renderSentence(sentence); console.log(`UIController: Sentence ${sentence.id} rendered successfully`); // Signal completion to process next sentence if (typeof callback === 'function') { callback(); } } catch (error) { console.error('UIController: Error rendering sentence:', error); // Still proceed to prevent blocking if (typeof callback === 'function') { callback(); } } }); console.log('UIController: SentenceQueue pipeline configured'); } handleCommand(command) { // Route commands to appropriate handlers switch (command.type) { case 'display': this.displayHandler.processCommand(command); break; case 'effect': this.effects.processCommand(command); break; case 'continue': { const playbackCoordinator = this.getModule('playback-coordinator'); if (playbackCoordinator && playbackCoordinator.isPlaying) { playbackCoordinator.fastForward(); } else if (this.animationQueue) { this.animationQueue.fastForward(); } } break; case 'input': if (this.socketClient) { console.log(`UI Controller: Sending command to socket: "${command.text}"`); const success = this.socketClient.sendCommand(command.text); if (success) { console.log('UI Controller: Command sent successfully'); } else { console.error('UI Controller: Failed to send command to socket'); // Display an error message to the user this.displayHandler.displayText('⚠️ Unable to send command. Server connection might be lost.', { style: { color: '#990000' } }); } } else { console.error('UI Controller: Socket client not available for sending commands'); } break; case 'menu': // Toggle options menu const optionsUI = this.getModule('options-ui'); if (optionsUI) { optionsUI.toggle(); } break; default: // Handle general UI commands or pass to game logic this.dispatchEvent(new ModuleEvent('ui:command', command)); } } /** * Update UI button states based on game state */ updateButtonStates(state = {}) { const { canSave, canLoad, canRestart } = state; // Get button elements const saveButton = document.getElementById('save'); const loadButton = document.getElementById('reload'); const restartButton = document.getElementById('rewind'); const speechToggle = document.getElementById('speech'); // Update save button state if (saveButton && typeof canSave === 'boolean') { if (canSave) { saveButton.removeAttribute('disabled'); } else { saveButton.setAttribute('disabled', 'disabled'); } } // Update load button state if (loadButton && typeof canLoad === 'boolean') { if (canLoad) { loadButton.removeAttribute('disabled'); } else { loadButton.setAttribute('disabled', 'disabled'); } } // Update restart button state if (restartButton && typeof canRestart === 'boolean') { if (canRestart) { restartButton.removeAttribute('disabled'); } else { restartButton.setAttribute('disabled', 'disabled'); } } if (typeof state.gameStarted === 'boolean') { document.body.dataset.gameRunning = state.gameStarted ? 'true' : 'false'; } // Update speech toggle button state if (speechToggle) { // Update the button appearance based on TTS state using existing styles speechToggle.removeAttribute('disabled'); if (this.ttsEnabled) { speechToggle.style.fontWeight = 'bold'; speechToggle.style.color = '#000'; speechToggle.title = this.ttsAvailable ? 'Disable speech' : 'Speech enabled, selected provider is not ready'; } else { speechToggle.style.fontWeight = 'normal'; speechToggle.style.color = '#999'; speechToggle.title = 'Enable speech'; } } } // Public API methods showUI() { if (!this.isVisible) { this.isVisible = true; this.displayHandler.show(); this.effects.startAmbientEffects(); } } hideUI() { if (this.isVisible) { this.isVisible = false; this.displayHandler.hide(); this.effects.stopAmbientEffects(); } } displayText(text, options = {}) { return this.displayHandler.displayText(text, options); } clearDisplay() { this.displayHandler.clear(); } sendCommand(command) { if (this.socketClient) { return this.socketClient.sendCommand(command); } return false; } } // Create the singleton instance const uiController = new UIControllerModule(); // Export the module export { uiController as UIController };