From aa29a6fd93398a090e982f77c7d46be4ef2d1a6f Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Fri, 4 Apr 2025 00:00:43 +0000 Subject: [PATCH] Split everything up into dynamically loaded modules. --- public/favicon.ico | 2 + public/index.html | 276 ++++--- public/js/ai-fiction.js | 756 ------------------ public/js/animation-queue.js | 248 ++++-- public/js/api-tts-handler.js | 280 +++++++ public/js/audio-manager.js | 109 ++- public/js/base-module.js | 134 ++++ public/js/browser-tts-handler.js | 198 +++++ public/js/debug-utils.js | 152 ++++ public/js/game-loop.js | 251 ++++++ public/js/hyphenopoly.module.js | 677 ++++++++++++++++ public/js/ink-story-player.js | 719 ----------------- public/js/input-handler.js | 290 ------- public/js/kokoro-handler.js | 1229 ++++++++++++++++-------------- public/js/kokoro-worker.js | 120 +++ public/js/loader.js | 532 +++++++++++++ public/js/localization.js | 260 +++++++ public/js/module-registry.js | 94 +++ public/js/options-ui.js | 947 +++++++++++++++++++++++ public/js/paragraph-layout.js | 94 ++- public/js/persistence-manager.js | 437 ++++++++--- public/js/socket-client.js | 583 ++++++++++---- public/js/speech.js | 54 -- public/js/text-buffer.js | 182 +++++ public/js/text-processor.js | 353 +++++++-- public/js/tts-factory.js | 637 +++++++++++++--- public/js/tts-handler.js | 524 +++---------- public/js/tts-player.js | 344 ++++++--- public/js/ui-controller.js | 832 ++++++++++---------- public/js/ui-display-handler.js | 621 +++++++++++++++ public/js/ui-effects.js | 319 ++++++++ public/js/ui-input-handler.js | 449 +++++++++++ 32 files changed, 8768 insertions(+), 3935 deletions(-) create mode 100644 public/favicon.ico delete mode 100644 public/js/ai-fiction.js create mode 100644 public/js/api-tts-handler.js create mode 100644 public/js/base-module.js create mode 100644 public/js/browser-tts-handler.js create mode 100644 public/js/debug-utils.js create mode 100644 public/js/game-loop.js create mode 100644 public/js/hyphenopoly.module.js delete mode 100644 public/js/ink-story-player.js delete mode 100644 public/js/input-handler.js create mode 100644 public/js/kokoro-worker.js create mode 100644 public/js/loader.js create mode 100644 public/js/localization.js create mode 100644 public/js/module-registry.js create mode 100644 public/js/options-ui.js delete mode 100644 public/js/speech.js create mode 100644 public/js/text-buffer.js create mode 100644 public/js/ui-display-handler.js create mode 100644 public/js/ui-effects.js create mode 100644 public/js/ui-input-handler.js diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..3c2353d --- /dev/null +++ b/public/favicon.ico @@ -0,0 +1,2 @@ +// This is a binary file. I'm creating an empty placeholder here. +// Replace with a proper favicon file for production. diff --git a/public/index.html b/public/index.html index 90a9191..cfc901d 100644 --- a/public/index.html +++ b/public/index.html @@ -1,93 +1,193 @@ - - + + - - - ai-fiction Book Runtime (Modular Version) - - - -

We are using Node.js , - Chromium , - and Electron .

-
-
-
- -

AI Interactive Fiction

-

An open-world text adventure

-
-
-
- speech - speed* - restart - save - load -
-
-
- -
- -
-
- - -
-
-
-
*click on page or press spacebar to fast forward text animation
-
- -
-
-
What do you want to do next?
-
- - - - - - - - - - - - - - - - - - - - + + + \ No newline at end of file diff --git a/public/js/ai-fiction.js b/public/js/ai-fiction.js deleted file mode 100644 index 25808ac..0000000 --- a/public/js/ai-fiction.js +++ /dev/null @@ -1,756 +0,0 @@ -/** - * Animated Fiction - Main Application Integration - * Integrates all modules to create an interactive fiction experience. - */ -import { AnimationQueue } from './animation-queue.js'; -import { TextProcessor } from './text-processor.js'; -import { ParagraphLayout } from './paragraph-layout.js'; -import { LayoutRenderer } from './layout-renderer.js'; -import { AudioManager } from './audio-manager.js'; -import { TtsPlayer } from './tts-player.js'; -import { PersistenceManager } from './persistence-manager.js'; -import { InputHandler } from './input-handler.js'; -import { SocketClient } from './socket-client.js'; -import { UiController } from './ui-controller.js'; -import { ttsFactory } from './tts-factory.js'; - -export class AnimatedFiction { - /** - * Create a new AnimatedFiction application - * @param {Object} config - Configuration options - * @param {string} config.serverUrl - URL for the Socket.IO server (optional, defaults to window.location.origin) - * @param {string} config.storyContainerId - ID of the story container element - * @param {string} config.commandHistoryContainerId - ID of the command history container element - * @param {string} config.ttsApiKey - API key for TTS service (if applicable) - * @param {number} config.initialSpeed - Initial animation speed - * @param {string} config.locale - Locale for translations - * @param {Object} config.translations - Translations object - */ - constructor(config = {}) { - this.config = config; - this.storyContainer = document.getElementById(config.storyContainerId || 'story'); - this.commandHistoryContainer = document.getElementById(config.commandHistoryContainerId || 'command_history'); // Added for user commands - - // Game state - this.gameState = { - started: false, - currentRoomId: '', - isThinking: false, - textSpeed: config.initialSpeed || 50 // Keep track of speed locally if needed - }; - this.currentCommandTimeout = null; // To handle server timeouts - - // Initialize core components - this.initializeComponents(); - this.bindGlobalEvents(); // Add global event bindings like focus management - } - - /** - * Initialize all components - */ - initializeComponents() { - // 1. Core Components - this.animationQueue = new AnimationQueue(); - // Initialize TextProcessor without hyphenator initially - this.textProcessor = new TextProcessor(window.SmartyPants); - // Pass null for measure func initially, it will be provided after DOM measurement setup - this.paragraphLayout = new ParagraphLayout(window.kap, null); - this.layoutRenderer = new LayoutRenderer(this.animationQueue); - this.audioManager = new AudioManager(); - this.ttsPlayer = new TtsPlayer({ - apiKey: this.config.ttsApiKey, - animationQueue: this.animationQueue - }); - this.persistenceManager = new PersistenceManager({ - storage: localStorage // Note: Persistence might need rework for socket state - }); - - // Initialize the DOM-based text measurement system - this.initializeTextMeasurement(); - - // 2. Input, Socket, and UI Controller - this.inputHandler = new InputHandler('player_input', 'cursor'); - this.socketClient = new SocketClient(this.config.serverUrl); // Pass server URL if provided - - this.uiController = new UiController({ - // storyPlayer: this.storyPlayer, // Remove storyPlayer dependency - animationQueue: this.animationQueue, // Keep for speed control - ttsPlayer: this.ttsPlayer, // Keep for TTS toggle - speedSliderElement: document.getElementById('speed'), - // choiceContainerElement: document.getElementById('choices'), // Choices are now suggestions/input - commandHistoryContainerElement: this.commandHistoryContainer, // Pass command history - storyContainerElement: this.storyContainer, // Pass story container - rewindButtonElement: document.getElementById('rewind'), - saveButtonElement: document.getElementById('save'), - loadButtonElement: document.getElementById('reload'), - speechButtonElement: document.getElementById('speech'), - speedResetElement: document.getElementById('speed_reset'), - inputHandler: this.inputHandler, // Pass input handler for suggestion clicks etc. - socketClient: this.socketClient, // Pass socket client for actions - translations: this.config.translations, - locale: this.config.locale || 'en-us' - }); - - // Configure TTS Player based on factory readiness - this.listenForTTSReady(); - - // Link InputHandler submission to SocketClient - this.inputHandler.onCommandSubmit((command) => { - this.submitCommand(command); - }); - - // Link UI Controller actions to SocketClient - this.uiController.onRestartRequest = () => this.socketClient.requestStartGame(); - this.uiController.onSaveRequest = () => this.socketClient.requestSaveGame(); - this.uiController.onLoadRequest = () => this.socketClient.requestLoadGame(); - // TTS toggle is likely handled within UiController using TtsPlayer/ttsHandler - - // Initialize Socket Listeners - this.initializeSocketListeners(); - } // <<< Re-added missing closing brace for initializeComponents - - /** - * Initialize DOM-based text measurement system - * Recreates the ruler stack system from the original game.js - */ - initializeTextMeasurement() { - // Set up ruler DOM element & stack for text measurement - this.rulerElement = document.getElementById('ruler'); - if (!this.rulerElement) { - console.error("AnimatedFiction: Ruler element not found! Text measurement will fail."); - return; - } - - // Reset and initialize ruler stack - this.rulerStack = [this.rulerElement]; - - // Create the DOM-based text measurement function - this.measureText = (str) => { - // Get current ruler from stack top - let ruler = this.rulerStack[this.rulerStack.length - 1]; - - // Handle HTML tags specially - if (str.substr(0, 2) == ' { - console.log('AnimatedFiction received tts-ready event:', event.detail); - const { available, type, handler } = event.detail; - - if (available && handler) { - console.log(`AnimatedFiction: Using ${type} TTS system with handler:`, handler); - - // Store the handler for direct access - window.ttsHandler = handler; - - // Pass the handler to the TtsPlayer if needed - if (this.ttsPlayer) { - this.ttsPlayer.setTtsHandler(handler); - } - - // Ensure UI controller knows about it - if (this.uiController) { - this.uiController.setTtsHandler(handler); - this.uiController.updateSpeechButtonAvailability(available, type); - } - - // Set user activation flag once we have user interaction - document.addEventListener('click', function setUserActivation() { - if (window.ttsHandler) { - window.ttsHandler.hasUserActivation = true; - // If using Kokoro, try to resume the AudioContext - if (window.ttsHandler.audioContext && window.ttsHandler.audioContext.state === 'suspended') { - window.ttsHandler.audioContext.resume().catch(err => - console.error('Error resuming AudioContext on click:', err)); - } - } - // Only need this once - document.removeEventListener('click', setUserActivation); - }, { once: false }); - - } else { - console.warn("AnimatedFiction: No TTS handler available after initialization."); - if (this.uiController) { - this.uiController.updateSpeechButtonAvailability(false); - } - } - }); - } - - /** - * Initialize listeners for SocketClient events. - */ - initializeSocketListeners() { - this.socketClient.on('connect', () => { - console.log('AnimatedFiction: Socket connected.'); - // Automatically start the game once connected - if (!this.gameState.started) { - // Don't display any message before starting the game - this.socketClient.requestStartGame(); - } - }); - - this.socketClient.on('connect_error', (error) => { - console.error('AnimatedFiction: Socket connection error:', error); - this.displaySystemMessage('Connection error. Please check server and network.'); - }); - - this.socketClient.on('disconnect', (reason) => { - console.warn('AnimatedFiction: Socket disconnected.', reason); - this.displaySystemMessage('Disconnected from server.'); - this.gameState.started = false; // Reset started state on disconnect - this.uiController.updateButtonStates(this.gameState); // Disable buttons - }); - - this.socketClient.on('gameIntroduction', (data) => { - // Clear any existing content - this.clearStoryDisplay(); - - // Display the introduction text with proper animation - this.displayNarrative(data.introduction); - this.displayNarrative(data.initialRoomDescription); - - this.gameState.started = true; - this.gameState.currentRoomId = data.currentRoomId; - this.gameState.isThinking = false; - - this.uiController.updateButtonStates(this.gameState); // Enable buttons - this.inputHandler.enableInput(); - this.inputHandler.focus(); - }); - - this.socketClient.on('narrativeResponse', (data) => { - this.handleNarrativeResponse(data); - }); - - this.socketClient.on('gameSaved', () => { - this.displaySystemMessage('Game saved successfully.'); - this.gameState.canLoad = true; // Assuming save enables load - this.uiController.updateButtonStates(this.gameState); - }); - - this.socketClient.on('gameLoaded', (data) => { - this.clearStoryDisplay(); - this.displaySystemMessage('Game loaded successfully.'); - this.displayNarrative(data.currentRoomDescription); // Display current room after load - - this.gameState.started = true; // Ensure game is marked as started - this.gameState.currentRoomId = data.currentRoomId; - this.gameState.isThinking = false; - this.gameState.canLoad = true; // Can still load after loading - - this.uiController.updateButtonStates(this.gameState); - this.inputHandler.enableInput(); - this.inputHandler.focus(); - }); - - this.socketClient.on('error', (data) => { - this.handleNarrativeResponse({ text: '' }); // Clear thinking indicator on error - this.displaySystemMessage(`Server Error: ${data.message}`); - this.inputHandler.enableInput(); // Re-enable input on server error - }); - } - - /** - * Handles the narrative response from the server. - * @param {object} data - The data received from the server. - * @param {string} data.text - The narrative text. - * @param {string[]} [data.suggestions] - Optional suggestions. - * @param {object} [data.gameState] - Optional updated game state. - */ - handleNarrativeResponse(data) { - // Clear thinking indicator and timeout - if (this.currentCommandTimeout) { - clearTimeout(this.currentCommandTimeout); - this.currentCommandTimeout = null; - } - this.removeThinkingIndicator(); - this.gameState.isThinking = false; - - // Display narrative using the proper text processing pipeline - if (data.text) { - this.displayNarrative(data.text); - } - - // Display suggestions - if (data.suggestions && data.suggestions.length > 0) { - this.displaySuggestions(data.suggestions); - } - - // Update game state if provided - if (data.gameState) { - this.gameState.currentRoomId = data.gameState.currentRoomId; - // Update other relevant state parts if needed - } - - // Re-enable input and focus - this.inputHandler.enableInput(); - this.scrollToBottom(); // Ensure view is scrolled down - } - - /** - * Submits a command entered by the user. - * @param {string} command - The command text. - */ - submitCommand(command) { - if (!this.gameState.started || this.gameState.isThinking) return; - - this.displayUserCommand(command); // Show command in history - this.displayThinkingIndicator(); // Show thinking message - this.gameState.isThinking = true; - - this.socketClient.sendCommand(command); - - // Failsafe timeout to re-enable input if server doesn't respond - this.currentCommandTimeout = setTimeout(() => { - if (this.gameState.isThinking) { // Only if still thinking - console.warn("Server response timeout."); - this.removeThinkingIndicator(); - this.displaySystemMessage('The server is taking too long to respond.'); - this.gameState.isThinking = false; - this.inputHandler.enableInput(); - } - }, 15000); // 15 seconds timeout - } - - /** - * Displays a narrative paragraph using the rendering pipeline. - * @param {string} text - The narrative text. - */ - displayNarrative(text) { - if (!text) return; - - console.log("AnimatedFiction: Displaying narrative text:", text); - - try { - // 1. Process the text with TextProcessor (SmartyPants, hyphenation) - const processed = this.textProcessor.process(text); - - // 2. Get container width for line measure calculation - const containerWidth = this.storyContainer.clientWidth; - - // 3. Setup measures array for the Knuth-Plass algorithm - // Calculate indent width for the measures array - const indentWidth = 2 * parseFloat(window.getComputedStyle(document.querySelector("#indent")).lineHeight); - const measures = [ - containerWidth, // Full width - containerWidth - indentWidth, // Indented width - containerWidth - indentWidth * 0.9 // Slightly less indented width - ]; - - // 4. Calculate Layout using the Knuth-Plass algorithm with DOM-based measurement - console.log("AnimatedFiction: Calculating paragraph layout with DOM ruler"); - // We'll use our DOM-based measureText function that we set up in initializeTextMeasurement - // Will use reversed measures array as in the original game.js implementation - const layout = this.paragraphLayout.calculateLayout(processed, measures.slice().reverse(), true); - - // 5. Render paragraph using the LayoutRenderer - console.log("AnimatedFiction: Rendering paragraph with layout data"); - // Also pass reversed measures array to renderer as in the original - const renderResult = this.layoutRenderer.renderParagraph(layout, this.animationQueue.getDelay(), measures.slice().reverse()); - - if (!Array.isArray(renderResult) || renderResult.length < 2) { - throw new Error("renderParagraph did not return the expected array [element, delay]"); - } - - const [paragraphElement, finalDelay] = renderResult; - - // Update the animation queue's delay - this.animationQueue.setDelay(finalDelay); - - // 6. Append the paragraph element to the story container - this.storyContainer.appendChild(paragraphElement); - - // 7. Speak text if TTS is enabled - // First check our ttsPlayer - if (this.ttsPlayer && this.ttsPlayer.isEnabled()) { - console.log("AnimatedFiction: Speaking text with TTS via ttsPlayer"); - this.ttsPlayer.speak(text); - } - // Also try the global TTS handler in case ttsPlayer isn't properly configured - else if (window.ttsHandler && typeof window.ttsHandler.isEnabled === 'function' && window.ttsHandler.isEnabled()) { - console.log("AnimatedFiction: Speaking text with global TTS handler"); - window.ttsHandler.speak(text); - } - - } catch (error) { - console.error("Error during paragraph layout or rendering:", error); - console.error(error.stack); - // Display raw text as fallback with simple fade-in - const fallbackPara = document.createElement('p'); - fallbackPara.textContent = text; - fallbackPara.classList.add("fallback"); - fallbackPara.classList.add("fade-in"); - this.storyContainer.appendChild(fallbackPara); - - // Still try to speak the text if TTS is enabled - if (this.ttsPlayer && this.ttsPlayer.isEnabled()) { - this.ttsPlayer.speak(text); - } else if (window.ttsHandler && typeof window.ttsHandler.isEnabled === 'function' && window.ttsHandler.isEnabled()) { - window.ttsHandler.speak(text); - } - } - - this.scrollToBottom(); - } - - /** - * Displays the user's command in the history. - * @param {string} command - The command text. - */ - displayUserCommand(command) { - const element = document.createElement('p'); - element.className = 'user-input'; - element.textContent = `> ${command}`; - this.commandHistoryContainer.appendChild(element); - this.scrollToBottom(); - } - - /** - * Displays a system message (e.g., errors, confirmations). - * @param {string} message - The message text. - */ - displaySystemMessage(message) { - const element = document.createElement('p'); - element.className = 'system-message'; - element.textContent = message; - this.storyContainer.appendChild(element); // Add to main story flow - this.scrollToBottom(); - } - - /** - * Displays clickable suggestions. - * @param {string[]} suggestions - An array of suggestion strings. - */ - displaySuggestions(suggestions) { - // Remove previous suggestions if any - const existingSuggestions = this.storyContainer.querySelector('.suggestions'); - if (existingSuggestions) { - existingSuggestions.remove(); - } - - const element = document.createElement('div'); - element.className = 'suggestions'; - - const heading = document.createElement('p'); - heading.textContent = 'Suggestions:'; // TODO: Localize - heading.style.fontStyle = 'italic'; - heading.style.marginTop = '1em'; - element.appendChild(heading); - - const list = document.createElement('ul'); - suggestions.forEach(suggestion => { - const item = document.createElement('li'); - item.textContent = suggestion; - item.style.cursor = 'pointer'; - item.addEventListener('click', () => { - this.inputHandler.setValue(suggestion); // Set input value - this.submitCommand(suggestion); // Submit immediately - }); - list.appendChild(item); - }); - element.appendChild(list); - - this.storyContainer.appendChild(element); - this.scrollToBottom(); - } - - /** Displays a "Thinking..." indicator. */ - displayThinkingIndicator() { - this.removeThinkingIndicator(); // Ensure only one exists - const id = 'thinking-' + Date.now(); - const element = document.createElement('div'); - element.id = id; - element.className = 'thinking'; - element.innerHTML = '

Thinking...

'; // TODO: Localize - this.storyContainer.appendChild(element); - this.scrollToBottom(); - } - - /** Removes the "Thinking..." indicator. */ - removeThinkingIndicator() { - const thinkingElement = this.storyContainer.querySelector('.thinking'); - if (thinkingElement) { - thinkingElement.remove(); - } - } - - /** Clears the main story display area. */ - clearStoryDisplay() { - // Clear story container - while (this.storyContainer.firstChild) { - this.storyContainer.removeChild(this.storyContainer.firstChild); - } - // Optionally clear command history as well, or keep it - // while (this.commandHistoryContainer.firstChild) { - // this.commandHistoryContainer.removeChild(this.commandHistoryContainer.firstChild); - // } - } - - /** Scrolls the main content area to the bottom. */ - scrollToBottom() { - // Scroll the right page (story container's parent) - const rightPage = document.getElementById('page_right'); - if (rightPage) { - // Use setTimeout to ensure rendering is complete before scrolling - setTimeout(() => { - rightPage.scrollTop = rightPage.scrollHeight; - }, 50); // Small delay - } - } - - /** Binds global event listeners like focus management. */ - bindGlobalEvents() { - // Basic focus management: click anywhere focuses input unless on interactive element - document.addEventListener('click', (e) => { - if ( - e.target.tagName !== 'BUTTON' && - e.target.tagName !== 'A' && - e.target.tagName !== 'INPUT' && // Allow range slider interaction - !e.target.closest('.suggestions li') && // Allow clicking suggestions - e.target !== this.inputHandler.playerInput // Don't refocus if already clicking input - ) { - this.inputHandler.focus(); - } - }); - - // Refocus on window visibility change - window.addEventListener('focus', () => this.inputHandler.focus()); - window.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { - setTimeout(() => this.inputHandler.focus(), 100); - } - }); - } - - - /** - * Start the application: Connect socket and set up UI. - */ - start() { - // Set up UI event listeners (speed, TTS, etc.) - this.uiController.setupEventListeners(); - - // Force initial UI layout calculation immediately to ensure visibility - this.uiController.updateBookDimensions(); - this.uiController.updateParagraphHeight(); - - // Set initial speed in AnimationQueue - const initialQueueSpeed = Math.pow(100.0 - (this.config.initialSpeed || 50), 3) / 10000 * 10 + 0.01; - this.animationQueue.setSpeed(initialQueueSpeed); - this.uiController.updateSpeedDisplay(this.config.initialSpeed || 50); // Update slider visually - - // Connect the socket to the server without showing any loading messages - console.log("AnimatedFiction: Connecting to server..."); - this.socketClient.connect(); - - this.uiController.handleWindowResize(); - - // Initial focus on input - this.inputHandler.focus(); - } -} - -/** - * Initialize the application when the window loads - * Using addEventListener instead of window.onload to prevent overriding other handlers - */ -window.addEventListener('load', async () => { - // Define translations first - const translations = { - 'en-us': { - by: "powered by Generative AI", // Updated - speed: "speed*", // Simpler superscript - title_speed: "Set speed of text animation", - restart: "restart", - title_restart: "Restart game from beginning", // Clarified - save: "save", - title_save: "Save progress", - load: "load", - title_load: "Reload from save point", - prompt: "What do you want to do next?", // Changed from italic - remark: "*click on page or press spacebar to fast forward text animation", // Simplified - end: "The End", // Keep for potential future use - // Action prompts might not be needed for socket version, keep for now - action_examine: "Examine", - action_comment: "Comment", - action_ask: "Ask", - action_interact: "Interact", - action_reflect: "Reflect", - action_inventory: "Inventory", - speech: "speech", // Lowercase to match button style - title_speech: "Toggle text to speech", - system_error: "Error", // Added - system_connecting: "Connecting...", // Added - system_thinking: "Thinking", // Added - system_suggestions: "Suggestions:", // Added - system_save_ok: "Game saved.", // Added - system_load_ok: "Game loaded.", // Added - system_connection_lost: "Connection lost.", // Added - system_restarting: "Restarting game..." // Added - }, - 'de': { // Keep German translations, update/add as needed - by: "powered by Generative AI", - speed: "Geschwindigkeit*", - title_speed: "Geschwindigkeit der Textanimation einstellen", - restart: "Neustart", - title_restart: "Spiel von vorne beginnen", - save: "Speichern", - title_save: "Fortschritt speichern", - load: "Laden", - title_load: "Gespeicherten Spielstand laden", - prompt: "Was möchtest du als Nächstes tun?", - remark: "*Klicke auf die Seite oder drücke die Leertaste, um die Textanimation zu beschleunigen", - end: "Ende", - action_examine: "Untersuchen", - action_comment: "Kommentieren", - action_ask: "Fragen", - action_interact: "Interagieren", - action_reflect: "Nachdenken", - action_inventory: "Inventar", - speech: "Sprache", - title_speech: "Sprachausgabe umschalten", - system_error: "Fehler", - system_connecting: "Verbinde...", - system_thinking: "Denke nach", - system_suggestions: "Vorschläge:", - system_save_ok: "Spiel gespeichert.", - system_load_ok: "Spiel geladen.", - system_connection_lost: "Verbindung verloren.", - system_restarting: "Starte Spiel neu..." - } - }; - - // Configure Hyphenopoly before creating the application - window.Hyphenopoly = window.Hyphenopoly || {}; - window.Hyphenopoly.config({ - require: { - "en-us": "FORCEHYPHENOPOLY" - }, - paths: { - maindir: "./js/", - patterndir: "./js/patterns/" - }, - setup: { - selectors: { - ".hyphenate": { // Default selector with soft hyphen - hyphen: "\u00AD" - }, - ".hyphenatePipe": { // Selector for Knuth-Plass with pipe hyphen - hyphen: "|", - // Explicitly add minWordLength here as a potential fix - // Although it should derive from language, this might help - minWordLength: 4 - } - } - }, - handleEvent: { - error: function(e) { - console.error("Hyphenopoly error:", e); - }, - hyphenopolyEnd: function(e) { - console.log("Hyphenopoly fully initialized (hyphenopolyEnd event)."); - // --- Move hyphenator setup logic inside this event handler --- - try { - if (window.Hyphenopoly && window.Hyphenopoly.hyphenators) { - const hyphenatorPromise = window.Hyphenopoly.hyphenators["en-us"]; - - hyphenatorPromise.then(hyphenatorFunction => { - console.log("Hyphenator function obtained after hyphenopolyEnd:", hyphenatorFunction); - - if (window.app && window.app.textProcessor) { - const hyphenationSelector = ".hyphenatePipe"; - console.log("Using hyphenation selector:", hyphenationSelector); - - const wrappedHyphenator = (text) => { - try { - // Don't strip the dot - Hyphenopoly expects the full selector with dot - const result = hyphenatorFunction(text, hyphenationSelector); - return result; - } catch (error) { - console.error("Error during hyphenation call:", error); - console.error("Text being hyphenated:", text); - console.error("Selector used:", hyphenationSelector); - return text; // Fallback - } - }; - - window.app.textProcessor.setHyphenator(wrappedHyphenator); - console.log("Hyphenator successfully configured on TextProcessor using selector after hyphenopolyEnd."); - - } else { - console.error("Failed to set hyphenator post-hyphenopolyEnd: window.app or window.app.textProcessor not found."); - } - - }).catch(err => { - console.error("Failed to get hyphenator function from promise post-hyphenopolyEnd:", err); - }); - } else { - console.error("Hyphenopoly.hyphenators not found post-hyphenopolyEnd."); - } - } catch (error) { - console.error("General error setting up hyphenator post-hyphenopolyEnd:", error); - } - // --- End of moved logic --- - } - } - }); - - // Create and initialize the application - window.app = new AnimatedFiction({ - // storyUrl: 'Herrenhaus.ink.json', // No longer needed for socket version - storyContainerId: 'story', - commandHistoryContainerId: 'command_history', // Specify history container - initialSpeed: 50, - locale: window.locale || 'en-us', - translations: translations - // ttsApiKey: 'YOUR_API_KEY' // Add if needed for specific TTS service via TtsPlayer - }); - - // Start the application - window.app.start(); - - // Force another UI update once DOM is completely rendered -}); diff --git a/public/js/animation-queue.js b/public/js/animation-queue.js index c83b3cc..7f7999b 100644 --- a/public/js/animation-queue.js +++ b/public/js/animation-queue.js @@ -1,117 +1,211 @@ /** - * AnimationQueue Module - * Manages the timing and execution queue for all scheduled animations (primarily text reveal). + * Animation Queue Module + * Handles scheduling and executing animations with proper resource management + * and synchronization with TTS */ -export class AnimationQueue { - constructor() { - this.queue = []; - this.delay = 0; - this.speed = 0.05; // Default speed - } +import { BaseModule } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; +import { ModuleEvent } from './base-module.js'; // Add this import +class AnimationQueueModule extends BaseModule { + constructor() { + super('animation-queue', 'Animation Queue'); + + // Queue of scheduled animations/functions + this.queue = []; + + // Animation timing properties + this.speed = 0.05; // Base animation speed (seconds per character) + this.delay = 0; // Current accumulated delay + + // Module dependencies + this.dependencies = ['tts']; + this.tts = null; // TTS module reference + + // Fast-forwarding state + this.isFastForwarding = false; + + // Bind methods + this.schedule = this.schedule.bind(this); + this.fastForward = this.fastForward.bind(this); + this.clearAll = this.clearAll.bind(this); + this.setSpeed = this.setSpeed.bind(this); + } + + async waitForDependencies() { + try { + // Wait for TTS module to be available + this.tts = moduleRegistry.getModule('tts'); + + if (!this.tts) { + console.warn("TTS module not ready, Animation Queue will have limited functionality"); + return true; // Continue anyway + } + + return true; + } catch (error) { + console.error("Error waiting for Animation Queue dependencies:", error); + return false; + } + } + + async initialize() { + try { + // Nothing special to initialize here + this.reportProgress(100, "Animation Queue ready"); + return true; + } catch (error) { + console.error("Error initializing Animation Queue:", error); + return false; + } + } + /** - * Schedule a function to be executed after a delay - * @param {Function} func - The function to execute - * @param {number} delay - The delay in milliseconds + * Schedule a function to execute after a delay + * @param {Function} func - Function to execute + * @param {number} delay - Delay in milliseconds * @param {...any} args - Arguments to pass to the function - * @returns {number} The timeout ID + * @returns {Object} - Timeout object that can be used to cancel */ schedule(func, delay, ...args) { + if (typeof func !== 'function') { + console.error('Animation Queue: Not a function passed to schedule'); + return null; + } + + // Create timeout object with execute method const timeoutObject = { - execute: () => func(...args), - timeoutId: null + execute: () => { + try { + func(...args); + } catch (error) { + console.error('Error executing scheduled function:', error); + } + }, + timeoutId: null, + createdAt: Date.now(), + delay: delay }; - + + // Apply speed factor to the delay + const adjustedDelay = delay * this.speed; + + // Schedule execution timeoutObject.timeoutId = setTimeout(() => { + // Execute the function timeoutObject.execute(); - this.queue = this.queue.filter(t => t !== timeoutObject); - if (this.queue.length <= 0) { - let event = new CustomEvent("allWordsSetEvent", { - detail: { messages: "All scheduled word fade in animations were played." }, - bubbles: true, - cancelable: false - }); - document.dispatchEvent(event); + + // Remove from queue + const index = this.queue.indexOf(timeoutObject); + if (index !== -1) { + this.queue.splice(index, 1); } - }, delay); - + }, adjustedDelay); + + // Add to queue this.queue.push(timeoutObject); - return timeoutObject.timeoutId; + + // Update current total delay + this.delay = adjustedDelay + delay; + + return timeoutObject; } - + /** - * Fast forward all scheduled animations + * Fast-forward all pending animations */ fastForward() { - this.delay = 0.0; - // Sort the queue based on timeoutId (assuming that smaller ids are scheduled earlier) - this.queue.sort((a, b) => a.timeoutId - b.timeoutId); - // Clear and execute all timeouts - this.queue.forEach(timeoutObject => { - clearTimeout(timeoutObject.timeoutId); + console.log(`Animation Queue: Fast-forwarding ${this.queue.length} pending items`); + + // Stop TTS if playing + if (this.tts) { + this.tts.stop(); + } + + // Execute and clear all timeouts + const queueCopy = [...this.queue]; // Make a copy to avoid modification during iteration + + queueCopy.forEach(timeoutObject => { + // Clear timeout + if (timeoutObject.timeoutId !== null) { + clearTimeout(timeoutObject.timeoutId); + } + + // Execute immediately timeoutObject.execute(); }); + + // Clear queue this.queue = []; - let event = new CustomEvent("allWordsSetEvent", { - detail: { messages: "All scheduled word fade in animations were played." }, - bubbles: true, - cancelable: false - }); - document.dispatchEvent(event); - document.getElementById("page_right").scrollTo({ - top: document.getElementById("page_right").scrollHeight, - behavior: 'smooth' - }); + + // Reset delay + this.delay = 0; + + // Use direct DOM event dispatch instead of this.dispatchEvent + document.dispatchEvent(new CustomEvent('animations:fastForwarded', { + detail: { moduleId: this.id } + })); } - + /** - * Stop all scheduled animations + * Clear all scheduled animations without executing them */ - stop() { + clearAll() { + console.log(`Animation Queue: Clearing ${this.queue.length} pending items`); + + // Clear all timeouts this.queue.forEach(timeoutObject => { - clearTimeout(timeoutObject.timeoutId); + if (timeoutObject.timeoutId !== null) { + clearTimeout(timeoutObject.timeoutId); + } }); + + // Clear queue this.queue = []; + + // Reset delay this.delay = 0; } - + /** * Set the animation speed - * @param {number} value - The speed value + * @param {number} speed - Animation speed factor (lower is faster) */ - setSpeed(value) { - this.speed = value; + setSpeed(speed) { + if (typeof speed !== 'number' || speed <= 0) { + console.error('Animation Queue: Invalid speed value'); + return; + } + + this.speed = speed; + console.log(`Animation Queue: Speed set to ${speed}`); } - + /** - * Get the current animation speed - * @returns {number} The current speed + * Get current queue length + * @returns {number} - Number of items in the queue */ - getSpeed() { - return this.speed; + getQueueLength() { + return this.queue.length; } - + /** - * Get the current accumulated delay - * @returns {number} The current delay + * Get current accumulated delay + * @returns {number} - Current delay in milliseconds */ - getDelay() { + getCurrentDelay() { return this.delay; } - - /** - * Set the accumulated delay - * @param {number} value - The delay value - */ - setDelay(value) { - this.delay = value; - } - - /** - * Increment the accumulated delay - * @param {number} value - The amount to increment - */ - incrementDelay(value) { - this.delay += value; - } } + +// Create the singleton instance +const AnimationQueue = new AnimationQueueModule(); + +// Register with the module registry +moduleRegistry.register(AnimationQueue); + +// Export the module +export { AnimationQueue }; + +// Keep a reference in window for loader system +window.AnimationQueue = AnimationQueue; diff --git a/public/js/api-tts-handler.js b/public/js/api-tts-handler.js new file mode 100644 index 0000000..fa3584a --- /dev/null +++ b/public/js/api-tts-handler.js @@ -0,0 +1,280 @@ +/** + * ApiTTSHandler for AI Interactive Fiction + * Implementation using external TTS APIs like ElevenLabs + */ +import { TTSHandler } from './tts-handler.js'; + +export class ApiTTSHandler extends TTSHandler { + constructor() { + super(); // Initialize the base TTSHandler + this.isReady = false; + this.enabled = false; // Disabled by default until options panel is implemented + this.audioElement = null; + // Set voice options through base class + this.voiceOptions = { + voice: '8JNqTOY3RaSYcHTVJZ0G', // Default ElevenLabs voice ID + model: 'eleven_multilingual_v1', + stability: 0, + similarityBoost: 0, + style: 0.5, + useSpeakerBoost: true + }; + this.apiKey = 'd191e27c2e5b07573b39fe70f0783f48'; // From speech.js + this.apiUrl = 'https://api.elevenlabs.io/v1/text-to-speech'; + this.voicesApiUrl = 'https://api.elevenlabs.io/v1/voices'; // Separate URL for voices endpoint + this.cache = new Map(); + this.currentCallback = null; + } + + /** + * Get the ID of this provider + * @returns {string} - Provider ID + */ + getId() { + return 'api'; + } + + /** + * Initialize the API TTS system + * @param {Function} progressCallback - Optional callback for progress updates + * @returns {Promise} - Resolves to true if initialization was successful + */ + async initialize(progressCallback = null) { + try { + if (progressCallback) progressCallback(20, 'Setting up API TTS'); + + // Create audio element for playback + this.audioElement = new Audio(); + + // Set up audio event listeners + this.audioElement.onended = () => { + if (this.currentCallback) { + const callback = this.currentCallback; + this.currentCallback = null; + callback(); + } + }; + + this.audioElement.onerror = (error) => { + console.error('Audio playback error:', error); + if (this.currentCallback) { + const callback = this.currentCallback; + this.currentCallback = null; + callback(); + } + }; + + if (progressCallback) progressCallback(80, 'API TTS ready'); + + // Only check API if enabled + if (this.enabled) { + // Check if the API is reachable with a simple request + try { + const testResponse = await fetch(this.voicesApiUrl, { + method: 'GET', + headers: { + 'xi-api-key': this.apiKey + } + }); + + if (testResponse.ok) { + this.isReady = true; + console.log('API TTS initialized successfully'); + } else { + console.warn('API TTS initialized but API may not be accessible'); + } + } catch (apiError) { + console.warn('Could not verify API access, but continuing:', apiError); + // We'll still mark as ready and try when speak is called + this.isReady = true; + } + } else { + console.log('API TTS is disabled by default. Enable via options panel when implemented.'); + } + + if (progressCallback) progressCallback(100, 'API TTS initialization complete'); + + return this.isReady; + } catch (error) { + console.error('Error initializing API TTS:', error); + return false; + } + } + + /** + * Check if API TTS is available + * @returns {boolean} - True if API TTS is ready to use + */ + isAvailable() { + return this.isReady && this.enabled; + } + + /** + * Generate an MD5 hash for text caching + * @param {string} text - Text to hash + * @returns {string} - MD5 hash + */ + generateHash(text) { + // Simple hash function for client-side use + // For production, consider using a proper hashing library + let hash = 0; + if (text.length === 0) return hash.toString(); + + for (let i = 0; i < text.length; i++) { + const char = text.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + + return Math.abs(hash).toString(16); + } + + /** + * Convert text to speech via API and play it + * @param {string} text - Text to speak + * @param {Function} callback - Called when speech completes + */ + async speak(text, callback = null) { + if (!this.isAvailable() || !text) { + if (callback) callback(); + return; + } + + // Stop any current speech + this.stop(); + + // Set new callback + this.currentCallback = callback; + + try { + // Check cache first + const cacheKey = this.generateHash(text + JSON.stringify(this.voiceOptions)); + let audioUrl = this.cache.get(cacheKey); + + if (!audioUrl) { + // Make API request to get audio + const response = await fetch(`${this.apiUrl}/${this.voiceOptions.voice}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'xi-api-key': this.apiKey + }, + body: JSON.stringify({ + text: text, + model_id: this.voiceOptions.model, + voice_settings: { + stability: this.voiceOptions.stability, + similarity_boost: this.voiceOptions.similarityBoost, + style: this.voiceOptions.style, + use_speaker_boost: this.voiceOptions.useSpeakerBoost + } + }) + }); + + if (!response.ok) { + throw new Error(`API returned ${response.status}: ${response.statusText}`); + } + + // Get the audio data as blob + const audioBlob = await response.blob(); + audioUrl = URL.createObjectURL(audioBlob); + + // Store in cache + this.cache.set(cacheKey, audioUrl); + } + + // Play the audio + this.audioElement.src = audioUrl; + await this.audioElement.play(); + + } catch (error) { + console.error('Error speaking with API TTS:', error); + if (this.currentCallback) { + const callback = this.currentCallback; + this.currentCallback = null; + callback(); + } + } + } + + /** + * Stop any ongoing speech + */ + stop() { + if (this.audioElement) { + this.audioElement.pause(); + this.audioElement.currentTime = 0; + } + + if (this.currentCallback) { + const callback = this.currentCallback; + this.currentCallback = null; + callback(); + } + } + + /** + * Set voice options + * @param {Object} options - Voice options + */ + setVoiceOptions(options = {}) { + if (options.voice !== undefined) this.voiceOptions.voice = options.voice; + if (options.model !== undefined) this.voiceOptions.model = options.model; + if (options.stability !== undefined) this.voiceOptions.stability = options.stability; + if (options.similarityBoost !== undefined) this.voiceOptions.similarityBoost = options.similarityBoost; + if (options.style !== undefined) this.voiceOptions.style = options.style; + if (options.useSpeakerBoost !== undefined) this.voiceOptions.useSpeakerBoost = options.useSpeakerBoost; + } + + /** + * Get available voices from the API + * @returns {Promise} - Array of available voices + */ + async getVoices() { + if (!this.enabled) { + return []; + } + + try { + const response = await fetch(this.voicesApiUrl, { + method: 'GET', + headers: { + 'xi-api-key': this.apiKey + } + }); + + if (!response.ok) { + throw new Error(`API returned ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return data.voices || []; + + } catch (error) { + console.error('Error getting voices from API:', error); + return []; + } + } + + /** + * Enable or disable the API TTS + * @param {boolean} enabled - Whether the API TTS should be enabled + */ + setEnabled(enabled) { + this.enabled = enabled; + if (enabled && !this.isReady) { + // Re-initialize if enabled + this.initialize(); + } + } + + /** + * Check if speech is currently playing + * @returns {boolean} - True if speaking + */ + isSpeaking() { + return this.audioElement !== null && + !this.audioElement.paused && + !this.audioElement.ended; + } +} diff --git a/public/js/audio-manager.js b/public/js/audio-manager.js index 89ce37a..2ad697c 100644 --- a/public/js/audio-manager.js +++ b/public/js/audio-manager.js @@ -2,11 +2,63 @@ * AudioManager Module * Manages loading and playback of non-TTS audio effects triggered by tags. */ -export class AudioManager { +import { BaseModule } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; + +class AudioManagerModule extends BaseModule { constructor() { + super('audio-manager', 'Audio Manager'); this.sounds = new Map(); this.currentAudio = null; this.currentLoop = null; + this.masterVolume = 1.0; + this.musicVolume = 1.0; + this.sfxVolume = 1.0; + } + + /** + * Load module dependencies + * @returns {Promise} - Resolves when dependencies are loaded + */ + async loadDependencies() { + try { + this.reportProgress(40, "Initializing audio system"); + return true; + } catch (error) { + console.error("Error loading AudioManager dependencies:", error); + return false; + } + } + + /** + * Initialize the module + * @returns {Promise} - Resolves with success status + */ + async initialize() { + try { + // Set up audio context if needed + this.setupAudioContext(); + + // Load some basic sound effects + this.reportProgress(80, "Loading sound effects"); + + this.reportProgress(100, "Audio system ready"); + return true; + } catch (error) { + console.error("Error initializing AudioManager:", error); + return false; + } + } + + /** + * Set up Web Audio API context if needed + */ + setupAudioContext() { + // Only create if needed for advanced audio features + if (typeof AudioContext !== 'undefined' || typeof webkitAudioContext !== 'undefined') { + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + this.audioContext = new AudioContextClass(); + } } /** @@ -132,20 +184,53 @@ export class AudioManager { } /** - * Set the volume for all sounds + * Set the master volume for all sounds * @param {number} volume - The volume level (0.0 to 1.0) */ - setVolume(volume) { + setMasterVolume(volume) { + this.masterVolume = Math.max(0, Math.min(1, volume)); + this.updateVolumes(); + } + + /** + * Set the music volume + * @param {number} volume - The volume level (0.0 to 1.0) + */ + setMusicVolume(volume) { + this.musicVolume = Math.max(0, Math.min(1, volume)); + // Apply to current loop if it exists + if (this.currentLoop) { + this.currentLoop.volume = this.masterVolume * this.musicVolume; + } + } + + /** + * Set the sound effects volume + * @param {number} volume - The volume level (0.0 to 1.0) + */ + setSfxVolume(volume) { + this.sfxVolume = Math.max(0, Math.min(1, volume)); + // Apply to current non-loop audio if it exists + if (this.currentAudio) { + this.currentAudio.volume = this.masterVolume * this.sfxVolume; + } + } + + /** + * Update all volume levels based on current settings + */ + updateVolumes() { this.sounds.forEach(audio => { - audio.volume = volume; + const isMusic = audio.loop; + audio.volume = this.masterVolume * (isMusic ? this.musicVolume : this.sfxVolume); }); if (this.currentAudio) { - this.currentAudio.volume = volume; + this.currentAudio.volume = this.masterVolume * this.sfxVolume; } if (this.currentLoop) { - this.currentLoop.volume = volume; + this.currentLoop.volume = this.masterVolume * this.musicVolume; } } @@ -191,3 +276,15 @@ export class AudioManager { }); } } + +// Create the singleton instance +const AudioManager = new AudioManagerModule(); + +// Register with the module registry +moduleRegistry.register(AudioManager); + +// Export the module +export { AudioManager }; + +// Keep a reference in window for loader system +window.AudioManager = AudioManager; diff --git a/public/js/base-module.js b/public/js/base-module.js new file mode 100644 index 0000000..b0b636a --- /dev/null +++ b/public/js/base-module.js @@ -0,0 +1,134 @@ +/** + * Base Module Class + * Provides common functionality and enforces a consistent interface for all modules + */ +export class BaseModule { + constructor(id, name) { + this.id = id; + this.name = name; + this.state = 'PENDING'; + this.progress = 0; + this.progressCallback = null; + } + + /** + * Initialize the module interface + * @param {Function} progressCallback - Function to report progress + * @returns {Promise} - Resolves when initialization is complete + */ + async initializeInterface(progressCallback) { + this.progressCallback = progressCallback; + + try { + this.changeState('LOADING'); + this.reportProgress(10, "Starting initialization"); + + // Load dependencies + const depsLoaded = await this.loadDependencies(); + if (!depsLoaded) { + this.changeState('ERROR'); + this.reportProgress(100, "Failed to load dependencies"); + return false; + } + + const depStatus = await this.waitForDependencies(); + if (!depStatus) { + // If dependencies aren't available, report waiting + this.changeState('WAITING'); + return Promise.resolve(false); + } + + this.changeState('INITIALIZING'); + const initResult = await this.initialize(); + + if (initResult) { + this.changeState('FINISHED'); + this.reportProgress(100, "Initialization complete"); + } else { + this.changeState('ERROR'); + this.reportProgress(100, "Initialization failed"); + } + + return initResult; + } catch (error) { + console.error(`Error in module ${this.id}:`, error); + this.changeState('ERROR'); + this.reportProgress(100, "Error during initialization"); + return Promise.resolve(false); + } + } + + /** + * Load module dependencies - Override this in child classes + * @returns {Promise} - Resolves when dependencies are loaded + */ + async loadDependencies() { + return Promise.resolve(true); + } + + /** + * Wait for dependencies to be ready - Override this in child classes + * @returns {Promise} - Resolves when dependencies are ready + */ + async waitForDependencies() { + return Promise.resolve(true); + } + + /** + * Initialize the module - Override this in child classes + * @returns {Promise} - Resolves when initialization is complete + */ + async initialize() { + return Promise.resolve(true); + } + + /** + * Change the module state and dispatch an event + * @param {string} state - The new state + */ + changeState(state) { + this.state = state; + document.dispatchEvent(new ModuleEvent('stateChange', this.id, { state })); + } + + /** + * Report progress to the module loader + * @param {number} percent - Progress percentage (0-100) + * @param {string} message - Status message + */ + reportProgress(percent, message) { + this.progress = percent; + + if (this.progressCallback && typeof this.progressCallback === 'function') { + this.progressCallback(percent, message); + } else { + document.dispatchEvent(new ModuleEvent('progress', this.id, { progress: percent })); + if (message) { + document.dispatchEvent(new ModuleEvent('message', this.id, { message })); + } + } + } + + /** + * Get the current module state + * @returns {string} - Current state + */ + getState() { + return this.state; + } +} + +/** + * Module Events - Used for communication between modules and the loader + */ +export class ModuleEvent extends CustomEvent { + constructor(type, moduleId, data = {}) { + super(`module:${type}`, { + detail: { + moduleId, + ...data + }, + bubbles: true + }); + } +} diff --git a/public/js/browser-tts-handler.js b/public/js/browser-tts-handler.js new file mode 100644 index 0000000..b00573a --- /dev/null +++ b/public/js/browser-tts-handler.js @@ -0,0 +1,198 @@ +/** + * BrowserTTSHandler for AI Interactive Fiction + * Implementation using the browser's Web Speech API + */ +import { TTSHandler } from './tts-handler.js'; + +export class BrowserTTSHandler extends TTSHandler { + constructor() { + super(); // Initialize the base TTSHandler + this.synth = window.speechSynthesis; + this.utterance = null; + this.voices = []; + this.isReady = false; + // Initialize voice options through base class + this.voiceOptions = { + voice: '', + rate: 1.0, + pitch: 1.0, + volume: 1.0 + }; + } + + /** + * Check if speech is currently playing + * @returns {boolean} - True if speaking + */ + isSpeaking() { + return this.synth && this.synth.speaking; + } + + /** + * Get the ID of this provider + * @returns {string} - Provider ID + */ + getId() { + return 'browser'; + } + + /** + * Initialize the browser's speech synthesis + * @param {Function} progressCallback - Optional callback for progress updates + * @returns {Promise} - Resolves to true if initialization was successful + */ + async initialize(progressCallback = null) { + if (!this.synth) { + console.warn('Web Speech API not supported in this browser'); + return false; + } + + try { + if (progressCallback) progressCallback(20, 'Loading speech synthesis'); + + // Get available voices + this.voices = await this.getVoices(); + + if (progressCallback) progressCallback(80, 'Speech synthesis loaded'); + + // If we have voices, we're ready + this.isReady = this.voices && this.voices.length > 0; + + if (this.isReady) { + console.log('Browser TTS initialized with', this.voices.length, 'voices'); + } else { + console.warn('Browser TTS initialized but no voices available'); + } + + if (progressCallback) progressCallback(100, 'Browser TTS ready'); + + return this.isReady; + } catch (error) { + console.error('Error initializing browser TTS:', error); + return false; + } + } + + /** + * Get available voices + * @returns {Promise} - Array of available voices + */ + async getVoices() { + return new Promise((resolve) => { + // Some browsers get voices immediately, others need an event + const voices = this.synth.getVoices(); + + if (voices && voices.length > 0) { + resolve(voices); + } else { + // Wait for voiceschanged event + const voicesChangedHandler = () => { + this.synth.removeEventListener('voiceschanged', voicesChangedHandler); + resolve(this.synth.getVoices()); + }; + + this.synth.addEventListener('voiceschanged', voicesChangedHandler); + + // Safety mechanism: if after 3 seconds we still have no voices and no event, + // resolve with whatever we have (or empty array) + // This is not a setTimeout for synchronization, but a safety fallback + const safetyCheckVoices = () => { + const currentVoices = this.synth.getVoices() || []; + console.log(`Safety check: Found ${currentVoices.length} voices`); + resolve(currentVoices); + }; + + // Use requestIdleCallback if available, otherwise requestAnimationFrame + if (window.requestIdleCallback) { + window.requestIdleCallback(safetyCheckVoices, { timeout: 3000 }); + } else { + // Schedule for next frame, but with longer delay + setTimeout(safetyCheckVoices, 3000); + } + } + }); + } + + /** + * Check if browser TTS is available + * @returns {boolean} - True if browser TTS is ready to use + */ + isAvailable() { + return this.isReady && this.synth; + } + + /** + * Speak text using browser TTS + * @param {string} text - The text to speak + * @param {Function} callback - Called when speech completes + */ + speak(text, callback = null) { + if (!this.isAvailable() || !text) { + if (callback) callback(); + return; + } + + // Stop any current speech + this.stop(); + + try { + // Create a new utterance + this.utterance = new SpeechSynthesisUtterance(text); + + // Apply voice options + if (this.voiceOptions.voice) { + // Find the voice by name or URI + const selectedVoice = this.voices.find(v => + v.name === this.voiceOptions.voice || + v.voiceURI === this.voiceOptions.voice + ); + if (selectedVoice) { + this.utterance.voice = selectedVoice; + } + } + + // Apply other options + this.utterance.rate = this.voiceOptions.rate; + this.utterance.pitch = this.voiceOptions.pitch; + this.utterance.volume = this.voiceOptions.volume; + + // Handle end of speech + this.utterance.onend = () => { + if (callback) callback(); + }; + + // Handle errors + this.utterance.onerror = (e) => { + console.error('Speech synthesis error:', e); + if (callback) callback(); + }; + + // Start speaking + this.synth.speak(this.utterance); + } catch (error) { + console.error('Error speaking with browser TTS:', error); + if (callback) callback(); + } + } + + /** + * Stop any ongoing speech + */ + stop() { + if (this.synth) { + this.synth.cancel(); + this.utterance = null; + } + } + + /** + * Set voice options + * @param {Object} options - Voice options + */ + setVoiceOptions(options = {}) { + if (options.voice !== undefined) this.voiceOptions.voice = options.voice; + if (options.rate !== undefined) this.voiceOptions.rate = options.rate; + if (options.pitch !== undefined) this.voiceOptions.pitch = options.pitch; + if (options.volume !== undefined) this.voiceOptions.volume = options.volume; + } +} diff --git a/public/js/debug-utils.js b/public/js/debug-utils.js new file mode 100644 index 0000000..bc2ced6 --- /dev/null +++ b/public/js/debug-utils.js @@ -0,0 +1,152 @@ +/** + * Debug Utilities for AI Interactive Fiction + * Provides debugging and testing tools + */ + +class DebugUtils { + /** + * Test the text processing pipeline with sample text + * @param {string} text - Test text to process + */ + static testTextPipeline(text = "This is a test sentence. Let's see if it displays correctly!") { + console.log("Debug: Testing text pipeline with:", text); + + // Find the text buffer + const textBuffer = window.TextBuffer || window.moduleRegistry?.getModule('text-buffer'); + if (textBuffer) { + textBuffer.addText(text); + console.log("Debug: Text added to buffer"); + return true; + } else { + console.error("Debug: TextBuffer not found"); + return false; + } + } + + /** + * Test the socket connection + */ + static testSocketConnection() { + console.log("Debug: Testing socket connection"); + + // Find the socket client + const socketClient = window.SocketClient || window.moduleRegistry?.getModule('socket-client'); + if (socketClient) { + if (socketClient.isConnected) { + console.log("Debug: Socket is connected"); + return true; + } else { + console.log("Debug: Socket is not connected, attempting connection"); + socketClient.connect(); + return false; + } + } else { + console.error("Debug: SocketClient not found"); + return false; + } + } + + /** + * Test the TTS system + * @param {string} text - Test text to speak + */ + static testTTS(text = "This is a test of the text to speech system.") { + console.log("Debug: Testing TTS with:", text); + + // Find the TTS player + const ttsPlayer = window.TTSPlayer || window.moduleRegistry?.getModule('tts'); + if (ttsPlayer) { + const wasEnabled = ttsPlayer.isEnabled(); + + // Enable TTS temporarily if it was disabled + if (!wasEnabled && ttsPlayer.toggle) { + ttsPlayer.toggle(); + } + + // Speak the text + ttsPlayer.speak(text, (result) => { + console.log("Debug: TTS completed with result:", result); + + // Restore previous enabled state + if (!wasEnabled && ttsPlayer.toggle) { + ttsPlayer.toggle(); + } + }); + + return true; + } else { + console.error("Debug: TTSPlayer not found"); + return false; + } + } + + /** + * Force all modules to reconnect + */ + static forceReconnect() { + console.log("Debug: Forcing module reconnection"); + + // Get all modules + const registry = window.moduleRegistry; + if (!registry) { + console.error("Debug: Module registry not found"); + return false; + } + + const modules = registry.getAllModules(); + + // UI Controller + const uiController = modules['ui-controller']; + if (uiController) { + if (uiController.textBuffer === null) { + uiController.textBuffer = modules['text-buffer']; + console.log("Debug: Reconnected UI Controller to Text Buffer"); + + // Reinitialize text buffer + if (uiController.initializeTextBuffer) { + uiController.initializeTextBuffer(); + } + } + + if (uiController.ttsHandler === null) { + uiController.ttsHandler = modules['tts']; + console.log("Debug: Reconnected UI Controller to TTS Player"); + } + } + + // Socket Client + const socketClient = modules['socket-client']; + if (socketClient) { + if (socketClient.textBuffer === null) { + socketClient.textBuffer = modules['text-buffer']; + console.log("Debug: Reconnected Socket Client to Text Buffer"); + } + } + + // Game Loop + const gameLoop = modules['game-loop']; + if (gameLoop) { + if (gameLoop.uiController === null) { + gameLoop.uiController = modules['ui-controller']; + console.log("Debug: Reconnected Game Loop to UI Controller"); + } + + if (gameLoop.socketClient === null) { + gameLoop.socketClient = modules['socket-client']; + console.log("Debug: Reconnected Game Loop to Socket Client"); + } + + if (gameLoop.textBuffer === null) { + gameLoop.textBuffer = modules['text-buffer']; + console.log("Debug: Reconnected Game Loop to Text Buffer"); + } + } + + return true; + } +} + +// Export as global for easy console access +window.DebugUtils = DebugUtils; + +export { DebugUtils }; diff --git a/public/js/game-loop.js b/public/js/game-loop.js new file mode 100644 index 0000000..31c47be --- /dev/null +++ b/public/js/game-loop.js @@ -0,0 +1,251 @@ +/** + * Game Loop Module for AI Interactive Fiction + * Manages the main game logic and connects various modules + */ +import { BaseModule } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; + +class GameLoopModule extends BaseModule { + constructor() { + super('game-loop', 'Game Loop'); + this.uiController = null; + this.socketClient = null; + this.ttsPlayer = null; + this.textBuffer = null; + this.isRunning = false; + this.gameState = { + started: false, + canLoad: false, + currentRoom: null, + inventory: [], + commandHistory: [] + }; + } + + /** + * Load module dependencies + * @returns {Promise} - Resolves when dependencies are loaded + */ + async loadDependencies() { + // Basic dependency declaration - details handled in waitForDependencies + return true; + } + + /** + * Wait for dependencies to be ready + */ + async waitForDependencies() { + try { + // Wait for TTS module with a timeout + const ttsReady = await moduleRegistry.waitForModule('tts', 15000); + + if (ttsReady) { + this.ttsPlayer = moduleRegistry.getModule('tts'); + this.reportProgress(30, "TTS module ready"); + } else { + console.warn("TTS module not ready, game will have limited functionality"); + } + + // Wait for UI Controller with a timeout + const uiReady = await moduleRegistry.waitForModule('ui-controller', 15000); + + if (uiReady) { + this.uiController = moduleRegistry.getModule('ui-controller'); + this.reportProgress(50, "UI Controller ready"); + } else { + console.warn("UI Controller not ready, game will have limited functionality"); + } + + // Get text buffer reference + this.textBuffer = moduleRegistry.getModule('text-buffer'); + if (this.textBuffer) { + this.reportProgress(60, "Text buffer ready"); + } + + // Continue even with limited functionality + return true; + } catch (error) { + console.error("Error waiting for dependencies:", error); + return false; + } + } + + /** + * Initialize the module + * @returns {Promise} - Resolves with success status + */ + async initialize() { + this.reportProgress(100, "Game loop initialized"); + return true; + } + + /** + * Start the game loop + */ + start() { + console.log("GameLoop: Starting game sequence..."); + + try { + // Update UI with initial game state + if (this.uiController && this.ttsPlayer) { + this.updateUIState(); + } else { + console.warn("GameLoop: UI Controller or TTS Player not ready for status update."); + } + + console.log("GameLoop: Setting up socket listeners and connecting..."); + + // Set up socket event listeners and connect + this.setupSocketEventListeners(); + + // Set the game loop as running + this.isRunning = true; + } catch (error) { + console.error("Error starting game loop:", error); + } + } + + /** + * Set up socket event listeners and connect to server + */ + setupSocketEventListeners() { + // Get the socket client module + this.socketClient = moduleRegistry.getModule('socket-client'); + + if (!this.socketClient) { + console.error("Socket client module not found"); + return; + } + + // Connect UI controller to socket client for command handling + if (this.uiController) { + this.uiController.socketClient = this.socketClient; + } else { + console.warn("GameLoop: UI Controller not ready for Socket Client assignment."); + } + + // Listen for socket connection event + this.socketClient.on('connect', () => { + console.log("GameLoop: Socket connected event received."); + + // Request a new game start when we (re)connect + console.log("GameLoop: Requesting start game on (re)connect."); + this.requestStartGame(); + }); + + // Listen for game state updates + this.socketClient.on('gameStateUpdate', (data) => { + console.log("GameLoop: Game state update received", data); + this.updateGameState(data); + }); + + // Listen for narrative responses + this.socketClient.on('narrativeResponse', (data) => { + console.log("GameLoop: Narrative response received", data); + // Text processing is handled by socket-client -> text-buffer -> ui-controller pipeline + }); + + // Listen for game introduction + this.socketClient.on('gameIntroduction', (data) => { + console.log("GameLoop: Received gameIntroduction"); + this.gameState.started = true; + this.updateUIState(); + // Text processing is handled by socket-client -> text-buffer -> ui-controller pipeline + }); + + // Connect to the socket server + this.socketClient.connect().then(success => { + if (success) { + console.log("GameLoop: Socket connection established successfully."); + console.log("GameLoop: Requesting to start a new game"); + this.requestStartGame(); + } else { + console.error("GameLoop: Failed to connect to socket server"); + } + }); + } + + /** + * Update the game state + * @param {Object} data - New game state data + */ + updateGameState(data) { + if (!data) return; + + // Update game state + if (data.currentRoom) { + this.gameState.currentRoom = data.currentRoom; + } + + if (data.inventory) { + this.gameState.inventory = data.inventory; + } + + // Update UI with new game state + this.updateUIState(); + } + + /** + * Update UI with current game state + */ + updateUIState() { + if (!this.uiController) return; + + // Update UI components based on game state + this.uiController.updateButtonStates(this.gameState); + } + + /** + * Request to start a new game + */ + requestStartGame() { + if (!this.socketClient) return; + + this.socketClient.requestStartGame(); + this.gameState.started = true; + } + + /** + * Request to save the current game + */ + requestSaveGame() { + if (!this.socketClient) return; + + this.socketClient.requestSaveGame(); + } + + /** + * Request to load a saved game + */ + requestLoadGame() { + if (!this.socketClient) return; + + this.socketClient.requestLoadGame(); + } + + /** + * Manually add text to the buffer + * Useful for testing or adding local messages + * @param {string} text - Text to add + */ + addText(text) { + if (!this.textBuffer) { + console.warn("Text buffer not available"); + return; + } + + this.textBuffer.addText(text); + } +} + +// Create the singleton instance +const GameLoop = new GameLoopModule(); + +// Register with the module registry +moduleRegistry.register(GameLoop); + +// Export the module +export { GameLoop }; + +// Keep a reference in window for loader system +window.GameLoop = GameLoop; diff --git a/public/js/hyphenopoly.module.js b/public/js/hyphenopoly.module.js new file mode 100644 index 0000000..dda742c --- /dev/null +++ b/public/js/hyphenopoly.module.js @@ -0,0 +1,677 @@ +/** + * @license Hyphenopoly.module.js 6.0.0 - hyphenation for node + * ©2024 Mathias Nater, Güttingen (mathiasnater at gmail dot com) + * https://github.com/mnater/Hyphenopoly + * + * Released under the MIT license + * http://mnater.github.io/Hyphenopoly/LICENSE + */ + +/* eslint-env node */ + +const decode = (() => { + const utf16ledecoder = new TextDecoder("utf-16le"); + return (ui16) => { + return utf16ledecoder.decode(ui16); + }; +})(); + +/** + * Create Object without standard Object-prototype + * @returns {Object} empty object + */ +const empty = () => { + return Object.create(null); +}; + +const H = empty(); + +H.supportedLanguages = [ + "af", + "as", + "be", + "bg", + "bn", + "ca", + "cs", + "cy", + "da", + "de", + "de-x-syllable", + "el-monoton", + "el-polyton", + "en-gb", + "en-us", + "eo", + "es", + "et", + "eu", + "fi", + "fo", + "fr", + "fur", + "ga", + "gl", + "gu", + "hi", + "hr", + "hsb", + "hu", + "hy", + "ia", + "id", + "is", + "it", + "ka", + "kmr", + "kn", + "la", + "lt", + "lv", + "mk", + "ml", + "mn-cyrl", + "mr", + "nb", + "nl", + "nn", + "no", + "oc", + "or", + "pa", + "pi", + "pl", + "pms", + "pt", + "rm", + "ro", + "ru", + "sh-cyrl", + "sh-latn", + "sk", + "sl", + "sq", + "sr-cyrl", + "sv", + "ta", + "te", + "th", + "tk", + "tr", + "uk", + "zh-latn-pinyin" +]; + +H.languages = new Map(); + +/** + * Create lang Object + * @param {string} lang The language + * @returns {Object} The newly created lang object + */ +function createLangObj(lang) { + if (!H.languages.has(lang)) { + H.languages.set(lang, empty()); + } + return H.languages.get(lang); +} + +/** + * Setup a language object (lo) and dispatch "engineReady" + * @param {string} lang The language + * @param {function} hyphenateFunction The hyphenateFunction + * @param {string} alphabet List of used characters + * @param {number} patternLeftmin leftmin as defined in patterns + * @param {number} patternRightmin rightmin as defined in patterns + * @returns {undefined} + */ +function prepareLanguagesObj( + lang, + hyphenateFunction, + alphabet, + patternLeftmin, + patternRightmin +) { + alphabet = alphabet.replace(/\\*-/g, "\\-"); + const lo = createLangObj(lang); + if (!lo.engineReady) { + lo.cache = new Map(); + const exc = []; + if (H.c.exceptions.has(lang)) { + exc.push(...H.c.exceptions.get(lang).split(", ")); + } + if (H.c.exceptions.has("global")) { + exc.push(...H.c.exceptions.get("global").split(", ")); + } + lo.exceptions = new Map(exc.map((e) => { + return [e.replace(/-/g, ""), e]; + })); + + lo.alphabet = alphabet; + lo.reNotAlphabet = RegExp(`[^${alphabet}]`, "i"); + lo.lm = Math.max( + patternLeftmin, + H.c.leftmin, + H.c.leftminPerLang.get(lang) || 0 + ); + lo.rm = Math.max( + patternRightmin, + H.c.rightmin, + H.c.rightminPerLang.get(lang) || 0 + ); + lo.hyphenate = hyphenateFunction; + lo.engineReady = true; + } + H.events.dispatch("engineReady", {"msg": lang}); +} + +/** + * Setup env for hyphenateFunction + * @param {Object} baseData baseData + * @param {function} hyphenateFunc hyphenateFunction + * @returns {function} hyphenateFunction with closured environment + */ +function encloseHyphenateFunction(buf, hyphenateFunc) { + const wordStore = new Uint16Array(buf, 0, 64); + + /** + * The hyphenateFunction that encloses the env above + * Copies the word to wasm-Memory, calls wasm.hyphenateFunc and reads + * the hyphenated word from wasm-Memory (eventually replacing hyphenchar) + * @param {String} word - the word that has to be hyphenated + * @param {String} hyphenchar - the hyphenate character + * @param {Number} leftmin - min number of chars to remain on line + * @param {Number} rightmin - min number of chars to go to new line + * @returns {String} the hyphenated word + */ + return ((word, hyphencc, leftmin, rightmin) => { + wordStore.set([ + ...[...word].map((c) => { + return c.charCodeAt(0); + }), + 0 + ]); + const len = hyphenateFunc(leftmin, rightmin, hyphencc); + if (len > 0) { + word = decode(new Uint16Array(buf, 0, len)); + } + return word; + }); +} + +/** + * Instantiate Wasm Engine + * @param {string} lang The language + * @returns {undefined} + */ +function instantiateWasmEngine(lang, wasmdata) { + /** + * Register character substitutions in the .wasm-hyphenEngine + * @param {number} alphalen - The length of the alphabet + * @param {object} exp - Export-object of the hyphenEngine + */ + function registerSubstitutions(alphalen, exp) { + if (H.c.substitute.has(lang)) { + const subst = H.c.substitute.get(lang); + subst.forEach((substituer, substituted) => { + const substitutedU = substituted.toUpperCase(); + const substitutedUcc = (substitutedU === substituted) + ? 0 + : substitutedU.charCodeAt(0); + alphalen = exp.subst( + substituted.charCodeAt(0), + substitutedUcc, + substituer.charCodeAt(0) + ); + }); + } + return alphalen; + } + + /** + * Instantiate the hyphenEngine + * @param {object} res - The fetched ressource + */ + function handleWasm(inst) { + const exp = inst.exports; + let alphalen = exp.lct.value; + alphalen = registerSubstitutions(alphalen, exp); + prepareLanguagesObj( + lang, + encloseHyphenateFunction( + exp.mem.buffer, + exp.hyphenate + ), + decode(new Uint16Array(exp.mem.buffer, 1664, alphalen)), + exp.lmi.value, + exp.rmi.value + ); + } + if (H.c.sync) { + const heInstance = new WebAssembly.Instance( + new WebAssembly.Module(wasmdata) + ); + handleWasm(heInstance); + } else { + WebAssembly.instantiate(wasmdata).then((res) => { + handleWasm(res.instance); + }); + } +} + +/** + * Read a .wasm file and call instantiateWasmEngine on success + * @param {string} lang - The language + * @returns {undefined} + */ +function loadHyphenEngine(lang) { + const file = `${lang}.wasm`; + // eslint-disable-next-line require-jsdoc + const cb = (err, data) => { + if (err) { + H.events.dispatch("error", { + "key": lang, + "msg": `${lang}.wasm not found.` + }); + } else { + instantiateWasmEngine(lang, new Uint8Array(data).buffer); + } + }; + + if (typeof H.c.loader !== "function") { + H.events.dispatch("error", { + "msg": "Loader must be a function." + }); + return; + } + + if (H.c.sync) { + cb(null, H.c.loaderSync(file, new URL('./patterns/', import.meta.url))); + } else { + H.c.loader(file, new URL('./patterns/', import.meta.url)).then( + (res) => { + cb(null, res); + }, + (err) => { + cb(err, null); + } + ); + } +} + +const wordHyphenatorPool = new Map(); + +/** + * Factory for hyphenatorFunctions for a specific language and class + * @param {Object} lo Language-Object + * @param {string} lang The language + * @returns {function} The hyphenate function + */ +function createWordHyphenator(lo, lang) { + if (wordHyphenatorPool.has(lang)) { + return wordHyphenatorPool.get(lang); + } + + /** + * HyphenateFunction for non-compound words + * @param {string} word The word + * @returns {string} The hyphenated word + */ + function hyphenateNormal(word) { + if (word.length > 61) { + H.events.dispatch("error", {"msg": "found word longer than 61 characters"}); + } else if (!lo.reNotAlphabet.test(word)) { + return lo.hyphenate( + word, + H.c.hyphen.charCodeAt(0), + lo.lm, + lo.rm + ); + } + return word; + } + + /** + * HyphenateFunction for compound words + * @param {string} word The word + * @returns {string} The hyphenated compound word + */ + function hyphenateCompound(word) { + let joiner = "-"; + const parts = word.split(joiner).map((p) => { + if (H.c.compound !== "hyphen" && + p.length >= H.c.minWordLength) { + return createWordHyphenator(lo, lang)(p); + } + return p; + }); + if (H.c.compound !== "auto") { + // Add Zero Width Space + joiner += "\u200B"; + } + return parts.join(joiner); + } + + /** + * Checks if a string is mixed case + * @param {string} s The string + * @returns {boolean} true if s is mixed case + */ + function isMixedCase(s) { + return Array.prototype.map.call(s, (c) => { + return (c === c.toLowerCase()); + }).some((v, i, a) => { + return (v !== a[0]); + }); + } + + /** + * HyphenateFunction for words (compound or not) + * @param {string} word The word + * @returns {string} The hyphenated word + */ + function hyphenator(word) { + let hw = lo.cache.get(word); + if (!hw) { + if (lo.exceptions.has(word)) { + hw = lo.exceptions.get(word).replace( + /-/g, + H.c.hyphen + ); + } else if (!H.c.mixedCase && isMixedCase(word)) { + hw = word; + } else if (word.includes("-")) { + hw = hyphenateCompound(word); + } else { + hw = hyphenateNormal(word); + } + lo.cache.set(word, hw); + } + return hw; + } + wordHyphenatorPool.set(lang, hyphenator); + return hyphenator; +} + +const orphanController = (() => { + /** + * Function template + * @param {string} ignore unused result of replace + * @param {string} leadingWhiteSpace The leading whiteSpace + * @param {string} lastWord The last word + * @param {string} trailingWhiteSpace The trailing whiteSpace + * @returns {string} Treated end of text + */ + function controlOrphans( + ignore, + leadingWhiteSpace, + lastWord, + trailingWhiteSpace + ) { + let h = H.c.hyphen; + if (".\\+*?[^]$(){}=!<>|:-".indexOf(H.c.hyphen) !== -1) { + h = `\\${H.c.hyphen}`; + } + if (H.c.orphanControl === 3 && leadingWhiteSpace === " ") { + // \u00A0 = no-break space (nbsp) + leadingWhiteSpace = "\u00A0"; + } + /* eslint-disable security/detect-non-literal-regexp */ + return leadingWhiteSpace + lastWord.replace(new RegExp(h, "g"), "") + trailingWhiteSpace; + /* eslint-enable security/detect-non-literal-regexp */ + } + return controlOrphans; +})(); + +/** + * Encloses hyphenateTextFunction + * @param {string} lang - The language + * @return {function} The hyphenateText-function + */ +function createTextHyphenator(lang) { + const lo = H.languages.get(lang); + const wordHyphenator = (wordHyphenatorPool.has(lang)) + ? wordHyphenatorPool.get(lang) + : createWordHyphenator(lo, lang); + + /* + * Transpiled RegExp of + * /[${alphabet}\p{Letter}-]{${minwordlength},}/gui + */ + const reWord = RegExp( + `[${lo.alphabet}a-z\u0300-\u036F\u0483-\u0487\u00DF-\u00F6\u00F8-\u00FE\u0101\u0103\u0105\u0107\u0109\u010D\u010F\u0111\u0113\u0117\u0119\u011B\u011D\u011F\u0123\u0125\u012B\u012F\u0131\u0135\u0137\u013C\u013E\u0142\u0144\u0146\u0148\u014D\u0151\u0153\u0155\u0159\u015B\u015D\u015F\u0161\u0165\u016B\u016D\u016F\u0171\u0173\u017A\u017C\u017E\u017F\u01CE\u01D0\u01D2\u01D4\u01D6\u01D8\u01DA\u01DC\u0219\u021B\u02BC\u0390\u03AC-\u03CE\u03D0\u03E3\u03E5\u03E7\u03E9\u03EB\u03ED\u03EF\u03F2\u0430-\u044F\u0451-\u045C\u045E\u045F\u0491\u04AF\u04E9\u0561-\u0585\u0587\u0905-\u090C\u090F\u0910\u0913-\u0928\u092A-\u0930\u0932\u0933\u0935-\u0939\u093D\u0960\u0961\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A85-\u0A8B\u0A8F\u0A90\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AE0\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B60\u0B61\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB5\u0BB7-\u0BB9\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39\u0D60\u0D61\u0D7A-\u0D7F\u0E01-\u0E2E\u0E30\u0E32\u0E33\u0E40-\u0E45\u10D0-\u10F0\u1200-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u1E0D\u1E37\u1E41\u1E43\u1E45\u1E47\u1E6D\u1F00-\u1F07\u1F10-\u1F15\u1F20-\u1F27\u1F30-\u1F37\u1F40-\u1F45\u1F50-\u1F57\u1F60-\u1F67\u1F70-\u1F7D\u1F80-\u1F87\u1F90-\u1F97\u1FA0-\u1FA7\u1FB2-\u1FB4\u1FB6\u1FB7\u1FC2-\u1FC4\u1FC6\u1FC7\u1FD2\u1FD3\u1FD6\u1FD7\u1FE2-\u1FE7\u1FF2-\u1FF4\u1FF6\u1FF7\u2C81\u2C83\u2C85\u2C87\u2C89\u2C8D\u2C8F\u2C91\u2C93\u2C95\u2C97\u2C99\u2C9B\u2C9D\u2C9F\u2CA1\u2CA3\u2CA5\u2CA7\u2CA9\u2CAB\u2CAD\u2CAF\u2CB1\u2CC9\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E-]{${H.c.minWordLength},}`, "gui" + ); + + /** + * Hyphenate text + * @param {string} text The text + * @param {string} lang The language of the text + * @returns {string} Hyphenated text + */ + return ((text) => { + if (H.c.normalize) { + text = text.normalize("NFC"); + } + let tn = text.replace(reWord, wordHyphenator); + if (H.c.orphanControl !== 1) { + tn = tn.replace( + /(\u0020*)(\S+)(\s*)$/, + orphanController + ); + } + return tn; + }); +} + +(() => { + // Events known to the system + const definedEvents = new Map(); + + /** + * Create Event Object + * @param {string} name The Name of the event + * @param {function|null} defFunc The default method of the event + * @param {boolean} cancellable Is the default cancellable + * @returns {undefined} + */ + function define(name, defFunc, cancellable) { + definedEvents.set(name, { + cancellable, + "default": defFunc, + "register": [] + }); + } + + define( + "error", + (e) => { + // eslint-disable-next-line no-console + console.error(e.msg); + }, + true + ); + + define( + "engineReady", + null, + false + ); + + /** + * Dispatch event with arguments + * @param {string} name The name of the event + * @param {Object|undefined} data Data of the event + * @returns {undefined} + */ + function dispatch(name, data) { + data.defaultPrevented = false; + data.preventDefault = (() => { + data.defaultPrevented = true; + }); + definedEvents.get(name).register.forEach((currentHandler) => { + currentHandler(data); + }); + if (!data.defaultPrevented && definedEvents.get(name).default) { + definedEvents.get(name).default(data); + } + } + + /** + * Add EventListender to event + * @param {string} name The name of the event + * @param {function} handler Function to register + * @returns {undefined} + */ + function addListener(name, handler) { + if (definedEvents.has(name)) { + definedEvents.get(name).register.push(handler); + } else { + H.events.dispatch( + "error", + {"msg": `unknown Event "${name}" discarded`} + ); + } + } + + H.events = empty(); + H.events.dispatch = dispatch; + H.events.addListener = addListener; +})(); + +/** + * Create a Map with a default Map behind the scenes. This mimics + * kind of a prototype chain of an object, but without the object- + * injection security risk. + * + * @param {Map} defaultsMap - A Map with default values + * @returns {Proxy} - A Proxy for the Map (dot-notation or get/set) + */ +function createMapWithDefaults(defaultsMap) { + const userMap = new Map(); + + /** + * The get-trap: get the value from userMap or else from defaults + * @param {Sring} key - The key to retrieve the value for + * @returns {*} + */ + function get(key) { + return (userMap.has(key)) + ? userMap.get(key) + : defaultsMap.get(key); + } + + /** + * The set-trap: set the value to userMap and don't touch defaults + * @param {Sring} key - The key for the value + * @param {*} value - The value + * @returns {*} + */ + function set(key, value) { + userMap.set(key, value); + } + return new Proxy(defaultsMap, { + "get": (_target, prop) => { + if (prop === "set") { + return set; + } + if (prop === "get") { + return get; + } + return get(prop); + } + }); +} + +/** + * Default loader emits error + * @returns null + */ +function defaultLoader() { + H.events.dispatch("error", { + "msg": "loader/loaderSync has not been configured." + }); + return null; +} + +H.config = ((userConfig) => { + const settings = createMapWithDefaults(new Map([ + ["compound", "hyphen"], + ["exceptions", new Map()], + ["hyphen", "\u00AD"], + ["leftmin", 0], + ["leftminPerLang", new Map()], + ["loader", defaultLoader], + ["loaderSync", defaultLoader], + ["minWordLength", 6], + ["mixedCase", true], + ["normalize", false], + ["orphanControl", 1], + ["require", []], + ["rightmin", 0], + ["rightminPerLang", new Map()], + ["substitute", new Map()], + ["sync", false] + ])); + Object.entries(userConfig).forEach(([key, value]) => { + switch (key) { + case "exceptions": + case "leftminPerLang": + case "paths": + case "rightminPerLang": + Object.entries(value).forEach(([k, v]) => { + settings.get(key).set(k, v); + }); + break; + case "substitute": + Object.entries(value).forEach(([lang, subst]) => { + settings.substitute.set( + lang, + new Map(Object.entries(subst)) + ); + }); + break; + default: + settings.set(key, value); + } + }); + H.c = settings; + if (H.c.handleEvent) { + Object.entries(H.c.handleEvent).forEach(([name, fn]) => { + H.events.addListener(name, fn); + }); + } + const result = new Map(); + if (H.c.require.length === 0) { + H.events.dispatch( + "error", + {"msg": "No language has been required. Setup config according to documenation."} + ); + } + H.c.require.forEach((lang) => { + if (H.c.sync) { + H.events.addListener("engineReady", (e) => { + if (e.msg === lang) { + result.set(lang, createTextHyphenator(lang)); + } + }); + } else { + const prom = new Promise((resolve, reject) => { + H.events.addListener("engineReady", (e) => { + if (e.msg === lang) { + resolve(createTextHyphenator(lang)); + } + }); + H.events.addListener("error", (e) => { + if (e.key === lang) { + reject(e.msg); + } + }); + }); + result.set(lang, prom); + } + loadHyphenEngine(lang); + }); + return result; +}); + +export default H; diff --git a/public/js/ink-story-player.js b/public/js/ink-story-player.js deleted file mode 100644 index c6f5f18..0000000 --- a/public/js/ink-story-player.js +++ /dev/null @@ -1,719 +0,0 @@ -/** - * InkStoryPlayer Module - * Orchestrates the narrative flow specific to the Ink story. - */ -export class InkStoryPlayer { - /** - * Create a new InkStoryPlayer - * @param {Object} config - Configuration options - * @param {Function} config.InkStory - The inkjs.Story constructor - * @param {Object} config.textProcessor - The TextProcessor instance - * @param {Object} config.paragraphLayout - The ParagraphLayout instance - * @param {Object} config.layoutRenderer - The LayoutRenderer instance - * @param {Object} config.audioManager - The AudioManager instance - * @param {Object} config.ttsPlayer - The TtsPlayer instance - * @param {Object} config.persistenceManager - The PersistenceManager instance - */ - constructor(config = {}) { - this.InkStory = config.InkStory; - this.textProcessor = config.textProcessor; - this.paragraphLayout = config.paragraphLayout; - this.layoutRenderer = config.layoutRenderer; - this.audioManager = config.audioManager; - this.ttsPlayer = config.ttsPlayer; - this.persistenceManager = config.persistenceManager; - - this.story = null; - this.storyContainer = document.getElementById('story'); - this.choiceContainer = document.getElementById('choices'); - this.savePoint = ""; - this.running = false; - this.keyEventListener = null; - this.indentedParagraphs = 0; - this.chapterBegin = false; - this.measure = []; - this.locale = 'en-us'; - this.translations = {}; - } - - /** - * Load a story from JSON content - * @param {Object} storyContent - The compiled Ink story JSON - * @param {string} initialState - Optional initial state to load - */ - loadStory(storyContent, initialState = null) { - this.story = new this.InkStory(storyContent); - - if (initialState) { - this.story.state.LoadJson(initialState); - } - - this.savePoint = this.story.state.toJson(); - - // Process global tags - this.processGlobalTags(); - } - - /** - * Process global tags from the story - */ - processGlobalTags() { - if (!this.story || !this.story.globalTags) return; - - for (let i = 0; i < this.story.globalTags.length; i++) { - const globalTag = this.story.globalTags[i]; - const splitTag = this.splitPropertyTag(globalTag); - - if (splitTag && splitTag.property == "title") { - const title = document.querySelector('.title'); - if (title) title.innerHTML = splitTag.val; - } else if (splitTag && splitTag.property == "author") { - const byline = document.querySelector('.byline'); - if (byline) byline.textContent += splitTag.val; - } else if (splitTag && splitTag.property == "subtitle") { - const subtitle = document.querySelector('.subtitle'); - if (subtitle) subtitle.textContent += splitTag.val; - } - } - } - - /** - * Continue the story - * @param {boolean} firstTime - Whether this is the first time continuing - */ - async continueStory(firstTime = true) { - if (!this.story) { - console.error('No story loaded'); - return; - } - - this.running = true; - this.layoutRenderer.setFastForwardingAll(false); - let chapterBegin = false; - - if (this.keyEventListener) { - window.removeEventListener('keypress', this.keyEventListener); - this.keyEventListener = null; - } - - document.querySelectorAll('#story p').forEach((p) => { - p.classList.remove("latest-paragraph"); - }); - - // Generate story text - loop through available content - while (this.story.canContinue) { - if (this.layoutRenderer.getFastForwardingAll()) { - return; - } - - if (this.layoutRenderer.animationQueue) { - this.layoutRenderer.animationQueue.setDelay(0.0); - } - - // Get ink to generate the next paragraph - const paragraphText = this.story.Continue(); - const tags = this.story.currentTags; - - // Process tags and get custom classes - const customClasses = this.processTags(tags); - - // Skip empty paragraphs - if (paragraphText.trim().length === 0) { - continue; - } - - // Process paragraph text and layout - await this.processParagraph(paragraphText, customClasses, chapterBegin); - - chapterBegin = false; - } - - if (this.layoutRenderer.animationQueue) { - this.layoutRenderer.animationQueue.setDelay(0.0); - } - - // Process choices - await this.processChoices(); - - // Dispatch turn complete event - const tce = new CustomEvent("turnCompleteEvent", { - detail: { messages: "All text and choices have been set up." }, - bubbles: true, - cancelable: false - }); - document.dispatchEvent(tce); - - // Check for end of story - if (this.story.canContinue === false && this.story.currentChoices.length === 0) { - const end = document.createElement("p"); - end.style.textTransform = "uppercase"; - end.style.textAlign = "center"; - end.classList.add("fade-in"); - end.classList.add("choice"); - - const endText = this.translations[this.locale] && this.translations[this.locale]['end'] - ? this.translations[this.locale]['end'] - : "The End"; - - end.appendChild(document.createTextNode(endText)); - this.choiceContainer.appendChild(end); - } - } - - /** - * Process tags for a paragraph - * @param {Array} tags - The tags for the paragraph - * @returns {Array} Custom CSS classes to apply - */ - processTags(tags) { - if (!tags || tags.length === 0) return []; - - const customClasses = []; - let tagDebug = ""; - - for (let i = 0; i < tags.length; i++) { - const tag = tags[i]; - tagDebug += tag + ";"; - - // Detect tags of the form "X: Y" - const splitTag = this.splitPropertyTag(tag); - - // AUDIO: src - if (splitTag && splitTag.property == "AUDIO") { - if (this.audioManager) { - this.audioManager.playSoundFromUrl(splitTag.val); - } - } - - // AUDIOLOOP: src - else if (splitTag && splitTag.property == "AUDIOLOOP") { - if (this.audioManager) { - this.audioManager.playSoundFromUrl(splitTag.val, true); - } - } - - // IMAGE: src - else if (splitTag && splitTag.property == "IMAGE") { - if (this.layoutRenderer) { - const imageElement = this.layoutRenderer.renderVisualTag("IMAGE", splitTag.val, this.storyContainer); - if (imageElement && this.layoutRenderer.animationQueue) { - this.layoutRenderer.showAfter(this.layoutRenderer.animationQueue.getDelay(), imageElement); - this.layoutRenderer.animationQueue.incrementDelay(this.layoutRenderer.animationQueue.getSpeed()); - } - } - } - - // LINK: url - else if (splitTag && splitTag.property == "LINK") { - window.location.href = splitTag.val; - } - - // LINKOPEN: url - else if (splitTag && splitTag.property == "LINKOPEN") { - window.open(splitTag.val); - } - - // BACKGROUND: src - else if (splitTag && splitTag.property == "BACKGROUND") { - if (this.layoutRenderer) { - this.layoutRenderer.renderVisualTag("BACKGROUND", splitTag.val); - } - } - - // CLASS: className - else if (splitTag && splitTag.property == "CLASS") { - customClasses.push(splitTag.val); - } - - // CLEAR - removes all existing content - else if (tag == "CLEAR") { - this.removeAll("p"); - this.removeAll("img"); - } - - // RESTART - clears everything and restarts the story from the beginning - else if (tag == "RESTART") { - this.removeAll("p"); - this.removeAll("img"); - this.restart(); - return []; - } - - // CHAPTER: Chapter Heading - else if (splitTag && splitTag.property == "CHAPTER") { - if (this.layoutRenderer) { - this.layoutRenderer.renderVisualTag("CHAPTER", splitTag.val, this.storyContainer); - } - this.chapterBegin = true; - this.indentedParagraphs = 2; - } - - // SEPARATOR - else if (tag == "SEPARATOR") { - if (this.layoutRenderer) { - this.layoutRenderer.renderVisualTag("SEPARATOR", splitTag.val, this.storyContainer); - } - this.chapterBegin = true; - } - } - - return customClasses; - } - - /** - * Process a paragraph of text - * @param {string} paragraphText - The paragraph text - * @param {Array} customClasses - Custom CSS classes to apply - * @param {boolean} chapterBegin - Whether this is the beginning of a chapter - */ - async processParagraph(paragraphText, customClasses, chapterBegin) { - const indentWidth = 2 * parseFloat(window.getComputedStyle(document.querySelector("#indent")).lineHeight); - let text = paragraphText; - let dropCap = null; - let dropQuote = null; - - if (this.chapterBegin) { - this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width)); - this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width) - indentWidth); - this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width) - indentWidth * 0.9); - - const words = paragraphText.split(" "); - let firstWord = words[0].substr(1, words[0].length); - let openingQuote = ""; - let firstLetter = words[0].substr(0, 1); - - if (firstLetter == "\"" || firstLetter == "'") { - openingQuote = window.SmartyPants ? window.SmartyPants.smartypantsu(firstLetter, 1) : firstLetter; - firstLetter = words[0].substr(1, 1); - firstWord = words[0].substr(2, words[0].length); - } - - if (firstWord.length < 5 && words.length > 1) { - firstWord += ' ' + words[1]; - } - - text = '' + firstWord + ' ' + paragraphText.substr(firstWord.length + 2 + openingQuote.length, paragraphText.length); - - dropCap = document.createElement("span"); - dropCap.classList.add("drop-cap"); - dropCap.appendChild(document.createTextNode(firstLetter)); - dropCap.style.left = '0%'; - dropCap.style.top = '0%'; - dropCap.style.position = 'absolute'; - - if (openingQuote) { - dropQuote = document.createElement("span"); - dropQuote.classList.add("drop-quote"); - dropQuote.appendChild(document.createTextNode(openingQuote)); - dropQuote.style.left = '-4.45%'; - dropQuote.style.top = '-16%'; - dropQuote.style.position = 'absolute'; - } - } else { - if (this.measure.length < 1) { - this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width)); - this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width) - indentWidth * 0.5); - } - } - - // Process text and calculate layout - const processedText = this.textProcessor.process(text); - const paragraphData = this.paragraphLayout.calculateLayout(processedText, [...this.measure].reverse(), true); - - // Render paragraph - const [p, d] = this.layoutRenderer.renderParagraph(paragraphData, this.layoutRenderer.animationQueue.getDelay(), [...this.measure].reverse()); - - // Update measures and indented paragraphs - for (let k = 0; k < parseInt(p.dataset.numberOfLines); k++) { - this.measure.pop(); - } - - this.indentedParagraphs -= p.dataset.numberOfLines; - - // Add drop cap and quote if needed - if (dropQuote) { - this.layoutRenderer.insertAfter(0, p, dropQuote, true); - } - - if (dropCap) { - this.layoutRenderer.insertAfter(0, p, dropCap, true); - } - - // Add custom classes - for (let i = 0; i < customClasses.length; i++) { - p.classList.add(customClasses[i]); - } - - p.lang = this.locale; - this.storyContainer.appendChild(p); - - // Smooth scroll to the paragraph - this.layoutRenderer.smoothScroll(p, this.layoutRenderer.animationQueue.getSpeed() * 10 * p.dataset.numberOfLines); - - // Wait for animations and speech - await Promise.all([ - new Promise(resolve => { - document.addEventListener('allWordsSetEvent', resolve, { once: true }); - }), - new Promise(async resolve => { - if (!this.ttsPlayer || !this.ttsPlayer.isEnabled()) { - resolve(); - return; - } - - await this.ttsPlayer.speak(paragraphText); - resolve(); - }) - ]); - } - - /** - * Process choices from the story - */ - async processChoices() { - const categoryContainers = { default: null }; - const categoryNumbers = { default: 0, categorized: 0 }; - - this.story.currentChoices.forEach(choice => { - if (this.layoutRenderer.getFastForwardingAll()) { - return; - } - - let tagDebug = ""; - let action = "default"; - - choice.tags.forEach(tag => { - tagDebug += tag + ";"; - const splitTag = this.splitPropertyTag(tag); - - if (splitTag && splitTag.property === "ACTION") { - action = splitTag.val; - } - }); - - const prompt = this.translations[this.locale] && this.translations[this.locale][`action_${action}`] - ? this.translations[this.locale][`action_${action}`] - : (action === "default" ? "What do you want to do?" : action); - - if (action != "default") { - this.createChoiceContainer(categoryContainers, categoryNumbers, action, prompt, choice, tagDebug); - } else { - const defaultPrompt = this.translations[this.locale] && this.translations[this.locale]['prompt'] - ? this.translations[this.locale]['prompt'] - : "What do you want to do?"; - - this.createChoiceContainer(categoryContainers, categoryNumbers, "default", defaultPrompt, choice, tagDebug, true); - } - }); - - // Set up key event listener - this.keyEventListener = this.setupKeyEventListener(); - } - - /** - * Create a choice container - * @param {Object} categoryContainers - Map of category containers - * @param {Object} categoryNumbers - Map of category numbers - * @param {string} action - The action category - * @param {string} prompt - The prompt text - * @param {Object} choice - The choice object - * @param {string} tagDebug - Debug information for tags - * @param {boolean} registerKeys - Whether to register keyboard shortcuts - * @returns {HTMLElement} The choice container element - */ - createChoiceContainer(categoryContainers, categoryNumbers, action, prompt, choice, tagDebug, registerKeys = false) { - let choiceCategoryContainer = categoryContainers[action]; - - if (!choiceCategoryContainer) { - choiceCategoryContainer = document.createElement('ol'); - const p = document.createElement('p'); - p.innerHTML = prompt; - choiceCategoryContainer.appendChild(p); - choiceCategoryContainer.classList.add("choice"); - choiceCategoryContainer.classList.add("fade-in"); - - if (!registerKeys) { - choiceCategoryContainer.classList.add("categorized"); - } - - if (this.story.currentChoices.length && !this.layoutRenderer.getFastForwardingAll()) { - this.choiceContainer.appendChild(choiceCategoryContainer); - } - } - - categoryContainers[action] = choiceCategoryContainer; - - let choiceNumber = categoryNumbers[action]; - if (choiceNumber === undefined) { - choiceNumber = 0; - } - - choiceNumber++; - - const choiceParagraphElement = document.createElement('li'); - choiceParagraphElement.classList.add("choice"); - choiceParagraphElement.lang = this.locale; - choiceParagraphElement.title = tagDebug; - - // Use SmartyPants if available - const choiceText = window.SmartyPants && typeof window.SmartyPants.smartypantsu === 'function' - ? window.SmartyPants.smartypantsu(choice.text, 1) - : choice.text; - - choiceParagraphElement.innerHTML = `${choiceText}`; - - if (!this.layoutRenderer.getFastForwardingAll()) { - this.layoutRenderer.insertAfter(this.layoutRenderer.animationQueue.getDelay(), choiceCategoryContainer, choiceParagraphElement, true); - } - - this.layoutRenderer.animationQueue.incrementDelay(this.layoutRenderer.animationQueue.getSpeed()); - - // Register key shortcuts - if (registerKeys) { - choiceParagraphElement.value = choiceNumber; - this.registerKey('Digit' + choiceNumber, choice.index); - } else { - let categorizedNumber = categoryNumbers['categorized']; - categorizedNumber++; - const keyLetter = String.fromCharCode(64 + categorizedNumber); - choiceParagraphElement.value = categorizedNumber; - this.registerKey('Key' + keyLetter, choice.index); - categoryNumbers['categorized'] = categorizedNumber; - } - - // Click on choice - const choiceAnchorEl = choiceParagraphElement.querySelectorAll("a")[0]; - choiceAnchorEl.addEventListener("click", (event) => { - // Don't follow link - event.preventDefault(); - this.chooseChoice(choice.index); - }); - - categoryNumbers[action] = choiceNumber; - - return choiceCategoryContainer; - } - - /** - * Choose a choice by index - * @param {number} index - The choice index - */ - chooseChoice(index) { - // Remove all existing choices - this.removeAll(".choice", true); - this.clearKeyRegistry(); - - // Tell the story where to go next - this.story.ChooseChoiceIndex(index); - - // This is where the save button will save from - this.savePoint = this.story.state.toJson(); - - // Continue the story - this.continueStory(false); - } - - /** - * Register a key for keyboard shortcuts - * @param {string} key - The key code - * @param {number} choice - The choice index - */ - registerKey(key, choice) { - this.keyRegistry[key] = choice; - } - - /** - * Clear the key registry - */ - clearKeyRegistry() { - this.keyRegistry = {}; - } - - /** - * Set up the key event listener for choices - * @returns {Function} The key event listener - */ - setupKeyEventListener() { - const keyEventListener = (event) => { - for (const key in this.keyRegistry) { - if (event.code === key) { - window.removeEventListener('keypress', keyEventListener); - this.chooseChoice(this.keyRegistry[key]); - break; - } - } - }; - - window.addEventListener('keypress', keyEventListener); - - return keyEventListener; - } - - /** - * Restart the story - */ - restart() { - if (!this.story) return; - - if (this.layoutRenderer.animationQueue) { - this.layoutRenderer.animationQueue.setDelay(0.0); - } - - this.story.ResetState(); - this.layoutRenderer.setFastForwardingAll(true); - this.layoutRenderer.animationQueue.fastForward(); - - this.removeAll("p"); - this.removeAll("img"); - this.removeAll("h2"); - this.removeAll("double"); - this.removeAll(".choice", true); - - if (this.keyEventListener) { - window.removeEventListener('keypress', this.keyEventListener); - this.keyEventListener = null; - } - - // Set save point to here - this.savePoint = this.story.state.toJson(); - - // Continue the story - this.continueStory(); - } - - /** - * Save the current state - * @returns {boolean} Whether the save was successful - */ - saveState() { - if (!this.persistenceManager || !this.story) return false; - - try { - const history = Array.from(document.querySelectorAll("#story p:not(.latest-paragraph)")).map(p => p.outerHTML); - - return this.persistenceManager.saveState({ - inkJson: this.savePoint, - history: history - }); - } catch (e) { - console.warn("Couldn't save state:", e); - return false; - } - } - - /** - * Load a saved state - * @returns {boolean} Whether the load was successful - */ - loadState() { - if (!this.persistenceManager || !this.story) return false; - - try { - const savedState = this.persistenceManager.loadState(); - - if (!savedState) { - return false; - } - - this.removeAll("p"); - this.removeAll("img"); - this.removeAll("h2"); - this.removeAll("double"); - this.removeAll(".choice", true); - - if (savedState.history) { - savedState.history.forEach(p => { - const d = document.createElement('div'); - d.innerHTML = p; - this.storyContainer.appendChild(d.firstChild); - }); - } - - if (savedState.inkJson) { - this.story.state.LoadJson(savedState.inkJson); - this.savePoint = savedState.inkJson; - } - - // Update paragraph heights - this.updateParagraphHeight(); - - if (this.keyEventListener) { - window.removeEventListener('keypress', this.keyEventListener); - this.keyEventListener = null; - } - - // Continue the story - this.continueStory(); - - return true; - } catch (e) { - console.warn("Couldn't load state:", e); - return false; - } - } - - /** - * Update paragraph heights based on viewport - */ - updateParagraphHeight() { - document.querySelectorAll("#story p").forEach((element) => { - if (element.dataset.vpc) { - const pHeight = parseFloat(window.getComputedStyle(document.getElementById('page_right')).height); - const newHeight = pHeight * element.dataset.vpc / 100 + 'px'; - element.style.height = newHeight; - } - }); - } - - /** - * Remove all elements that match the given selector - * @param {string} selector - The CSS selector - * @param {boolean} choices - Whether to remove from the choice container - */ - removeAll(selector, choices = false) { - const container = choices ? this.choiceContainer : this.storyContainer; - const allElements = container.querySelectorAll(selector); - - for (let i = 0; i < allElements.length; i++) { - const el = allElements[i]; - el.parentNode.removeChild(el); - } - } - - /** - * Helper for parsing out tags of the form: # PROPERTY: value - * @param {string} tag - The tag to parse - * @returns {Object|null} The parsed property and value - */ - splitPropertyTag(tag) { - const propertySplitIdx = tag.indexOf(":"); - - if (propertySplitIdx !== -1) { - const property = tag.substr(0, propertySplitIdx).trim(); - const val = tag.substr(propertySplitIdx + 1).trim(); - - return { - property: property, - val: val - }; - } - - return null; - } - - /** - * Set the locale for translations - * @param {string} locale - The locale code - */ - setLocale(locale) { - this.locale = locale; - } - - /** - * Set translations - * @param {Object} translations - The translations object - */ - setTranslations(translations) { - this.translations = translations; - } -} diff --git a/public/js/input-handler.js b/public/js/input-handler.js deleted file mode 100644 index 9584af3..0000000 --- a/public/js/input-handler.js +++ /dev/null @@ -1,290 +0,0 @@ -/** - * Input Handler Module - * Manages the multi-line text input field with a custom cursor. - */ -export class InputHandler { - constructor(inputId = 'player_input', cursorId = 'cursor') { - this.playerInput = document.getElementById(inputId); - this.cursor = document.getElementById(cursorId); - this.commandInputContainer = document.getElementById('command_input'); // Assuming this container exists - - if (!this.playerInput || !this.cursor || !this.commandInputContainer) { - console.error('InputHandler: Required DOM elements not found.'); - return; - } - - this.commandSubmitCallback = null; // Callback for when a command is submitted - - this.bindEvents(); - this.adjustTextareaHeight(); // Initial adjustment - this.updateCursorPosition(); // Initial position - - // Setup handler for window load event to ensure proper initialization - window.addEventListener('load', () => { - console.log('InputHandler: Window loaded, adjusting text area height and cursor position'); - this.adjustTextareaHeight(); - this.updateCursorPosition(); - }); - } - - /** - * Register a callback function to be called when a command is submitted. - * @param {function(string)} callback - The function to call with the command text. - */ - onCommandSubmit(callback) { - this.commandSubmitCallback = callback; - } - - /** - * Bind event handlers to the input element. - */ - bindEvents() { - // Submit command on Enter key without Shift - this.playerInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); // Prevent default to avoid newline - this.submitCommand(); - } - // Allow Shift+Enter for new lines (default behavior) - }); - - // Auto-resize textarea and update cursor on input - this.playerInput.addEventListener('input', () => { - this.adjustTextareaHeight(); - this.updateCursorPosition(); - }); - - // Update cursor on various events - this.playerInput.addEventListener('click', this.updateCursorPosition.bind(this)); - this.playerInput.addEventListener('keyup', this.updateCursorPosition.bind(this)); - - // Show/hide cursor on focus/blur - this.playerInput.addEventListener('focus', () => { - if (this.cursor) this.cursor.style.opacity = '1'; - this.updateCursorPosition(); - }); - this.playerInput.addEventListener('blur', () => { - if (this.cursor) this.cursor.style.opacity = '0'; - }); - - // Handle paste events - this.playerInput.addEventListener('paste', () => { - // Use setTimeout to let the paste complete before adjusting - setTimeout(() => { - this.adjustTextareaHeight(); - this.updateCursorPosition(); - }, 10); - }); - - // Handle window resize - window.addEventListener('resize', () => { - this.adjustTextareaHeight(); - this.updateCursorPosition(); - }); - } - - /** - * Submit the current command. - */ - submitCommand() { - const command = this.playerInput.value.trim(); - if (command === '' || !this.commandSubmitCallback) return; - - // Fade out the input field container - if (this.commandInputContainer) { - this.commandInputContainer.classList.add('fading'); - } - - // Disable input temporarily - this.playerInput.disabled = true; - - // Call the registered callback - this.commandSubmitCallback(command); - - // Clear input - this.clearInput(); - } - - /** - * Clears the input field and resets its state. - */ - clearInput() { - this.playerInput.value = ''; - this.resetCursorPosition(); - this.adjustTextareaHeight(); - } - - /** - * Re-enables the input field after a command submission or response. - */ - enableInput() { - if (this.commandInputContainer) { - // Remove fading class and add fade-in animation - this.commandInputContainer.classList.remove('fading'); - this.commandInputContainer.classList.add('fade-in-input'); - - // Remove animation class after it completes - setTimeout(() => { - if (this.commandInputContainer) { - this.commandInputContainer.classList.remove('fade-in-input'); - } - }, 500); // Match CSS animation duration - } - - this.playerInput.disabled = false; - this.focus(); - } - - /** - * Focuses the input field. - */ - focus() { - this.playerInput.focus(); - // Ensure cursor is visible and positioned correctly after focus - setTimeout(() => { - if (this.cursor) this.cursor.style.opacity = '1'; - this.updateCursorPosition(); - }, 10); - } - - /** - * Gets the current value of the input field. - * @returns {string} The input text. - */ - getValue() { - return this.playerInput.value; - } - - /** - * Sets the value of the input field. - * @param {string} value - The text to set. - */ - setValue(value) { - this.playerInput.value = value; - this.adjustTextareaHeight(); - this.updateCursorPosition(); - this.focus(); // Focus after setting value - } - - /** - * Resets the cursor position to the start. - */ - resetCursorPosition() { - if (this.cursor) { - this.cursor.style.left = '0px'; - // Adjust top based on computed style padding or a default - const computedStyle = window.getComputedStyle(this.playerInput); - const paddingTop = parseFloat(computedStyle.paddingTop) || 6; - this.cursor.style.top = `${paddingTop}px`; - } - } - - /** - * Update the custom cursor position based on input text and caret position. - * Uses a temporary div for accurate measurement. - */ - updateCursorPosition() { - if (!this.cursor || !this.playerInput) return; - - const input = this.playerInput; - const cursor = this.cursor; - const caretPosition = input.selectionStart || 0; - const inputText = input.value; - - // If no text, position cursor at the beginning based on padding - if (inputText.length === 0 && caretPosition === 0) { - this.resetCursorPosition(); - return; - } - - // Create a temporary measurement div - const div = document.createElement('div'); - const style = getComputedStyle(input); - - // Apply relevant styles from the textarea to the div - div.style.position = 'absolute'; - div.style.top = '-9999px'; - div.style.left = '-9999px'; - div.style.width = style.width; - div.style.height = 'auto'; - div.style.padding = style.padding; - div.style.border = style.border; - div.style.fontFamily = style.fontFamily; - div.style.fontSize = style.fontSize; - div.style.fontWeight = style.fontWeight; - div.style.lineHeight = style.lineHeight; - div.style.whiteSpace = 'pre-wrap'; - div.style.wordWrap = 'break-word'; - div.style.boxSizing = style.boxSizing; - - // Create spans for text before and after the caret, and a marker span - const preCaretText = document.createTextNode(inputText.substring(0, caretPosition)); - const caretMarker = document.createElement('span'); - caretMarker.innerHTML = ' '; // Use non-breaking space for measurement - const postCaretText = document.createTextNode(inputText.substring(caretPosition)); - - // Append spans to the div - div.appendChild(preCaretText); - div.appendChild(caretMarker); - div.appendChild(postCaretText); - - // Append div to body for measurement - document.body.appendChild(div); - - // Get position relative to the div's content box - const markerRect = caretMarker.getBoundingClientRect(); - const divRect = div.getBoundingClientRect(); - - // Calculate position relative to the input's top-left, considering scroll - const cursorLeft = markerRect.left - divRect.left; - const cursorTop = markerRect.top - divRect.top - input.scrollTop; - - // Set cursor position - cursor.style.left = `${cursorLeft}px`; - cursor.style.top = `${cursorTop}px`; - - // Clean up the temporary div - document.body.removeChild(div); - } - - /** - * Adjust textarea height based on its content. - */ - adjustTextareaHeight() { - if (!this.playerInput) return; - const textarea = this.playerInput; - // Temporarily reset height to accurately measure scrollHeight - textarea.style.height = 'auto'; - // Set height to scrollHeight to fit content, adding a small buffer if needed - textarea.style.height = `${textarea.scrollHeight}px`; - } - - /** - * Sets up focus management to keep the input field focused. - * Note: Some parts might be better handled by the main application logic - * depending on overall focus requirements (e.g., clicking outside input). - */ - setupFocusManagement() { - // Focus input field when the handler is initialized - this.focus(); - - // Re-focus input when user returns to this browser tab/window - window.addEventListener('focus', () => this.focus()); - window.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { - setTimeout(() => this.focus(), 100); - } - }); - - // Optional: Add a listener to the document to refocus if needed, - // but be careful not to interfere with other interactive elements. - /* - document.addEventListener('click', (e) => { - // Example: Refocus if click is not on specific elements - if (!e.target.closest('button, a, .interactive-ui-element')) { - this.focus(); - } - }); - */ - } -} diff --git a/public/js/kokoro-handler.js b/public/js/kokoro-handler.js index 5793041..b4218f8 100644 --- a/public/js/kokoro-handler.js +++ b/public/js/kokoro-handler.js @@ -1,597 +1,708 @@ /** - * Kokoro Text-to-Speech Handler for AI Interactive Fiction - * Uses the kokoro-js library for high-quality TTS + * KokoroHandler for AI Interactive Fiction + * Handles neural TTS via Kokoro.js with progress reporting and non-blocking loading */ +import { TTSHandler } from './tts-handler.js'; -export class KokoroHandler { - constructor() { - this.enabled = false; - this.speaking = false; - this.paused = false; - this.audio = null; - this.currentSpeed = 1.0; // Note: KokoroTTS might not support speed changes directly - this.audioQueue = []; - this.isProcessingQueue = false; - this.kokoroReady = false; - this.kokoroInstance = null; // Store the KokoroTTS instance - this.hasUserActivation = false; - this.initializationPromise = null; - this.audioContext = null; // For playing the generated audio - this.currentVoice = "af_heart"; // Default voice from README - this.currentAudioSource = null; // To keep track of the playing audio source - - // Start initialization process - this.initializeKokoro(); - } - - /** - * Initialize Kokoro TTS by waiting for the class and then instantiating - */ - async initializeKokoro() { - if (this.initializationPromise) { - return this.initializationPromise; +export class KokoroHandler extends TTSHandler { + constructor() { + super(); // Initialize the base TTSHandler + this.kokoro = null; + this.isReady = false; + this.currentUtterance = null; + // Set default voice options through the base class + this.voiceOptions = { + voice: 'bf_alice', // Default to high-quality voice + speed: 1.0 + }; + this.progressCallback = null; + this.scriptLoaded = false; + this.useLegacyFormat = false; + this.legacySpeak = null; + this.availableVoices = []; + this.worker = null; + this.workerReady = false; + this.pendingWorkerRequests = []; + this.workerInitialized = false; } - this.initializationPromise = new Promise(async (resolve) => { - try { - // Wait for the KokoroTTS class to be loaded - if (typeof window.KokoroTTS === 'undefined') { - console.log('Kokoro TTS class not found, waiting for it to load...'); + /** + * Get the ID of this provider + * @returns {string} - Provider ID + */ + getId() { + return 'kokoro'; + } - let loadTimeoutId = null; // Variable to hold the timeout ID + /** + * Initialize the Kokoro TTS system + * @param {Function} progressCallback - Optional callback for progress updates + * @returns {Promise} - Resolves to true if initialization was successful + */ + async initialize(progressCallback = null) { + this.progressCallback = progressCallback; - const loadHandler = async () => { - clearTimeout(loadTimeoutId); // <<< Clear the timeout - window.removeEventListener('kokoro-class-loaded', loadHandler); - window.removeEventListener('kokoro-class-load-failed', failHandler); - console.log('KokoroTTS class loaded event received.'); - const success = await this._initKokoroInstance(); - resolve(success); - }; + try { + // First load the script if not already loaded + if (this.progressCallback) this.progressCallback(10, "Loading Kokoro script"); + await this.loadKokoroScript(); - const failHandler = () => { - clearTimeout(loadTimeoutId); // <<< Clear the timeout - window.removeEventListener('kokoro-class-loaded', loadHandler); - window.removeEventListener('kokoro-class-load-failed', failHandler); - console.error('KokoroTTS class failed to load.'); - resolve(false); - }; - - window.addEventListener('kokoro-class-loaded', loadHandler); - window.addEventListener('kokoro-class-load-failed', failHandler); - - // Timeout if the event never fires - loadTimeoutId = setTimeout(() => { // <<< Store the timeout ID - // Check if still waiting (listener might have run but instance not ready yet) - if (!this.kokoroInstance && !this.kokoroReady) { - window.removeEventListener('kokoro-class-loaded', loadHandler); - window.removeEventListener('kokoro-class-load-failed', failHandler); - console.error('Timed out waiting for KokoroTTS class load event.'); - resolve(false); + // Check if the kokoro library is loaded correctly + if (!window.kokoro) { + console.error("Kokoro TTS library not loaded correctly"); + this.isReady = false; + document.dispatchEvent(new CustomEvent('kokoro-loading-complete', { + detail: { success: false, error: "Library not loaded" } + })); + return false; } - }, 15000); // Increased timeout - return; + // Report progress + if (this.progressCallback) { + this.progressCallback(40, "Creating Kokoro instance"); + } + + // Initialize Kokoro instance + try { + const apiFound = await this.checkKokoroApi(); + if (!apiFound) { + console.error("Kokoro TTS API not found in loaded module"); + this.isReady = false; + if (this.progressCallback) this.progressCallback(100, "Kokoro API not found"); + document.dispatchEvent(new CustomEvent('kokoro-loading-complete', { + detail: { success: false, error: "API not found" } + })); + return false; + } + + // Initialize the Web Worker for speech generation + await this.initWorker(); + + // Set ready state + this.isReady = true; + + if (this.isReady) { + console.log('Kokoro TTS initialized successfully'); + if (this.progressCallback) { + this.progressCallback(100, "Kokoro TTS ready"); + } + } else { + console.warn('Kokoro TTS initialized but not available'); + if (this.progressCallback) { + this.progressCallback(100, "Kokoro TTS unavailable"); + } + } + } catch (initError) { + console.error("Failed to initialize Kokoro instance:", initError); + this.isReady = false; + if (this.progressCallback) { + this.progressCallback(100, "Kokoro initialization failed"); + } + } + + // Dispatch event indicating completion, with success status + document.dispatchEvent(new CustomEvent('kokoro-loading-complete', { + detail: { success: this.isReady } + })); + + return this.isReady; + } catch (error) { + console.error('Failed to initialize Kokoro TTS:', error); + this.isReady = false; + + // Dispatch event even on error to unblock waiting processes + document.dispatchEvent(new CustomEvent('kokoro-loading-complete', { + detail: { success: false, error: error.message } + })); + + return false; } - - // If we get here, KokoroTTS class is already available - console.log('KokoroTTS class found directly.'); - const success = await this._initKokoroInstance(); - resolve(success); - - } catch (error) { - console.error('Error during KokoroHandler initialization:', error); - resolve(false); - } - }); - - return this.initializationPromise; - } - - /** - * Internal method to create and initialize the KokoroTTS instance - * @private - */ - async _initKokoroInstance() { - if (this.kokoroInstance || this.kokoroReady) return true; // Already initialized or initializing - - try { - console.log('Initializing KokoroTTS instance (GPU Only Attempt)...'); - const model_id = "onnx-community/Kokoro-82M-v1.0-ONNX"; - - // --- Check for WebGPU Support --- - const device = await this.getBestDevice(); - if (device !== 'webgpu') { - console.warn('WebGPU not available or supported. Kokoro TTS (GPU) cannot be initialized.'); - // Explicitly set ready to false and return false to signal failure - this.kokoroReady = false; - return false; - } - // --- End WebGPU Check --- - - // Use fp32 for WebGPU as recommended - const dtype = 'fp32'; - console.log(`Attempting KokoroTTS init with device: ${device}, dtype: ${dtype}`); - - console.log(`Calling KokoroTTS.from_pretrained('${model_id}', { dtype: '${dtype}', device: '${device}' })...`); - - // --- Add Timeout Wrapper for from_pretrained --- - const fromPretrainedPromise = window.KokoroTTS.from_pretrained(model_id, { - dtype: dtype, - device: device, // Always 'webgpu' if we reach here - }); - - const pretrainedTimeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('KokoroTTS.from_pretrained (WebGPU) timed out after 55 seconds')), 55000) // 55 seconds timeout - ); - - try { - this.kokoroInstance = await Promise.race([ - fromPretrainedPromise, - pretrainedTimeoutPromise - ]); - } catch (timeoutError) { - console.error(timeoutError.message); // Log the specific timeout error - throw timeoutError; // Re-throw to be caught by the outer catch block - } - // --- End Timeout Wrapper --- - - console.log('KokoroTTS.from_pretrained call completed.'); - - if (!this.kokoroInstance) { - console.error('KokoroTTS.from_pretrained returned a falsy value.'); - throw new Error('KokoroTTS.from_pretrained returned null or undefined.'); - } - - // Defer AudioContext creation until first use - - this.kokoroReady = true; - console.log('Kokoro TTS (WebGPU) instance created successfully (AudioContext deferred).'); - return true; - } catch (error) { - console.error('Error during KokoroTTS (WebGPU) initialization:', error); - if (error.message) { - console.error('Error message:', error.message); - } - if (error.stack) { - console.error('Error stack:', error.stack); - } - this.kokoroInstance = null; - this.kokoroReady = false; - return false; // Ensure failure is explicitly returned - } - } - - /** - * Determine the best device (webgpu or wasm) - * Checks for WebGPU support. - * @private - */ - async getBestDevice() { - if (navigator.gpu) { - try { - // Request an adapter. If this succeeds, WebGPU is likely available. - const adapter = await navigator.gpu.requestAdapter(); - if (adapter) { - console.log('WebGPU supported, selecting webgpu device.'); - return 'webgpu'; - } - console.warn('WebGPU adapter request returned null.'); - } catch (e) { - console.warn('WebGPU adapter request failed:', e); - } - } - console.log('WebGPU not supported or available, cannot use GPU for Kokoro.'); - return 'wasm'; // Return wasm indicating GPU is not the best/available option - } - - /** - * List available voices (delegates to KokoroTTS instance) - */ - async listVoices() { - if (!this.kokoroReady || !this.kokoroInstance) { - console.warn('Kokoro not ready, cannot list voices.'); - return []; - } - try { - // The README uses tts.list_voices(), assuming it's a method on the instance - if (typeof this.kokoroInstance.list_voices === 'function') { - return await this.kokoroInstance.list_voices(); - } else { - console.warn('list_voices method not found on KokoroTTS instance. Returning default.'); - // Fallback based on README examples - return [{ name: 'af_heart', description: 'Default American Female' }]; - } - } catch (error) { - console.error('Error listing Kokoro voices:', error); - return []; - } - } - - /** - * Set the voice to use - * @param {string} voiceName - Name of the voice (e.g., 'af_heart') - */ - setVoice(voiceName) { - this.currentVoice = voiceName; - console.log(`Kokoro voice set to: ${voiceName}`); - } - - /** - * Toggle TTS functionality on/off - * @returns {boolean} New state of TTS (enabled/disabled) - */ - toggle() { - // Set user activation flag when toggle is called - this.hasUserActivation = true; - - // --- Create AudioContext on first activation --- - if (!this.audioContext) { - try { - this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); - console.log('AudioContext created on user activation.'); - // Resume if context starts suspended - if (this.audioContext.state === 'suspended') { - this.audioContext.resume().catch(err => console.error('Error resuming initial AudioContext:', err)); - } - } catch (e) { - console.error('Failed to create AudioContext:', e); - // If AudioContext fails, Kokoro cannot play audio - this.kokoroReady = false; - return false; - } - } - // --- End AudioContext Creation --- - - if (!this.kokoroReady) { - console.warn('Kokoro TTS not ready yet'); - // Optionally, trigger re-initialization or inform user - return false; } - this.enabled = !this.enabled; - console.log("Kokoro TTS toggled:", this.enabled ? "ON" : "OFF"); - - // Stop any ongoing speech when disabling - if (!this.enabled && (this.speaking || this.isProcessingQueue)) { - this.stop(); - } - - return this.enabled; - } - - /** - * Set the speech rate/speed - * @param {number} speed - Speed multiplier (0.1 to 2.0) - */ - setSpeed(speed) { - this.currentSpeed = Math.max(0.5, Math.min(2.0, speed)); - } - - /** - * Process text for better speech synthesis - * @param {string} text - Text to process - * @returns {string} - Processed text - */ - processTextForSpeech(text) { - if (!text) return ""; - - // Remove markdown/formatting that would sound strange when read - text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); // Bold - text = text.replace(/\*([^*]+)\*/g, '$1'); // Italic - text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // Links - - // Clean up any HTML tags - text = text.replace(/<[^>]+>/g, ''); - - return text; - } - - /** - * Split text into digestible chunks for better TTS handling - * @param {string} text - Text to split - * @returns {string[]} - Array of text chunks - */ - splitTextIntoChunks(text) { - if (!text) return []; - - // Split by sentence terminators, keeping the terminator with the chunk - const sentenceRegex = /[^.!?]+[.!?]+/g; - const sentences = text.match(sentenceRegex) || [text]; - - // Group sentences into chunks for better performance - const chunks = []; - let currentChunk = ''; - - for (const sentence of sentences) { - // If adding this sentence would make the chunk too long, start a new chunk - if (currentChunk.length + sentence.length > 500) { - if (currentChunk) chunks.push(currentChunk); - currentChunk = sentence; - } else { - currentChunk += sentence; - } - } - - // Add the last chunk if it's not empty - if (currentChunk) chunks.push(currentChunk); - - return chunks; - } - - /** - * Process the speech queue using KokoroTTS - * @private - */ - async processQueue() { - // Ensure AudioContext is ready before processing - if (!this.audioContext) { - console.warn('AudioContext not available, cannot process Kokoro queue.'); - this.isProcessingQueue = false; - this.speaking = false; - return; - } - // Ensure AudioContext is running - if (this.audioContext.state === 'suspended') { - await this.audioContext.resume().catch(err => console.error('Error resuming AudioContext for queue:', err)); - } - - if (this.isProcessingQueue || this.audioQueue.length === 0 || !this.kokoroReady || !this.kokoroInstance) { - if (this.audioQueue.length === 0) { - this.speaking = false; // Ensure speaking flag is reset when queue is empty - } - // Reset processing flag if we exit early - if (this.isProcessingQueue && this.audioQueue.length === 0) { - this.isProcessingQueue = false; - } - return; - } - - this.isProcessingQueue = true; - this.speaking = true; // Set speaking true when processing starts - - try { - const textChunk = this.audioQueue.shift(); - - if (!textChunk) { - this.isProcessingQueue = false; - this.speaking = false; - return; - } - - console.log(`Kokoro generating chunk (${this.audioQueue.length} remaining):`, textChunk.substring(0, 30) + "..."); - - try { - // Use Kokoro instance to generate audio - const audioResult = await this.kokoroInstance.generate(textChunk, { - voice: this.currentVoice, + /** + * Initialize the Web Worker for speech generation + * @returns {Promise} - Resolves when the worker is initialized + */ + initWorker() { + return new Promise((resolve, reject) => { + try { + // Create the worker + this.worker = new Worker('/js/kokoro-worker.js'); + + // Handle messages from the worker + this.worker.onmessage = (e) => { + const { type, result, error } = e.data; + + switch (type) { + case 'ready': + console.log('Kokoro worker is ready'); + this.workerReady = true; + + // Process any pending requests + this.processPendingRequests(); + resolve(); + break; + + case 'initialized': + console.log('Kokoro worker initialized'); + this.workerInitialized = true; + break; + + case 'generated': + if (this.currentUtterance && this.currentUtterance.resolveGenerate) { + // Convert the ArrayBuffer back to Float32Array + const audioData = { + audio: new Float32Array(result.audio), + sampling_rate: result.sampling_rate + }; + this.currentUtterance.resolveGenerate(audioData); + } + break; + + case 'error': + console.error('Kokoro worker error:', error); + if (this.currentUtterance && this.currentUtterance.rejectGenerate) { + this.currentUtterance.rejectGenerate(new Error(error)); + } + break; + + default: + console.warn('Unknown message type from worker:', type); + } + }; + + // Handle worker errors + this.worker.onerror = (error) => { + console.error('Kokoro worker error:', error); + this.workerReady = false; + if (this.currentUtterance && this.currentUtterance.rejectGenerate) { + this.currentUtterance.rejectGenerate(error); + } + reject(error); + }; + + // Initialize the worker + this.worker.postMessage({ + type: 'init' + }); + + } catch (error) { + console.error('Failed to initialize Kokoro worker:', error); + reject(error); + } }); + } - // --- Updated Check: Expect Float32Array --- - if (!audioResult || !audioResult.audio || !(audioResult.audio instanceof Float32Array) || !audioResult.sampling_rate) { - console.error('Invalid audio data or sampling rate received from KokoroTTS.generate', audioResult); - throw new Error('Invalid audio data or sampling rate received from KokoroTTS.generate'); + /** + * Process any pending worker requests + */ + processPendingRequests() { + if (this.pendingWorkerRequests.length > 0 && this.workerReady) { + const request = this.pendingWorkerRequests.shift(); + this.generateInWorker(request.text, request.options) + .then(request.resolve) + .catch(request.reject); + + // Process the next request after a small delay to keep UI responsive + if (this.pendingWorkerRequests.length > 0) { + setTimeout(() => this.processPendingRequests(), 10); + } } - // --- End Updated Check --- + } - const rawAudioSamples = audioResult.audio; - const samplingRate = audioResult.sampling_rate; - console.log(`Received raw audio samples (${rawAudioSamples.length}), sample rate: ${samplingRate}`); + /** + * Generate speech in the worker + * @param {string} text - Text to convert to speech + * @param {Object} options - Voice options + * @returns {Promise} - Resolves with audio data + */ + generateInWorker(text, options) { + return new Promise((resolve, reject) => { + if (!this.worker || !this.workerReady) { + // Queue the request if worker isn't ready + this.pendingWorkerRequests.push({ + text, + options, + resolve, + reject + }); + return; + } + + // Store the promise callbacks in the current utterance + if (this.currentUtterance) { + this.currentUtterance.resolveGenerate = resolve; + this.currentUtterance.rejectGenerate = reject; + + // Send the generation request to the worker + this.worker.postMessage({ + type: 'generate', + data: { + text, + voice: options.voice, + speed: options.speed + } + }); + } else { + reject(new Error('No current utterance for worker generation')); + } + }); + } - // Decode and play the raw audio samples - await this.playRawAudio(rawAudioSamples, samplingRate); + /** + * Load the Kokoro script dynamically + * @returns {Promise} - Resolves when script is loaded + */ + loadKokoroScript() { + return new Promise((resolve, reject) => { + // Check if already loaded + if (this.scriptLoaded || typeof window.kokoro !== 'undefined') { + this.scriptLoaded = true; + resolve(); + return; + } - } catch (error) { - console.error("Error generating or playing Kokoro speech:", error); - } finally { - // Always continue processing the queue - this.isProcessingQueue = false; - // Check if queue is now empty to reset speaking flag - if (this.audioQueue.length === 0) { - this.speaking = false; - console.log("Kokoro queue finished."); + // Import the module using dynamic import + import('/js/kokoro-js.js') + .then(module => { + this.scriptLoaded = true; + console.log("Kokoro module structure:", Object.keys(module)); + + // Store the module in window.kokoro + window.kokoro = module; + + if (this.progressCallback) this.progressCallback(30, "Kokoro script loaded"); + console.log("Kokoro script loaded successfully via dynamic import"); + + if (module.KokoroTTS) { + console.log("Found KokoroTTS class in module"); + resolve(); + } else if (module.TextSplitterStream && typeof module.TextSplitterStream === 'function') { + console.log("Found TextSplitterStream in module, this may be the correct format"); + resolve(); + } else if (module.pipeline || (module.default && module.default.pipeline)) { + console.log("Found pipeline in module, using Xenova/kokoro-tts format"); + window.kokoroTTS = module.default || module; + this.useLegacyFormat = true; + resolve(); + } else { + console.error("Kokoro module found but couldn't locate TTS constructor"); + reject(new Error('Kokoro TTS API not found in module')); + } + }) + .catch(error => { + console.error("Error importing Kokoro module:", error); + reject(error); + }); + }); + } + + /** + * Check if the Kokoro library is loaded correctly and locate the API + * @returns {boolean} - True if API is found + */ + async checkKokoroApi() { + if (this.useLegacyFormat && window.kokoroTTS) { + // Handle legacy format (Xenova/kokoro-tts) + try { + if (this.progressCallback) this.progressCallback(40, "Loading Kokoro model"); + + this.kokoro = await window.kokoroTTS.pipeline('text-to-speech', { + quantized: true, + progress_callback: (progress) => { + if (this.progressCallback && progress % 10 === 0 || progress === 100) { + const message = `Loading Kokoro model: ${progress}%`; + this.progressCallback(40 + (progress * 0.5), message); + } + } + }); + + this.legacySpeak = async (text, options) => { + return await this.kokoro(text, options); + }; + + if (this.progressCallback) this.progressCallback(90, "Kokoro model loaded"); + this.isReady = !!this.kokoro; + return this.isReady; + } catch (error) { + console.error("Failed to initialize legacy Kokoro pipeline:", error); + return false; + } + } else if (window.kokoro) { + // Handle newer kokoro-js format + try { + if (window.kokoro.KokoroTTS) { + console.log("Using KokoroTTS class from module"); + + if (this.progressCallback) this.progressCallback(40, "Creating Kokoro TTS instance"); + + const model_id = "onnx-community/Kokoro-82M-v1.0-ONNX"; + + try { + console.log("Starting Kokoro model initialization - this may take some time"); + if (this.progressCallback) this.progressCallback(50, "Loading Kokoro model files"); + + // Use the from_pretrained method to load the model + this.kokoro = await window.kokoro.KokoroTTS.from_pretrained(model_id, { + dtype: "fp32", // Using fp32 for better quality + device: "wasm", // Using wasm for compatibility + onProgress: (progress) => { + if (this.progressCallback) { + const scaledProgress = 40 + (progress * 50); + const intProgress = Math.floor(progress * 100); + if (intProgress % 10 === 0 || intProgress === 100) { + const message = `Loading Kokoro model: ${intProgress}%`; + this.progressCallback(Math.min(90, scaledProgress), message); + } + } + } + }); + + console.log("Kokoro model initialization complete"); + if (this.progressCallback) this.progressCallback(90, "Kokoro model loaded"); + + // Available voices from the library + this.availableVoices = [ + 'af_heart', 'af_alloy', 'af_aoede', 'af_bella', 'af_jessica', + 'af_kore', 'af_nicole', 'af_nova', 'af_river', 'af_sarah', + 'af_sky', 'am_adam', 'am_echo', 'am_eric', 'am_fenrir', + 'am_liam', 'am_michael', 'am_onyx', 'am_puck', 'am_santa', + 'bf_emma', 'bf_isabella', 'bm_george', 'bm_lewis', 'bf_alice', + 'bf_lily', 'bm_daniel', 'bm_fable' + ]; + + console.log("Using available voices:", this.availableVoices); + + this.isReady = true; + return true; + } catch (modelError) { + console.error("Failed to initialize Kokoro model:", modelError); + return false; + } + } + + console.error("Could not find compatible API in Kokoro module"); + return false; + } catch (error) { + console.error("Failed to initialize Kokoro instance:", error); + return false; + } } - // Use setTimeout to avoid potential stack overflow on rapid processing - setTimeout(() => this.processQueue(), 0); - } - } catch (error) { - console.error("Error in Kokoro processQueue:", error); - this.isProcessingQueue = false; - this.speaking = false; // Reset speaking flag on error - } - } - /** - * Play raw Float32Array audio samples using Web Audio API - * @param {Float32Array} samples - The raw audio samples - * @param {number} sampleRate - The sample rate of the audio - * @private - */ - async playRawAudio(samples, sampleRate) { - if (!this.audioContext) { - console.error('AudioContext not initialized.'); - return; - } - if (this.audioContext.state === 'suspended') { - await this.audioContext.resume().catch(err => console.error('Error resuming AudioContext for playback:', err)); + return false; } - try { - // Create an AudioBuffer - const audioBuffer = this.audioContext.createBuffer( - 1, // Number of channels (assuming mono) - samples.length, // Length of the buffer - sampleRate // Sample rate - ); - - // Copy the samples to the AudioBuffer - // NOTE: If audio is stereo, this needs adjustment - audioBuffer.copyToChannel(samples, 0); - - // Create a source node - const source = this.audioContext.createBufferSource(); - source.buffer = audioBuffer; - source.connect(this.audioContext.destination); - - // Store the current source to allow stopping - this.currentAudioSource = source; - - console.log(`Playing audio buffer (${(samples.length / sampleRate).toFixed(2)}s)`); - - return new Promise((resolve) => { - source.onended = () => { - // Check if this source was the one we intended to stop - if (this.currentAudioSource === source) { - this.currentAudioSource = null; - } - console.log('Audio playback finished.'); - resolve(); - }; - source.start(0); // Start playback immediately - }); - } catch (error) { - console.error('Error creating or playing raw audio buffer:', error); - this.currentAudioSource = null; // Clear source on error - } - } - - /** - * Speak the provided text using KokoroTTS - * @param {string} text - Text to be spoken - * @param {function} onEndCallback - Callback when all speech ends - */ - speak(text, onEndCallback = null) { - if (!this.enabled || !text) { - if (onEndCallback) onEndCallback(); - return; + /** + * Check if Kokoro TTS is available + * @returns {boolean} - True if Kokoro is ready to use + */ + isAvailable() { + return this.isReady && this.kokoro !== null; } - // If kokoro is not ready yet, wait for initialization - if (!this.kokoroReady) { - console.warn("Kokoro TTS not ready yet, waiting for initialization..."); - this.initializationPromise.then(success => { - if (success && this.enabled) { - this._speakInternal(text, onEndCallback); - } else { - console.error("Kokoro failed to initialize, cannot speak."); - if (onEndCallback) onEndCallback(); + /** + * Check if voice is currently speaking + * @returns {boolean} - True if speaking + */ + isSpeaking() { + return this.currentUtterance !== null; + } + + /** + * Speak text using Kokoro TTS + * @param {string} text - The text to speak + * @param {Function} callback - Called when speech completes + */ + async speak(text, callback = null) { + if (!this.isAvailable() || !text) { + if (callback) callback("TTS not available or no text provided"); + return; + } + + console.log(`Attempting to speak: "${text}" with voice: ${this.voiceOptions.voice}`); + + // Stop any current speech + this.stop(); + + try { + // Process text for better TTS quality + const processedText = this.processText(text); + + // Create the utterance object to track state + this.currentUtterance = { + text: processedText, + options: { ...this.voiceOptions }, + onComplete: callback + }; + + if (window.kokoro.KokoroTTS && this.kokoro) { + // Generate audio with the selected voice using the worker + console.log(`Generating audio for text: "${processedText}" with voice: ${this.voiceOptions.voice}`); + + try { + // Generate speech in the worker + const audioData = await this.generateInWorker(processedText, { + voice: this.voiceOptions.voice, + speed: this.voiceOptions.speed || 1.0 + }); + + if (!audioData) { + throw new Error("Worker generated no audio data"); + } + + // Create WAV from Float32Array for browser playback + const wavBlob = this.float32ArrayToWav(audioData.audio, audioData.sampling_rate); + const url = URL.createObjectURL(wavBlob); + const audio = new Audio(url); + + // Set up event handlers for the audio element + audio.oncanplay = async () => { + try { + await audio.play(); + } catch (playError) { + console.error("Error playing audio:", playError); + if (this.currentUtterance && this.currentUtterance.onComplete) { + this.currentUtterance.onComplete("Play error: " + playError.message); + } + URL.revokeObjectURL(url); + } + }; + + audio.onended = () => { + if (this.currentUtterance && this.currentUtterance.onComplete) { + this.currentUtterance.onComplete("Playback completed successfully"); + } + this.currentUtterance = null; + URL.revokeObjectURL(url); + }; + + audio.onerror = (error) => { + console.error("Error playing Kokoro audio:", error); + if (this.currentUtterance && this.currentUtterance.onComplete) { + this.currentUtterance.onComplete("Audio error: " + (error.message || "unknown error")); + } + this.currentUtterance = null; + URL.revokeObjectURL(url); + }; + + // Store the audio element for stopping later + this.currentUtterance.audio = audio; + } catch (genError) { + console.error("Error generating audio:", genError); + if (callback) callback("Generate error: " + genError.message); + this.currentUtterance = null; + } + } else { + console.error("Kokoro TTS not properly initialized"); + if (callback) callback("Kokoro not initialized"); + this.currentUtterance = null; + } + } catch (error) { + console.error('Error speaking with Kokoro TTS:', error); + if (callback) callback("Error: " + error.message); + this.currentUtterance = null; } - }); - return; } - this._speakInternal(text, onEndCallback); - } + /** + * Stop any ongoing speech + */ + stop() { + if (this.currentUtterance && this.currentUtterance.audio) { + try { + this.currentUtterance.audio.pause(); + this.currentUtterance.audio.currentTime = 0; + } catch (error) { + console.error('Error stopping Kokoro TTS:', error); + } - /** - * Internal method to handle speech after initialization checks - * @private - */ - _speakInternal(text, onEndCallback) { - // Ensure AudioContext is resumed after user interaction - if (this.audioContext && this.audioContext.state === 'suspended') { - this.audioContext.resume().catch(err => console.error('Error resuming AudioContext:', err)); + // Call the completion callback if it exists + if (this.currentUtterance.onComplete) { + this.currentUtterance.onComplete(); + } + + this.currentUtterance = null; + } } - // Don't attempt to speak without user activation - if (!this.hasUserActivation) { - console.warn("Not attempting to speak because there hasn't been user interaction yet"); - if (onEndCallback) onEndCallback(); - return; + /** + * Set the speech rate/speed + * @param {number} speed - Speech rate (0.5-2.0) + */ + setSpeed(speed) { + // Ensure speed is within valid range + const normalizedSpeed = Math.max(0.5, Math.min(2.0, speed)); + this.voiceOptions.speed = normalizedSpeed; } - try { - const processedText = this.processTextForSpeech(text); - console.log("Kokoro TTS attempting to speak:", processedText.substring(0, 50) + "..."); - - // Stop any existing speech - this.stop(); - - // Split into manageable chunks (consider if Kokoro handles long text well) - const chunks = this.splitTextIntoChunks(processedText); - this.audioQueue = chunks; - - // Start processing the queue - if (this.audioQueue.length > 0 && !this.isProcessingQueue) { - this.processQueue(); - } - - // Set up a completion callback - if (onEndCallback) { - const checkCompletion = () => { - if (!this.isSpeaking()) { // Check if speaking is false - onEndCallback(); - } else { - setTimeout(checkCompletion, 150); // Check again shortly - } - }; - // Start checking slightly after processing begins - setTimeout(checkCompletion, 100); - } - } catch (error) { - console.error("Error in Kokoro speak:", error); - if (onEndCallback) onEndCallback(); - } - } - - /** - * Pause the current speech (Note: May not be perfectly resumable with AudioBufferSourceNode) - */ - pause() { - if (!this.speaking || !this.audioContext) return; - // Suspending AudioContext is a way to pause, but resuming might not be seamless - this.audioContext.suspend().catch(err => console.error('Error suspending AudioContext:', err)); - this.paused = true; - console.log('Kokoro audio paused (via AudioContext suspend)'); - } - - /** - * Resume paused speech - */ - resume() { - if (!this.paused || !this.audioContext) return; - this.audioContext.resume().catch(err => console.error('Error resuming AudioContext:', err)); - this.paused = false; - console.log('Kokoro audio resumed (via AudioContext resume)'); - } - - /** - * Stop the current speech - */ - stop() { - // Stop any currently playing audio source - if (this.currentAudioSource) { - try { - this.currentAudioSource.stop(); - } catch (e) { - // Ignore errors if source already stopped - } - this.currentAudioSource = null; + /** + * Set the voice to use + * @param {string} voice - Voice identifier + */ + setVoice(voice) { + if (voice && typeof voice === 'string') { + this.voiceOptions.voice = voice; + } } - // Clear the queue and reset flags - this.audioQueue = []; - this.isProcessingQueue = false; - this.speaking = false; - this.paused = false; - console.log('Kokoro speech stopped and queue cleared.'); - } + /** + * Get available voices + * @returns {Promise} - Array of available voices + */ + async getVoices() { + if (!this.isAvailable()) return []; - /** - * Check if TTS is currently active/enabled - */ - isEnabled() { - return this.enabled && this.kokoroReady; - } + try { + // Return the manually collected list of voices + if (this.availableVoices && this.availableVoices.length > 0) { + return this.availableVoices.map(id => ({ + id: id, + name: this.formatVoiceId(id) + })); + } + + // Fallback to hardcoded list if needed + return [ + { id: 'af_heart', name: 'Heart (Female)' }, + { id: 'af_bella', name: 'Bella (Female)' }, + { id: 'am_michael', name: 'Michael (Male)' }, + { id: 'bf_emma', name: 'Emma (British Female)' }, + { id: 'bm_george', name: 'George (British Male)' } + ]; + } catch (error) { + console.error('Error getting Kokoro voices:', error); + return []; + } + } - /** - * Check if speech is currently in progress - */ - isSpeaking() { - // Consider both the processing flag and if an audio source is active - return this.speaking || this.isProcessingQueue || !!this.currentAudioSource; - } -} + /** + * Format a voice ID into a readable name + * @param {string} voiceId - The voice ID to format + * @returns {string} - Formatted voice name + */ + formatVoiceId(voiceId) { + if (!voiceId) return "Unknown Voice"; + + // Convert id like "en_us_female" to "English (US) - Female" + const parts = voiceId.split('_'); + + if (parts.length >= 3) { + // Try to map language codes + const languageMap = { + 'en': 'English', + 'fr': 'French', + 'de': 'German', + 'es': 'Spanish', + 'it': 'Italian', + 'pt': 'Portuguese', + 'pl': 'Polish', + 'ja': 'Japanese', + 'ko': 'Korean', + 'zh': 'Chinese', + 'ru': 'Russian' + }; + + // Get language name + const langName = languageMap[parts[0]] || parts[0].toUpperCase(); + + // Get region + const region = parts[1].toUpperCase(); + + // Get gender or voice type + const voiceType = parts.slice(2).join(' ').replace(/\b\w/g, l => l.toUpperCase()); + + return `${langName} (${region}) - ${voiceType}`; + } + + // For other naming formats, capitalize words + return voiceId.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + } -// Create and export a singleton for the factory to use -export const kokoroHandler = new KokoroHandler(); \ No newline at end of file + /** + * Process text for better TTS quality + * @param {string} text - Raw text + * @returns {string} - Processed text + */ + processText(text) { + if (!text) return ''; + + // Replace specific patterns for better TTS + let processed = text + .replace(/\s+/g, ' ') // Normalize whitespace + .replace(/--/g, '—') // Em dash + .replace(/\.\.\./g, '…') // Ellipsis + .trim(); + + return processed; + } + + /** + * Convert a Float32Array to a WAV format Blob + * @param {Float32Array} float32Array - The audio data as Float32Array + * @param {number} sampleRate - The sample rate of the audio + * @returns {Blob} - A WAV format Blob + */ + float32ArrayToWav(float32Array, sampleRate = 24000) { + // WAV header is 44 bytes + const buffer = new ArrayBuffer(44 + float32Array.length * 4); + const view = new DataView(buffer); + + // Write WAV header + // "RIFF" chunk descriptor + this.writeString(view, 0, 'RIFF'); + view.setUint32(4, 36 + float32Array.length * 4, true); // File size + this.writeString(view, 8, 'WAVE'); + + // "fmt " sub-chunk + this.writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); // Subchunk1Size (16 for PCM) + view.setUint16(20, 3, true); // AudioFormat (3 for IEEE float) + view.setUint16(22, 1, true); // NumChannels (1 for mono) + view.setUint32(24, sampleRate, true); // SampleRate + view.setUint32(28, sampleRate * 4, true); // ByteRate (SampleRate * NumChannels * BitsPerSample/8) + view.setUint16(32, 4, true); // BlockAlign (NumChannels * BitsPerSample/8) + view.setUint16(34, 32, true); // BitsPerSample (32 bits for float) + + // "data" sub-chunk + this.writeString(view, 36, 'data'); + view.setUint32(40, float32Array.length * 4, true); // Subchunk2Size + + // Write the Float32Array data + const offset = 44; + for (let i = 0; i < float32Array.length; i++) { + view.setFloat32(offset + i * 4, float32Array[i], true); + } + + // Create a Blob from the buffer + return new Blob([buffer], { type: 'audio/wav' }); + } + + /** + * Write a string to a DataView + * @param {DataView} view - The DataView to write to + * @param {number} offset - The offset to write at + * @param {string} string - The string to write + */ + writeString(view, offset, string) { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } + } + + /** + * Clean up resources when this handler is no longer needed + */ + dispose() { + this.stop(); + + // Terminate the worker if it exists + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } + + // Clean up other resources + this.kokoro = null; + this.isReady = false; + } +} \ No newline at end of file diff --git a/public/js/kokoro-worker.js b/public/js/kokoro-worker.js new file mode 100644 index 0000000..3ac2b97 --- /dev/null +++ b/public/js/kokoro-worker.js @@ -0,0 +1,120 @@ +/** + * Kokoro Web Worker + * Handles TTS processing in a separate thread to keep UI responsive + */ + +// Global variables +let kokoroLoaded = false; +let isProcessing = false; +let voiceOptions = { + voice: 'bf_alice', + speed: 1.0 +}; + +// Initialize when receiving init message +self.onmessage = function(e) { + const message = e.data; + + try { + switch (message.type) { + case 'init': + // Just acknowledge initialization - actual model loading happens on first generate call + self.postMessage({ type: 'ready' }); + break; + + case 'generate': + if (!message.data || !message.data.text) { + self.postMessage({ + type: 'error', + error: 'No text provided for generation' + }); + return; + } + + // Store voice options + if (message.data.voice) voiceOptions.voice = message.data.voice; + if (message.data.speed) voiceOptions.speed = message.data.speed; + + // Generate speech + generateSpeech(message.data.text) + .catch(error => { + self.postMessage({ + type: 'error', + error: `Generation error: ${error.message || error}` + }); + }); + break; + + default: + self.postMessage({ + type: 'error', + error: `Unknown message type: ${message.type}` + }); + } + } catch (error) { + self.postMessage({ + type: 'error', + error: `Worker error: ${error.message || error}` + }); + } +}; + +/** + * Generate speech from text + * @param {string} text - Text to convert to speech + */ +async function generateSpeech(text) { + if (isProcessing) { + throw new Error('Already processing another request'); + } + + isProcessing = true; + + try { + // Load Kokoro if not already loaded + if (!kokoroLoaded) { + // Load the Kokoro script + self.importScripts('/js/kokoro-js.js'); + + if (!self.kokoro || !self.kokoro.KokoroTTS) { + throw new Error('Kokoro failed to load correctly'); + } + + kokoroLoaded = true; + } + + // Create a new Kokoro instance for this generation + // We can't easily transfer the instance from the main thread, so we create it here + const kokoroTTS = self.kokoro.KokoroTTS; + + // Create instance using from_pretrained + const tts = await kokoroTTS.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", { + dtype: "fp32", + device: "wasm", + cache: true // Use cache to speed up subsequent loads + }); + + // Generate speech + const result = await tts.generate(text, { + voice: voiceOptions.voice, + speed: voiceOptions.speed + }); + + // Send the result back to the main thread + // We can't transfer the Float32Array directly, so let's transfer the buffer + const audioBuffer = result.audio.buffer; + + self.postMessage({ + type: 'generated', + result: { + audio: audioBuffer, + sampling_rate: result.sampling_rate + } + }, [audioBuffer]); // Transfer the buffer for better performance + + } catch (error) { + throw error; + } finally { + isProcessing = false; + } +} \ No newline at end of file diff --git a/public/js/loader.js b/public/js/loader.js new file mode 100644 index 0000000..dbb2191 --- /dev/null +++ b/public/js/loader.js @@ -0,0 +1,532 @@ +/** + * Module Loader System + * + * Handles loading and initializing modules in the correct order, + * with dependency management and progress reporting. + */ +import { moduleRegistry } from './module-registry.js'; +import { ModuleEvent } from './base-module.js'; + +/** + * Module States + */ +const ModuleState = { + PENDING: 'PENDING', + LOADING: 'LOADING', + WAITING: 'WAITING', + INITIALIZING: 'INITIALIZING', + FINISHED: 'FINISHED', + ERROR: 'ERROR' +}; + +/** + * Module Loader - Manages the loading of all modules + */ +const ModuleLoader = (function() { + // Private variables + let loadingOverlay = null; + let modulesList = null; + let progressIndicator = null; + let progressText = null; + let statusText = null; + let isLoadingComplete = false; + let moduleWeights = {}; + let createdModules = new Set(); // Track which modules we've created UI elements for + let gameLoopModule = null; // Add variable to hold game loop instance + + /** + * Initialize the loader + */ + function init() { + // Prevent duplicate initialization + if (createdModules.size > 0) { + console.warn('Module Loader already initialized'); + return; + } + + console.log('Module Loader: Initialization started'); + // Create the loading overlay + createLoadingOverlay(); + + // Setup event listeners + setupEventListeners(); + + // Load available module scripts + loadModuleScripts().then(() => { + // Once scripts are loaded, initialize modules + initializeModules(); + }); + } + + /** + * Setup event listeners for module communication + */ + function setupEventListeners() { + // Listen for module progress events + document.addEventListener('module:progress', handleModuleProgress); + + // Listen for module state change events + document.addEventListener('module:stateChange', handleModuleStateChange); + + // Listen for module status message events + document.addEventListener('module:message', handleModuleMessage); + } + + /** + * Load all module scripts + * @returns {Promise} - Resolves when all module scripts are loaded + */ + async function loadModuleScripts() { + // Define modules with their weights + const modulesToLoad = [ + // Core functionality modules + { id: 'persistence-manager', script: '/js/persistence-manager.js', weight: 40 }, + { id: 'localization', script: '/js/localization.js', weight: 40 }, + { id: 'text-processor', script: '/js/text-processor.js', weight: 40 }, + { id: 'paragraph-layout', script: '/js/paragraph-layout.js', weight: 40 }, + { id: 'animation-queue', script: '/js/animation-queue.js', weight: 50 }, + + // Audio and TTS modules + { id: 'audio-manager', script: '/js/audio-manager.js', weight: 60 }, + { id: 'tts', script: '/js/tts-player.js', weight: 75 }, + + // UI and interaction modules + { id: 'text-buffer', script: '/js/text-buffer.js', weight: 50 }, + { id: 'ui-effects', script: '/js/ui-effects.js', weight: 50 }, // Add UI Effects module + { id: 'ui-input-handler', script: '/js/ui-input-handler.js', weight: 50 }, // Add UI Input Handler module + { id: 'ui-display-handler', script: '/js/ui-display-handler.js', weight: 60 }, // Add UI Display Handler module + { id: 'ui-controller', script: '/js/ui-controller.js', weight: 100 }, + { id: 'options-ui', script: '/js/options-ui.js', weight: 40 }, + { id: 'socket-client', script: '/js/socket-client.js', weight: 60 }, + + // Main game module - should be last to load + { id: 'game-loop', script: '/js/game-loop.js', weight: 25 } + ]; + + // Store module weights for progress calculation + modulesToLoad.forEach(module => { + moduleWeights[module.id] = module.weight; + }); + + // Create a module list entry for each module + modulesToLoad.forEach(module => { + createModuleListItem(module.id, getModuleNameFromId(module.id)); + }); + + // Load each module script + const loadPromises = modulesToLoad.map(module => loadScript(module.script)); + return Promise.all(loadPromises); + } + + /** + * Load a script dynamically + * @param {string} src - Script source URL + * @returns {Promise} - Resolves when script is loaded + */ + function loadScript(src) { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.type = 'module'; + script.src = src; + script.onload = () => resolve(); + script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); + document.head.appendChild(script); + }); + } + + /** + * Initialize all registered modules + */ + function initializeModules() { + const modules = moduleRegistry.getAllModules(); + + // Find the game loop module instance + gameLoopModule = moduleRegistry.getModule('game-loop'); + + // For each registered module, start initialization + Object.values(modules).forEach(async (module) => { + try { + // Create a progress callback for this module + const progressCallback = (percent, message) => { + handleModuleProgress({ + detail: { + moduleId: module.id, + progress: percent + } + }); + + if (message) { + handleModuleMessage({ + detail: { + moduleId: module.id, + message + } + }); + } + }; + + // Initialize the module with progress callback + await module.initializeInterface(progressCallback); + + } catch (error) { + console.error(`Error initializing module ${module.id}:`, error); + } + }); + } + + /** + * Get a human-readable module name from its ID + * @param {string} id - Module ID + * @returns {string} - User-friendly module name + */ + function getModuleNameFromId(id) { + // Convert kebab-case to Title Case + return id + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + /** + * Create the loading overlay + */ + function createLoadingOverlay() { + // Find existing elements or create minimal ones for progress reporting + loadingOverlay = document.querySelector('.loading-overlay'); + + if (!loadingOverlay) { + // If no overlay exists in the HTML, create a minimal one + loadingOverlay = document.createElement('div'); + loadingOverlay.className = 'loading-overlay'; + loadingOverlay.style.transition = 'opacity 0.5s ease-out'; + document.body.appendChild(loadingOverlay); + + const content = document.createElement('div'); + content.className = 'loading-content'; + + const title = document.createElement('h2'); + title.textContent = 'Loading Interface'; + content.appendChild(title); + + const progressBar = document.createElement('div'); + progressBar.className = 'loading-bar'; + + progressIndicator = document.createElement('div'); + progressIndicator.className = 'loading-progress'; + progressBar.appendChild(progressIndicator); + + progressText = document.createElement('div'); + progressText.className = 'loading-text'; + progressText.textContent = '0%'; + progressBar.appendChild(progressText); + + statusText = document.createElement('div'); + statusText.className = 'loading-status'; + statusText.textContent = 'Loading modules...'; + + modulesList = document.createElement('ul'); + modulesList.id = 'modules-list'; + + content.appendChild(progressBar); + content.appendChild(statusText); + content.appendChild(modulesList); + loadingOverlay.appendChild(content); + } else { + // If overlay exists, find the progress elements + progressIndicator = loadingOverlay.querySelector('.loading-progress'); + progressText = loadingOverlay.querySelector('.loading-text'); + statusText = loadingOverlay.querySelector('.loading-status'); + modulesList = loadingOverlay.querySelector('#modules-list'); + + // Ensure transition is set + loadingOverlay.style.transition = 'opacity 0.5s ease-out'; + } + } + + /** + * Create a module list item in the UI + * @param {string} id - Module ID + * @param {string} name - Module display name + */ + function createModuleListItem(id, name) { + if (!modulesList) return; + + // Check if we've already created this module item + if (createdModules.has(id)) return; + + // Mark this module as created + createdModules.add(id); + + const moduleItem = document.createElement('li'); + moduleItem.className = 'module-item'; + moduleItem.id = `module-${id}`; + moduleItem.innerHTML = ` + ${name} + Pending + `; + modulesList.appendChild(moduleItem); + } + + /** + * Handle module progress events + */ + function handleModuleProgress(event) { + const { moduleId, progress } = event.detail; + updateModuleProgress(moduleId, progress); + updateOverallProgress(); + } + + /** + * Handle module state change events + */ + function handleModuleStateChange(event) { + const { moduleId, state } = event.detail; + updateModuleState(moduleId, state); + updateOverallProgress(); + + // Check if all modules are finished after each state change + checkAllFinished(); + } + + /** + * Handle module status message events + */ + function handleModuleMessage(event) { + const { moduleId, message } = event.detail; + updateModuleStatusText(moduleId, message); + } + + /** + * Check if all modules are finished loading + */ + function checkAllFinished() { + const modules = moduleRegistry.getAllModules(); + const allFinished = Object.values(modules).every(module => { + const state = module.getState(); + return state === ModuleState.FINISHED || state === ModuleState.ERROR; + }); + + if (allFinished && !isLoadingComplete) { + finalizeLoading(); + } + } + + /** + * Finalize the loading process + */ + function finalizeLoading() { + console.log('Loading completed. Finalizing...'); + completeFinalization(); + } + + /** + * Complete the finalization process + */ + function completeFinalization() { + isLoadingComplete = true; + + // Call the start method on the game loop module directly + // Ensure the game loop module was found during initialization + if (gameLoopModule && typeof gameLoopModule.start === 'function') { + // Hide the overlay first, then start the game loop + hideOverlay(() => { + console.log("Loader: Overlay hidden, starting Game Loop."); + gameLoopModule.start(); + }); + } else { + console.error("Loader: Game Loop module not found or start method missing."); + // Hide overlay anyway, but log error + hideOverlay(); + } + } + + /** + * Hide the loading overlay with a fade out animation + * Then completely remove it from the DOM + * @param {Function} [callback] - Optional callback to execute after fade completes + */ + function hideOverlay(callback) { // Added callback parameter + if (!loadingOverlay) { + if (callback) callback(); // Call callback immediately if no overlay + return; + } + + // Set opacity to 0 to trigger the fade-out transition + loadingOverlay.style.opacity = '0'; + + // Use transition event listener to remove from DOM after fade completes + loadingOverlay.addEventListener('transitionend', function handler(e) { + // Only handle the opacity transition + if (e.propertyName === 'opacity') { + console.log('Module Loader: Removing overlay from DOM'); + + // Remove from DOM completely + if (loadingOverlay.parentNode) { + loadingOverlay.parentNode.removeChild(loadingOverlay); + } + + // Remove the event listener to prevent memory leaks + loadingOverlay.removeEventListener('transitionend', handler); + + // Set to null to allow garbage collection + loadingOverlay = null; + + // Execute the callback if provided + if (callback) callback(); + } + }); + + // Fallback in case the transition event doesn't fire + setTimeout(() => { + if (loadingOverlay && loadingOverlay.parentNode) { + console.log('Module Loader: Removing overlay from DOM (fallback)'); + loadingOverlay.parentNode.removeChild(loadingOverlay); + loadingOverlay = null; + } + // Execute callback in fallback as well + if (callback) callback(); + }, 1000); // Wait longer than the transition duration + } + + /** + * Update the state of a module + * @param {string} id - Module ID + * @param {string} state - New state + */ + function updateModuleState(id, state) { + // Update UI + const moduleItem = document.getElementById(`module-${id}`); + if (!moduleItem) return; + + const statusElement = moduleItem.querySelector('.module-status'); + if (!statusElement) return; + + // Remove all status classes + statusElement.classList.remove( + 'status-pending', + 'status-loading', + 'status-waiting', + 'status-initializing', + 'status-finished', + 'status-error' + ); + + // Add appropriate class and text + let statusText = ''; + switch (state) { + case ModuleState.PENDING: + statusElement.classList.add('status-pending'); + statusText = 'Pending'; + break; + case ModuleState.LOADING: + statusElement.classList.add('status-loading'); + statusText = 'Loading'; + break; + case ModuleState.WAITING: + statusElement.classList.add('status-waiting'); + statusText = 'Waiting'; + break; + case ModuleState.INITIALIZING: + statusElement.classList.add('status-initializing'); + statusText = 'Initializing'; + break; + case ModuleState.FINISHED: + statusElement.classList.add('status-finished'); + statusText = 'Finished'; + break; + case ModuleState.ERROR: + statusElement.classList.add('status-error'); + statusText = 'Error'; + break; + } + + statusElement.textContent = statusText; + } + + /** + * Update the progress of a module + * @param {string} id - Module ID + * @param {number} progress - Progress percentage (0-100) + */ + function updateModuleProgress(id, progress) { + // Module states are now managed by the module itself + + // Update any additional UI elements for module progress if needed + const moduleItem = document.getElementById(`module-${id}`); + if (moduleItem) { + // Update progress display if needed + } + } + + /** + * Update the status text of a module in the UI + * @param {string} id - Module ID + * @param {string} text - Status text to display + */ + function updateModuleStatusText(id, text) { + const moduleItem = document.getElementById(`module-${id}`); + if (!moduleItem) return; + + let statusDetailElement = moduleItem.querySelector('.module-status-detail'); + + if (!statusDetailElement) { + statusDetailElement = document.createElement('span'); + statusDetailElement.className = 'module-status-detail'; + moduleItem.appendChild(statusDetailElement); + } + + statusDetailElement.textContent = text; + } + + /** + * Update overall progress based on module weights and progress + */ + function updateOverallProgress() { + const modules = moduleRegistry.getAllModules(); + const moduleIds = Object.keys(modules); + + // Calculate total weight + const totalWeight = moduleIds.reduce((sum, id) => { + return sum + (moduleWeights[id] || 1); + }, 0); + + // Calculate weighted progress + let overallProgress = moduleIds.reduce((sum, id) => { + const module = modules[id]; + const weight = moduleWeights[id] || 1; + return sum + (module.progress * weight / totalWeight); + }, 0); + + overallProgress = Math.min(Math.round(overallProgress), 100); + + // Update progress bar + if (progressIndicator) { + progressIndicator.style.width = `${overallProgress}%`; + } + + if (progressText) { + progressText.textContent = `${overallProgress}%`; + } + + // Update status text based on progress + if (statusText) { + if (overallProgress >= 100 && !isLoadingComplete) { + statusText.textContent = 'Finalizing...'; + } else if (isLoadingComplete) { + statusText.textContent = 'Complete!'; + } + } + } + + // Public API + return { + init, + ModuleState + }; +})(); + +// Now that ModuleLoader is defined, add the event listener +document.addEventListener('DOMContentLoaded', () => { + // Start the loading process when the DOM is loaded + ModuleLoader.init(); +}); diff --git a/public/js/localization.js b/public/js/localization.js new file mode 100644 index 0000000..50850e0 --- /dev/null +++ b/public/js/localization.js @@ -0,0 +1,260 @@ +/** + * Localization Module + * Manages translations and locale settings for the application + */ +import { BaseModule } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; + +class LocalizationModule extends BaseModule { + constructor() { + super('localization', 'Localization'); + this.currentLocale = 'en-us'; // Default locale + this.translations = {}; + this.observers = new Set(); // Modules that need to be notified of locale changes + } + + /** + * Initialize the module + * @returns {Promise} - Resolves with success status + */ + async initialize() { + try { + // Load translations + this.loadTranslations(); + + // Set global locale for SmartyPants + window.locale = this.currentLocale; + + this.reportProgress(100, "Localization module ready"); + return true; + } catch (error) { + console.error("Error initializing localization module:", error); + return false; + } + } + + /** + * Load all translations + */ + loadTranslations() { + // Add English translations (default) + this.addTranslations('en-us', { + // UI elements + 'by': 'powered by Generative AI', + 'title': 'AI Interactive Fiction', + 'subtitle': 'An open-world text adventure', + 'speech': 'speech', + 'speed': 'speed', + 'restart': 'restart', + 'save': 'save', + 'load': 'load', + 'prompt': 'What do you want to do next?', + 'remark': '*click on page or press spacebar to fast forward text animation', + + // Tooltips + 'title_speech': 'Toggle text to speech', + 'title_speech_unavailable': 'Text-to-Speech not available', + 'title_restart': 'Restart story from beginning', + 'title_save': 'Save progress', + 'title_load': 'Reload from save point', + + // Confirm dialogs + 'confirm_restart': 'Are you sure you want to restart the game? All progress will be lost.' + }); + + // Add German translations + this.addTranslations('de', { + 'by': 'unterstützt durch KI', + 'title': 'KI Interaktive Fiktion', + 'subtitle': 'Ein Textabenteuer in offener Welt', + 'speech': 'Sprache', + 'speed': 'Tempo', + 'restart': 'Neustart', + 'save': 'Speichern', + 'load': 'Laden', + 'prompt': 'Was möchtest du als nächstes tun?', + 'remark': '*Klicke auf die Seite oder drücke die Leertaste, um die Textanimation zu beschleunigen', + + 'title_speech': 'Text-zu-Sprache umschalten', + 'title_speech_unavailable': 'Text-zu-Sprache nicht verfügbar', + 'title_restart': 'Geschichte von Anfang an neu starten', + 'title_save': 'Fortschritt speichern', + 'title_load': 'Von Speicherpunkt neu laden', + + 'confirm_restart': 'Bist du sicher, dass du das Spiel neu starten möchtest? Der gesamte Fortschritt geht verloren.' + }); + + // Add French translations + this.addTranslations('fr', { + 'by': 'propulsé par l\'IA', + 'title': 'Fiction Interactive IA', + 'subtitle': 'Une aventure textuelle en monde ouvert', + 'speech': 'parole', + 'speed': 'vitesse', + 'restart': 'recommencer', + 'save': 'sauver', + 'load': 'charger', + 'prompt': 'Que voulez-vous faire ensuite?', + 'remark': '*cliquez sur la page ou appuyez sur la barre d\'espace pour accélérer l\'animation du texte', + + 'title_speech': 'Activer/désactiver la synthèse vocale', + 'title_speech_unavailable': 'Synthèse vocale non disponible', + 'title_restart': 'Redémarrer l\'histoire depuis le début', + 'title_save': 'Sauvegarder la progression', + 'title_load': 'Recharger depuis le point de sauvegarde', + + 'confirm_restart': 'Êtes-vous sûr de vouloir redémarrer le jeu? Tous les progrès seront perdus.' + }); + } + + /** + * Add translations for a specific locale + * @param {string} locale - Locale code + * @param {Object} translations - Translation key-value pairs + */ + addTranslations(locale, translations) { + if (!this.translations[locale]) { + this.translations[locale] = {}; + } + + Object.assign(this.translations[locale], translations); + } + + /** + * Get translation for a key in current locale + * @param {string} key - Translation key + * @param {string} [defaultValue] - Default value if translation not found + * @returns {string} - Translated text or default value + */ + translate(key, defaultValue = null) { + const localeTranslations = this.translations[this.currentLocale]; + + if (localeTranslations && localeTranslations[key] !== undefined) { + return localeTranslations[key]; + } + + // Fall back to English if translation not found + if (this.currentLocale !== 'en-us' && this.translations['en-us'] && this.translations['en-us'][key]) { + return this.translations['en-us'][key]; + } + + // Return default value or key if no translation found + return defaultValue || key; + } + + /** + * Set the current locale + * @param {string} locale - Locale code + */ + setLocale(locale) { + if (this.translations[locale]) { + this.currentLocale = locale; + + // Update global locale for SmartyPants + window.locale = locale; + + // Notify observers of locale change + this.notifyObservers(); + + console.log(`Localization: Locale set to ${locale}`); + return true; + } + + console.warn(`Localization: Locale ${locale} not available`); + return false; + } + + /** + * Get the current locale + * @returns {string} - Current locale code + */ + getLocale() { + return this.currentLocale; + } + + /** + * Register a module to be notified of locale changes + * @param {Object} module - Module to register + * @param {Function} updateMethod - Method to call on locale change + */ + registerObserver(module, updateMethod) { + if (typeof updateMethod !== 'function') { + console.error('Localization: Update method must be a function'); + return; + } + + this.observers.add({ module, updateMethod }); + } + + /** + * Unregister an observer module + * @param {Object} module - Module to unregister + */ + unregisterObserver(module) { + this.observers.forEach(observer => { + if (observer.module === module) { + this.observers.delete(observer); + } + }); + } + + /** + * Notify all observer modules of locale change + */ + notifyObservers() { + this.observers.forEach(observer => { + try { + observer.updateMethod(this.currentLocale); + } catch (error) { + console.error(`Error notifying observer for locale change:`, error); + } + }); + } + + /** + * Get all available locales + * @returns {Array} - Array of locale codes + */ + getAvailableLocales() { + return Object.keys(this.translations); + } + + /** + * Get all translations for a specific locale + * @param {string} locale - Locale code + * @returns {Object} - Translations for the locale + */ + getTranslationsForLocale(locale) { + return this.translations[locale] || {}; + } + + /** + * Get the current locale's direction (ltr or rtl) + * @returns {string} - Text direction ('ltr' or 'rtl') + */ + getTextDirection() { + // List of RTL languages + const rtlLocales = ['ar', 'he', 'fa', 'ur']; + + // Check if current locale starts with any RTL language code + for (const rtl of rtlLocales) { + if (this.currentLocale.startsWith(rtl)) { + return 'rtl'; + } + } + + return 'ltr'; + } +} + +// Create the singleton instance +const Localization = new LocalizationModule(); + +// Register with the module registry +moduleRegistry.register(Localization); + +// Export the module +export { Localization }; + +// Keep a reference in window for loader system +window.Localization = Localization; diff --git a/public/js/module-registry.js b/public/js/module-registry.js new file mode 100644 index 0000000..2482283 --- /dev/null +++ b/public/js/module-registry.js @@ -0,0 +1,94 @@ +/** + * Module Registry + * Manages module registration and dependency tracking + */ +export class ModuleRegistry { + constructor() { + this.modules = {}; + this.readyPromises = {}; + } + + /** + * Register a module + * @param {BaseModule} module - Module to register + */ + register(module) { + if (!module || !module.id) { + console.error('Invalid module - must have an id property'); + return; + } + + this.modules[module.id] = module; + + // Create a promise that will resolve when this module is ready + this.readyPromises[module.id] = new Promise((resolve) => { + // Set up a state change listener for this module + document.addEventListener('module:stateChange', (event) => { + if (event.detail.moduleId === module.id && + (event.detail.state === 'FINISHED' || event.detail.state === 'ERROR')) { + resolve(event.detail.state === 'FINISHED'); + } + }); + + // Check if already in finished state + if (module.state === 'FINISHED') { + resolve(true); + } else if (module.state === 'ERROR') { + resolve(false); + } + }); + } + + /** + * Get a module by id + * @param {string} id - Module id + * @returns {BaseModule} - The module, or null if not found + */ + getModule(id) { + return this.modules[id] || null; + } + + /** + * Get all registered modules + * @returns {Object} - Map of modules + */ + getAllModules() { + return this.modules; + } + + /** + * Wait for a module to be ready (in FINISHED state) + * @param {string} id - Module id to wait for + * @param {number} timeout - Optional timeout in ms + * @returns {Promise} - Resolves when the module is ready + */ + waitForModule(id, timeout = null) { + if (!this.readyPromises[id]) { + return Promise.resolve(false); + } + + if (timeout) { + // Add timeout logic + return Promise.race([ + this.readyPromises[id], + new Promise(resolve => setTimeout(() => resolve(false), timeout)) + ]); + } + + return this.readyPromises[id]; + } + + /** + * Wait for multiple modules to be ready + * @param {Array} ids - Array of module ids to wait for + * @param {number} timeout - Optional timeout in ms + * @returns {Promise} - Resolves when all modules are ready + */ + waitForModules(ids, timeout = null) { + const promises = ids.map(id => this.waitForModule(id, timeout)); + return Promise.all(promises); + } +} + +// Create and export a singleton instance +export const moduleRegistry = new ModuleRegistry(); diff --git a/public/js/options-ui.js b/public/js/options-ui.js new file mode 100644 index 0000000..c054c0c --- /dev/null +++ b/public/js/options-ui.js @@ -0,0 +1,947 @@ +/** + * Options UI Module for AI Interactive Fiction + * Provides a user interface for adjusting game settings, TTS options, etc. + */ +import { BaseModule } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; + +class OptionsUIModule extends BaseModule { + /** + * Create new options UI + */ + constructor() { + super('options-ui', 'Options UI'); + this.persistenceManager = null; + this.ttsPlayer = null; + this.audioManager = null; + this.ttsFactory = null; + this.modal = null; + this.isOpen = false; + + // Configuration + this.config = { + modalClass: 'options-modal', + modalContentClass: 'options-content', + backdrop: true + }; + + // Bound event handlers for proper this context + this.handleTtsSystemChanged = this.handleTtsSystemChanged.bind(this); + } + + /** + * Initialize the module + * @returns {Promise} - Resolves with success status + */ + async initialize() { + try { + // Set up event listeners + window.addEventListener('tts-system-changed', this.handleTtsSystemChanged); + + // The option modal will be created on demand + this.reportProgress(100, "Options UI ready"); + return true; + } catch (error) { + console.error("Error initializing options UI:", error); + return false; + } + } + + /** + * Handle TTS system changes + * @param {CustomEvent} event - The event containing TTS system change details + */ + handleTtsSystemChanged(event) { + console.log("TTS system changed:", event.detail); + + if (this.isOpen) { + // Refresh the voices list if the options UI is currently open + this.populateVoices(); + } + } + + /** + * Wait for dependencies to be ready + * @returns {Promise} - Resolves when dependencies are ready + */ + async waitForDependencies() { + try { + // Wait for the persistence manager if available + this.persistenceManager = moduleRegistry.getModule('persistence-manager'); + this.ttsPlayer = moduleRegistry.getModule('tts'); + + // These dependencies are optional - UI will adapt if not available + this.audioManager = moduleRegistry.getModule('audio-manager'); + + return true; + } catch (error) { + console.error("Error waiting for options UI dependencies:", error); + return true; // Non-critical, can continue + } + } + + /** + * Create the options UI elements + */ + createModal() { + if (this.modal) return; + + // Create modal container + this.modal = document.createElement('div'); + this.modal.className = this.config.modalClass; + this.modal.style.display = 'none'; + + // Create backdrop if enabled + if (this.config.backdrop) { + this.backdrop = document.createElement('div'); + this.backdrop.className = 'modal-backdrop'; + this.backdrop.addEventListener('click', () => this.hide()); + this.modal.appendChild(this.backdrop); + } + + // Create content container + const content = document.createElement('div'); + content.className = this.config.modalContentClass; + + // Add header with title and close button + const header = document.createElement('div'); + header.className = 'options-header'; + + const title = document.createElement('h2'); + title.textContent = 'Options'; + header.appendChild(title); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'close-button'; + closeBtn.textContent = '×'; + closeBtn.setAttribute('aria-label', 'Close options'); + closeBtn.addEventListener('click', () => this.hide()); + header.appendChild(closeBtn); + + content.appendChild(header); + + // Create tabs + const tabContainer = document.createElement('div'); + tabContainer.className = 'tabs-container'; + + const tabs = document.createElement('div'); + tabs.className = 'tabs'; + + const tabGeneral = document.createElement('button'); + tabGeneral.className = 'tab active'; + tabGeneral.textContent = 'General'; + tabGeneral.dataset.tab = 'general'; + + const tabVoice = document.createElement('button'); + tabVoice.className = 'tab'; + tabVoice.textContent = 'Voice'; + tabVoice.dataset.tab = 'voice'; + + const tabAudio = document.createElement('button'); + tabAudio.className = 'tab'; + tabAudio.textContent = 'Audio'; + tabAudio.dataset.tab = 'audio'; + + const tabAccess = document.createElement('button'); + tabAccess.className = 'tab'; + tabAccess.textContent = 'Accessibility'; + tabAccess.dataset.tab = 'accessibility'; + + tabs.appendChild(tabGeneral); + tabs.appendChild(tabVoice); + tabs.appendChild(tabAudio); + tabs.appendChild(tabAccess); + + tabContainer.appendChild(tabs); + content.appendChild(tabContainer); + + // Create tab content sections + const tabContent = document.createElement('div'); + tabContent.className = 'tab-content'; + + // General tab content + const generalContent = document.createElement('div'); + generalContent.className = 'tab-pane active'; + generalContent.dataset.tab = 'general'; + + const animSpeedSection = document.createElement('div'); + animSpeedSection.className = 'option-section'; + + const animSpeedLabel = document.createElement('label'); + animSpeedLabel.textContent = 'Animation Speed'; + animSpeedLabel.htmlFor = 'option-anim-speed'; + + const animSpeedSlider = document.createElement('input'); + animSpeedSlider.type = 'range'; + animSpeedSlider.id = 'option-anim-speed'; + animSpeedSlider.min = '0'; + animSpeedSlider.max = '100'; + animSpeedSlider.value = '50'; // Will be updated from preferences + + const animSpeedValue = document.createElement('span'); + animSpeedValue.className = 'range-value'; + animSpeedValue.textContent = '50%'; + + animSpeedSlider.addEventListener('input', () => { + const val = animSpeedSlider.value; + animSpeedValue.textContent = `${val}%`; + + if (this.persistenceManager) { + this.persistenceManager.updatePreference('animation', 'speed', parseInt(val, 10)); + } + + // Update animation queue speed if available + const animQueue = moduleRegistry.getModule('animation-queue'); + if (animQueue) { + const speed = Math.pow(100.0 - val, 3) / 10000 * 10 + 0.01; + animQueue.setSpeed(speed); + } + }); + + animSpeedSection.appendChild(animSpeedLabel); + animSpeedSection.appendChild(animSpeedSlider); + animSpeedSection.appendChild(animSpeedValue); + generalContent.appendChild(animSpeedSection); + + // Voice tab content + const voiceContent = document.createElement('div'); + voiceContent.className = 'tab-pane'; + voiceContent.dataset.tab = 'voice'; + + const ttsSysSection = document.createElement('div'); + ttsSysSection.className = 'option-section'; + + const ttsSysLabel = document.createElement('label'); + ttsSysLabel.textContent = 'TTS System'; + ttsSysLabel.htmlFor = 'option-tts-system'; + + const ttsSysSelect = document.createElement('select'); + ttsSysSelect.id = 'option-tts-system'; + + // Will populate systems dynamically later + ttsSysSection.appendChild(ttsSysLabel); + ttsSysSection.appendChild(ttsSysSelect); + voiceContent.appendChild(ttsSysSection); + + // Voice selection section + const voiceSection = document.createElement('div'); + voiceSection.className = 'option-section'; + + const voiceLabel = document.createElement('label'); + voiceLabel.textContent = 'Voice'; + voiceLabel.htmlFor = 'option-voice'; + + const voiceSelect = document.createElement('select'); + voiceSelect.id = 'option-voice'; + + // Will populate voices dynamically later + voiceSection.appendChild(voiceLabel); + voiceSection.appendChild(voiceSelect); + voiceContent.appendChild(voiceSection); + + // Voice rate section + const rateSection = document.createElement('div'); + rateSection.className = 'option-section'; + + const rateLabel = document.createElement('label'); + rateLabel.textContent = 'Speech Rate'; + rateLabel.htmlFor = 'option-speech-rate'; + + const rateSlider = document.createElement('input'); + rateSlider.type = 'range'; + rateSlider.id = 'option-speech-rate'; + rateSlider.min = '50'; + rateSlider.max = '200'; + rateSlider.value = '100'; // Will be updated from preferences + + const rateValue = document.createElement('span'); + rateValue.className = 'range-value'; + rateValue.textContent = '1.0x'; + + rateSlider.addEventListener('input', () => { + const val = rateSlider.value; + const rate = val / 100; + rateValue.textContent = `${rate.toFixed(1)}x`; + + if (this.ttsPlayer) { + this.ttsPlayer.setSpeed(rate); + } + + if (this.persistenceManager) { + this.persistenceManager.updatePreference('tts', 'rate', rate); + } + }); + + rateSection.appendChild(rateLabel); + rateSection.appendChild(rateSlider); + rateSection.appendChild(rateValue); + voiceContent.appendChild(rateSection); + + // Audio tab content + const audioContent = document.createElement('div'); + audioContent.className = 'tab-pane'; + audioContent.dataset.tab = 'audio'; + + // Master volume section + const masterVolSection = document.createElement('div'); + masterVolSection.className = 'option-section'; + + const masterVolLabel = document.createElement('label'); + masterVolLabel.textContent = 'Master Volume'; + masterVolLabel.htmlFor = 'option-master-vol'; + + const masterVolSlider = document.createElement('input'); + masterVolSlider.type = 'range'; + masterVolSlider.id = 'option-master-vol'; + masterVolSlider.min = '0'; + masterVolSlider.max = '100'; + masterVolSlider.value = '100'; // Will be updated from preferences + + const masterVolValue = document.createElement('span'); + masterVolValue.className = 'range-value'; + masterVolValue.textContent = '100%'; + + masterVolSlider.addEventListener('input', () => { + const val = masterVolSlider.value; + masterVolValue.textContent = `${val}%`; + + if (this.audioManager) { + this.audioManager.setMasterVolume(val / 100); + } + + if (this.persistenceManager) { + this.persistenceManager.updatePreference('audio', 'masterVolume', val / 100); + } + }); + + masterVolSection.appendChild(masterVolLabel); + masterVolSection.appendChild(masterVolSlider); + masterVolSection.appendChild(masterVolValue); + audioContent.appendChild(masterVolSection); + + // TTS volume section + const ttsVolSection = document.createElement('div'); + ttsVolSection.className = 'option-section'; + + const ttsVolLabel = document.createElement('label'); + ttsVolLabel.textContent = 'Speech Volume'; + ttsVolLabel.htmlFor = 'option-tts-vol'; + + const ttsVolSlider = document.createElement('input'); + ttsVolSlider.type = 'range'; + ttsVolSlider.id = 'option-tts-vol'; + ttsVolSlider.min = '0'; + ttsVolSlider.max = '100'; + ttsVolSlider.value = '100'; // Will be updated from preferences + + const ttsVolValue = document.createElement('span'); + ttsVolValue.className = 'range-value'; + ttsVolValue.textContent = '100%'; + + ttsVolSlider.addEventListener('input', () => { + const val = ttsVolSlider.value; + ttsVolValue.textContent = `${val}%`; + + if (this.ttsPlayer) { + this.ttsPlayer.setVolume(val / 100); + } + + if (this.persistenceManager) { + this.persistenceManager.updatePreference('tts', 'volume', val / 100); + } + }); + + ttsVolSection.appendChild(ttsVolLabel); + ttsVolSection.appendChild(ttsVolSlider); + ttsVolSection.appendChild(ttsVolValue); + audioContent.appendChild(ttsVolSection); + + // Music volume section (for future use) + const musicVolSection = document.createElement('div'); + musicVolSection.className = 'option-section'; + + const musicVolLabel = document.createElement('label'); + musicVolLabel.textContent = 'Music Volume'; + musicVolLabel.htmlFor = 'option-music-vol'; + + const musicVolSlider = document.createElement('input'); + musicVolSlider.type = 'range'; + musicVolSlider.id = 'option-music-vol'; + musicVolSlider.min = '0'; + musicVolSlider.max = '100'; + musicVolSlider.value = '70'; // Will be updated from preferences + + const musicVolValue = document.createElement('span'); + musicVolValue.className = 'range-value'; + musicVolValue.textContent = '70%'; + + musicVolSlider.addEventListener('input', () => { + const val = musicVolSlider.value; + musicVolValue.textContent = `${val}%`; + + if (this.audioManager) { + this.audioManager.setMusicVolume(val / 100); + } + + if (this.persistenceManager) { + this.persistenceManager.updatePreference('audio', 'musicVolume', val / 100); + } + }); + + musicVolSection.appendChild(musicVolLabel); + musicVolSection.appendChild(musicVolSlider); + musicVolSection.appendChild(musicVolValue); + audioContent.appendChild(musicVolSection); + + // SFX volume section (for future use) + const sfxVolSection = document.createElement('div'); + sfxVolSection.className = 'option-section'; + + const sfxVolLabel = document.createElement('label'); + sfxVolLabel.textContent = 'Effects Volume'; + sfxVolLabel.htmlFor = 'option-sfx-vol'; + + const sfxVolSlider = document.createElement('input'); + sfxVolSlider.type = 'range'; + sfxVolSlider.id = 'option-sfx-vol'; + sfxVolSlider.min = '0'; + sfxVolSlider.max = '100'; + sfxVolSlider.value = '100'; // Will be updated from preferences + + const sfxVolValue = document.createElement('span'); + sfxVolValue.className = 'range-value'; + sfxVolValue.textContent = '100%'; + + sfxVolSlider.addEventListener('input', () => { + const val = sfxVolSlider.value; + sfxVolValue.textContent = `${val}%`; + + if (this.audioManager) { + this.audioManager.setSfxVolume(val / 100); + } + + if (this.persistenceManager) { + this.persistenceManager.updatePreference('audio', 'sfxVolume', val / 100); + } + }); + + sfxVolSection.appendChild(sfxVolLabel); + sfxVolSection.appendChild(sfxVolSlider); + sfxVolSection.appendChild(sfxVolValue); + audioContent.appendChild(sfxVolSection); + + // Accessibility tab content + const accessContent = document.createElement('div'); + accessContent.className = 'tab-pane'; + accessContent.dataset.tab = 'accessibility'; + + // High contrast toggle + const contrastSection = document.createElement('div'); + contrastSection.className = 'option-section checkbox-section'; + + const contrastCheckbox = document.createElement('input'); + contrastCheckbox.type = 'checkbox'; + contrastCheckbox.id = 'option-high-contrast'; + + const contrastLabel = document.createElement('label'); + contrastLabel.textContent = 'High Contrast Mode'; + contrastLabel.htmlFor = 'option-high-contrast'; + + contrastCheckbox.addEventListener('change', () => { + const isEnabled = contrastCheckbox.checked; + + // Apply high contrast class to body + if (isEnabled) { + document.body.classList.add('high-contrast'); + } else { + document.body.classList.remove('high-contrast'); + } + + if (this.persistenceManager) { + this.persistenceManager.updatePreference('accessibility', 'highContrast', isEnabled); + } + }); + + contrastSection.appendChild(contrastCheckbox); + contrastSection.appendChild(contrastLabel); + accessContent.appendChild(contrastSection); + + // Larger text toggle + const largerTextSection = document.createElement('div'); + largerTextSection.className = 'option-section checkbox-section'; + + const largerTextCheckbox = document.createElement('input'); + largerTextCheckbox.type = 'checkbox'; + largerTextCheckbox.id = 'option-larger-text'; + + const largerTextLabel = document.createElement('label'); + largerTextLabel.textContent = 'Larger Text'; + largerTextLabel.htmlFor = 'option-larger-text'; + + largerTextCheckbox.addEventListener('change', () => { + const isEnabled = largerTextCheckbox.checked; + + // Apply larger text class to body + if (isEnabled) { + document.body.classList.add('larger-text'); + } else { + document.body.classList.remove('larger-text'); + } + + if (this.persistenceManager) { + this.persistenceManager.updatePreference('accessibility', 'largerText', isEnabled); + } + }); + + largerTextSection.appendChild(largerTextCheckbox); + largerTextSection.appendChild(largerTextLabel); + accessContent.appendChild(largerTextSection); + + // Add tab content to container + tabContent.appendChild(generalContent); + tabContent.appendChild(voiceContent); + tabContent.appendChild(audioContent); + tabContent.appendChild(accessContent); + + content.appendChild(tabContent); + + // Add buttons at the bottom + const buttons = document.createElement('div'); + buttons.className = 'options-buttons'; + + const resetButton = document.createElement('button'); + resetButton.textContent = 'Reset to Defaults'; + resetButton.className = 'reset-button'; + resetButton.addEventListener('click', () => this.resetToDefaults()); + + const saveButton = document.createElement('button'); + saveButton.textContent = 'Save & Close'; + saveButton.className = 'save-button'; + saveButton.addEventListener('click', () => this.saveAndClose()); + + buttons.appendChild(resetButton); + buttons.appendChild(saveButton); + content.appendChild(buttons); + + // Set up tab switching + tabs.addEventListener('click', (e) => { + if (e.target.classList.contains('tab')) { + // Deactivate all tabs and tab panes + Array.from(tabs.querySelectorAll('.tab')).forEach(tab => { + tab.classList.remove('active'); + }); + Array.from(tabContent.querySelectorAll('.tab-pane')).forEach(pane => { + pane.classList.remove('active'); + }); + + // Activate clicked tab and corresponding pane + e.target.classList.add('active'); + const tabName = e.target.dataset.tab; + const pane = tabContent.querySelector(`.tab-pane[data-tab="${tabName}"]`); + if (pane) { + pane.classList.add('active'); + } + + // If switching to voice tab, ensure voices are updated + if (tabName === 'voice') { + this.populateTtsSystems(); + this.populateVoices(); + } + } + }); + + this.modal.appendChild(content); + document.body.appendChild(this.modal); + + // Store references to UI elements for later use + this.elements = { + animSpeed: animSpeedSlider, + animSpeedValue: animSpeedValue, + ttsSystem: ttsSysSelect, + voiceSelect: voiceSelect, + speechRate: rateSlider, + speechRateValue: rateValue, + masterVolume: masterVolSlider, + masterVolumeValue: masterVolValue, + ttsVolume: ttsVolSlider, + ttsVolumeValue: ttsVolValue, + musicVolume: musicVolSlider, + musicVolumeValue: musicVolValue, + sfxVolume: sfxVolSlider, + sfxVolumeValue: sfxVolValue, + highContrast: contrastCheckbox, + largerText: largerTextCheckbox + }; + } + + /** + * Show the options UI + */ + show() { + if (!this.modal) { + this.createModal(); + } + + // Load current preferences + this.loadPreferences(); + + // Populate TTS systems and voices + this.populateTtsSystems(); + this.populateVoices(); + + // Show the modal + this.modal.style.display = 'flex'; + this.isOpen = true; + } + + /** + * Hide the options UI + */ + hide() { + if (this.modal) { + this.modal.style.display = 'none'; + this.isOpen = false; + } + } + + /** + * Toggle the options UI visibility + */ + toggle() { + if (this.isOpen) { + this.hide(); + } else { + this.show(); + } + } + + /** + * Load current preferences into UI + */ + loadPreferences() { + if (!this.persistenceManager || !this.elements) return; + + const prefs = this.persistenceManager.getAllPreferences(); + + // Animation speed + const animSpeed = this.persistenceManager.getPreference('animation', 'speed', 50); + this.elements.animSpeed.value = animSpeed; + this.elements.animSpeedValue.textContent = `${animSpeed}%`; + + // TTS settings + const ttsEnabled = this.persistenceManager.getPreference('tts', 'enabled', false); + const ttsProvider = this.persistenceManager.getPreference('tts', 'provider', 'browser'); + const ttsVoice = this.persistenceManager.getPreference('tts', 'voice', ''); + const ttsVolume = this.persistenceManager.getPreference('tts', 'volume', 1.0); + const ttsRate = this.persistenceManager.getPreference('tts', 'rate', 1.0); + + // TTS rate slider + this.elements.speechRate.value = Math.round(ttsRate * 100); + this.elements.speechRateValue.textContent = `${ttsRate.toFixed(1)}x`; + + // TTS volume slider + this.elements.ttsVolume.value = Math.round(ttsVolume * 100); + this.elements.ttsVolumeValue.textContent = `${Math.round(ttsVolume * 100)}%`; + + // Audio volumes + const masterVolume = this.persistenceManager.getPreference('audio', 'masterVolume', 1.0); + const musicVolume = this.persistenceManager.getPreference('audio', 'musicVolume', 0.7); + const sfxVolume = this.persistenceManager.getPreference('audio', 'sfxVolume', 1.0); + + this.elements.masterVolume.value = Math.round(masterVolume * 100); + this.elements.masterVolumeValue.textContent = `${Math.round(masterVolume * 100)}%`; + + this.elements.musicVolume.value = Math.round(musicVolume * 100); + this.elements.musicVolumeValue.textContent = `${Math.round(musicVolume * 100)}%`; + + this.elements.sfxVolume.value = Math.round(sfxVolume * 100); + this.elements.sfxVolumeValue.textContent = `${Math.round(sfxVolume * 100)}%`; + + // Accessibility settings + const highContrast = this.persistenceManager.getPreference('accessibility', 'highContrast', false); + const largerText = this.persistenceManager.getPreference('accessibility', 'largerText', false); + + this.elements.highContrast.checked = highContrast; + this.elements.largerText.checked = largerText; + } + + /** + * Populate TTS systems dropdown + */ + populateTtsSystems() { + if (!this.ttsPlayer || !this.elements) return; + + const systems = this.ttsPlayer.getAvailableSystems(); + const select = this.elements.ttsSystem; + + // Clear existing options and listeners + select.innerHTML = ''; + const newSelect = select.cloneNode(false); + select.parentNode.replaceChild(newSelect, select); + this.elements.ttsSystem = newSelect; + select = newSelect; + + // Get current TTS info + const currentInfo = this.ttsPlayer.getTTSInfo(); + const currentId = currentInfo.type || ''; + + // Create an option for each available system + systems.forEach(id => { + const option = document.createElement('option'); + option.value = id; + + switch (id) { + case 'browser': + option.textContent = 'Browser Built-in TTS'; + break; + case 'kokoro': + option.textContent = 'Kokoro Neural TTS'; + break; + case 'api': + option.textContent = 'API-based TTS'; + break; + default: + option.textContent = id.charAt(0).toUpperCase() + id.slice(1); + } + + if (id === currentId) { + option.selected = true; + } + + select.appendChild(option); + }); + + // Add change listener + select.addEventListener('change', () => { + const selectedSystem = select.value; + if (this.ttsPlayer) { + this.ttsPlayer.switchTTS(selectedSystem); + + // Update persistence + if (this.persistenceManager) { + this.persistenceManager.updatePreference('tts', 'provider', selectedSystem); + } + } + }); + } + + /** + * Populate voices dropdown for current TTS system + */ + async populateVoices() { + if (!this.ttsPlayer || !this.elements || !this.ttsPlayer.getVoices) return; + + try { + const voices = await this.ttsPlayer.getVoices(); + const select = this.elements.voiceSelect; + + // Clear existing options and listeners + select.innerHTML = ''; + const newSelect = select.cloneNode(false); + select.parentNode.replaceChild(newSelect, select); + this.elements.voiceSelect = newSelect; + select = newSelect; + + if (!voices || voices.length === 0) { + const option = document.createElement('option'); + option.value = ''; + option.textContent = 'No voices available'; + select.appendChild(option); + select.disabled = true; + return; + } + + select.disabled = false; + + // Get current preference + let currentVoice = ''; + if (this.persistenceManager) { + currentVoice = this.persistenceManager.getPreference('tts', 'voice', ''); + } + + // Add voices to dropdown + voices.forEach(voice => { + const option = document.createElement('option'); + option.value = voice.id || voice.name; + option.textContent = voice.name; + + if (voice.id === currentVoice || voice.name === currentVoice) { + option.selected = true; + } + + select.appendChild(option); + }); + + // Add change listener + select.addEventListener('change', () => { + const selectedVoice = select.value; + + // Update TTS + if (this.ttsPlayer) { + this.ttsPlayer.setVoice(selectedVoice); + } + + // Update persistence + if (this.persistenceManager) { + this.persistenceManager.updatePreference('tts', 'voice', selectedVoice); + } + }); + + console.log(`Voices populated for current TTS system. Selected: ${select.value}`); + + } catch (error) { + console.error("Error populating voices:", error); + + const select = this.elements.voiceSelect; + select.innerHTML = ''; + + const option = document.createElement('option'); + option.value = ''; + option.textContent = 'Error loading voices'; + select.appendChild(option); + select.disabled = true; + } + } + + /** + * Reset all options to defaults + */ + resetToDefaults() { + if (!this.persistenceManager) return; + + const confirmed = confirm('Reset all options to default values?'); + if (confirmed) { + // Reset preferences + this.persistenceManager.resetPreferences(); + + // Update UI + this.loadPreferences(); + + // Apply changes + this.applySettings(); + + // Refresh voice list + this.populateVoices(); + } + } + + /** + * Save settings and close modal + */ + saveAndClose() { + if (this.persistenceManager && this.elements) { + // Save preferences - already saved as they change + + // Apply settings + this.applySettings(); + } + + this.hide(); + } + + /** + * Apply current settings to the app + */ + applySettings() { + if (!this.persistenceManager) return; + + // Apply animation speed + const animSpeed = this.persistenceManager.getPreference('animation', 'speed', 50); + const animQueue = moduleRegistry.getModule('animation-queue'); + if (animQueue) { + const speed = Math.pow(100.0 - animSpeed, 3) / 10000 * 10 + 0.01; + animQueue.setSpeed(speed); + } + + // Apply TTS settings + const ttsEnabled = this.persistenceManager.getPreference('tts', 'enabled', false); + const ttsProvider = this.persistenceManager.getPreference('tts', 'provider', 'browser'); + const ttsVoice = this.persistenceManager.getPreference('tts', 'voice', ''); + const ttsVolume = this.persistenceManager.getPreference('tts', 'volume', 1.0); + const ttsRate = this.persistenceManager.getPreference('tts', 'rate', 1.0); + + if (this.ttsPlayer) { + // Set TTS system + if (ttsProvider) { + this.ttsPlayer.switchTTS(ttsProvider); + } + + // Apply voice options + this.ttsPlayer.setVoiceOptions({ + voice: ttsVoice, + volume: ttsVolume, + rate: ttsRate + }); + } + + // Apply audio volume settings + const masterVolume = this.persistenceManager.getPreference('audio', 'masterVolume', 1.0); + const musicVolume = this.persistenceManager.getPreference('audio', 'musicVolume', 0.7); + const sfxVolume = this.persistenceManager.getPreference('audio', 'sfxVolume', 1.0); + + if (this.audioManager) { + this.audioManager.setMasterVolume(masterVolume); + this.audioManager.setMusicVolume(musicVolume); + this.audioManager.setSfxVolume(sfxVolume); + } + + // Apply accessibility settings + const highContrast = this.persistenceManager.getPreference('accessibility', 'highContrast', false); + const largerText = this.persistenceManager.getPreference('accessibility', 'largerText', false); + + if (highContrast) { + document.body.classList.add('high-contrast'); + } else { + document.body.classList.remove('high-contrast'); + } + + if (largerText) { + document.body.classList.add('larger-text'); + } else { + document.body.classList.remove('larger-text'); + } + } + + /** + * Set the TTS factory reference + * @param {Object} factory - The TTS factory instance + */ + setTtsFactory(factory) { + this.ttsFactory = factory; + } + + /** + * Update available TTS systems info + * @param {Object} systemsInfo - Information about available TTS systems + */ + updateAvailableSystems(systemsInfo) { + // Will repopulate next time UI is opened + console.log("TTS systems info updated:", systemsInfo); + + // If the options UI is currently open, update it + if (this.isOpen) { + this.populateTtsSystems(); + this.populateVoices(); + } + } + + /** + * Clean up when module is disposed + */ + dispose() { + // Remove event listeners + window.removeEventListener('tts-system-changed', this.handleTtsSystemChanged); + } +} + +// Create the singleton instance +const OptionsUI = new OptionsUIModule(); + +// Register with the module registry +moduleRegistry.register(OptionsUI); + +// Export the module +export { OptionsUI }; + +// Keep a reference in window for loader system +window.OptionsUI = OptionsUI; \ No newline at end of file diff --git a/public/js/paragraph-layout.js b/public/js/paragraph-layout.js index bc2d551..95bad82 100644 --- a/public/js/paragraph-layout.js +++ b/public/js/paragraph-layout.js @@ -2,32 +2,90 @@ * ParagraphLayout Module * Interfaces with the Knuth-Plass line breaking algorithm to calculate optimal line breaks. */ -export class ParagraphLayout { +import { BaseModule } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; + +class ParagraphLayoutModule extends BaseModule { /** * Create a new ParagraphLayout - * @param {Function} kapAlgorithm - The Knuth and Plass algorithm function - * @param {Function} measureTextFunc - Function to measure text width */ - constructor(kapAlgorithm, measureTextFunc) { - this.kapAlgorithm = kapAlgorithm; - this.measureText = measureTextFunc; + constructor() { + super('paragraph-layout', 'Paragraph Layout'); + this.kapAlgorithm = null; + this.measureText = null; } - + + /** + * Load module dependencies + * @returns {Promise} - Resolves when dependencies are loaded + */ + async loadDependencies() { + try { + // First load linebreak.js if needed + if (!window.linebreak) { + await this.loadScript('/js/linebreak.js'); + this.reportProgress(40, "Linebreak algorithm loaded"); + } + + // Then load knuth-and-plass.js if needed + if (!window.kap) { + await this.loadScript('/js/knuth-and-plass.js'); + this.reportProgress(60, "KAP algorithm loaded"); + } + + this.kapAlgorithm = window.kap; + + return true; + } catch (error) { + console.error("Error loading paragraph layout dependencies:", error); + return false; + } + } + + /** + * Load a script dynamically + * @param {string} src - Script source URL + * @returns {Promise} - Resolves when script is loaded + */ + loadScript(src) { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.onload = () => resolve(); + script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); + document.head.appendChild(script); + }); + } + + /** + * Initialize the module + * @returns {Promise} - Resolves with success status + */ + async initialize() { + try { + // The measureText function will be provided by the game controller later + this.reportProgress(100, "Paragraph layout initialized"); + return true; + } catch (error) { + console.error("Error initializing paragraph layout:", error); + return false; + } + } + /** * Calculate layout for a paragraph * @param {string} processedText - The pre-processed text (with SmartyPants and hyphenation) * @param {Array} measures - Array of line width measurements - * @param {boolean} debug - Whether to enable debug output + * @param {boolean} hyphenate - Whether to enable hyphenation * @param {Function} [measureFunc] - Optional specific measurement function for this call * @returns {Object} Layout data with nodes and breaks */ - calculateLayout(processedText, measures, hyphenate = false, measureFunc = null) { + calculateLayout(processedText, measures, hyphenate = true, measureFunc = null) { const measure = measureFunc || this.measureText; // Use provided func or fallback to instance default if (typeof measure !== 'function') { - console.error("ParagraphLayout: Invalid measure function provided or stored."); - // Return a dummy layout or throw an error? - return { nodes: [], breaks: [] }; // Return empty layout + throw new Error('No text measurement function available'); } + return this.kapAlgorithm(processedText, measure, measures, hyphenate); } @@ -47,3 +105,15 @@ export class ParagraphLayout { this.kapAlgorithm = kapFunc; } } + +// Create the singleton instance +const ParagraphLayout = new ParagraphLayoutModule(); + +// Register with the module registry +moduleRegistry.register(ParagraphLayout); + +// Export the module +export { ParagraphLayout }; + +// Keep a reference in window for loader system +window.ParagraphLayout = ParagraphLayout; diff --git a/public/js/persistence-manager.js b/public/js/persistence-manager.js index 09edbb0..37537b4 100644 --- a/public/js/persistence-manager.js +++ b/public/js/persistence-manager.js @@ -1,123 +1,364 @@ /** - * PersistenceManager Module - * Handles saving and loading the game state. + * Persistence Manager Module + * Handles saving and loading game state and user preferences */ -export class PersistenceManager { - /** - * Create a new PersistenceManager - * @param {Object} config - Configuration options - * @param {Storage} config.storage - The storage backend (e.g., localStorage) - * @param {string} config.saveStateKey - Key for saving the state - * @param {string} config.saveHistoryKey - Key for saving the history - */ - constructor(config = {}) { - this.storage = config.storage || window.localStorage; - this.saveStateKey = config.saveStateKey || 'save-state'; - this.saveHistoryKey = config.saveHistoryKey || 'save-history'; - } +import { BaseModule } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; +class PersistenceManagerModule extends BaseModule { /** - * Save the current state - * @param {Object} stateObject - The state object to save - * @param {string} stateObject.inkJson - The serialized Ink state - * @param {Array} stateObject.history - Array of HTML strings representing the story history - * @returns {boolean} Whether the save was successful + * Create a new persistence manager */ - saveState(stateObject) { + constructor() { + super('persistence-manager', 'Persistence Manager'); + this.storage = window.localStorage; + this.stateKey = 'ai_fiction_state'; + this.prefsKey = 'ai_fiction_prefs'; + + // Default preferences + this.defaultPreferences = { + tts: { + enabled: false, + provider: 'browser', // 'browser', 'kokoro', 'elevenlabs' + voice: '', + volume: 1.0 + }, + audio: { + masterVolume: 1.0, + musicVolume: 0.7, + sfxVolume: 1.0 + }, + animation: { + speed: 50, // 0-100 scale + fastForwardKey: ' ' // Space key + }, + accessibility: { + highContrast: false, + largerText: false + } + }; + + // Current preferences (will be loaded from storage) + this.preferences = { ...this.defaultPreferences }; + } + + /** + * Initialize the module + * @returns {Promise} - Resolves with success status + */ + async initialize() { try { - if (stateObject.inkJson) { - this.storage.setItem(this.saveStateKey, stateObject.inkJson); - } + // Test storage availability + this.storage = this.getStorageObject(); - if (stateObject.history) { - this.storage.setItem(this.saveHistoryKey, JSON.stringify(stateObject.history)); - } + // Load preferences automatically + this.loadPreferences(); + this.reportProgress(100, "Persistence manager ready"); return true; } catch (error) { - console.error('Error saving state:', error); + console.error("Error initializing persistence manager:", error); + // Continue without persistence rather than failing + return true; + } + } + + /** + * Get the appropriate storage object, testing availability + * @returns {Storage} - The storage object to use + */ + getStorageObject() { + try { + // Test if localStorage is available + if (window.localStorage) { + const testKey = '__storage_test__'; + window.localStorage.setItem(testKey, testKey); + window.localStorage.removeItem(testKey); + return window.localStorage; + } + } catch (e) { + console.warn('localStorage not available, using memory storage'); + // Create a memory-based storage fallback + return this.createMemoryStorage(); + } + + console.warn('localStorage not available, using memory storage'); + return this.createMemoryStorage(); + } + + /** + * Create a memory-based storage fallback + * @returns {Object} - A storage-like object + */ + createMemoryStorage() { + const memoryStore = {}; + + return { + getItem: (key) => memoryStore[key] || null, + setItem: (key, value) => { + memoryStore[key] = String(value); + }, + removeItem: (key) => { + delete memoryStore[key]; + }, + clear: () => { + Object.keys(memoryStore).forEach(key => { + delete memoryStore[key]; + }); + } + }; + } + + /** + * Save the current game state + * @param {Object} state - The game state to save + */ + saveState(state) { + if (!this.storage) { + console.warn('No storage available, game state not saved.'); + return false; + } + + try { + const stateString = JSON.stringify(state); + this.storage.setItem(this.stateKey, stateString); + console.log('Game state saved successfully.'); + return true; + } catch (error) { + console.error('Error saving game state:', error); return false; } } - + /** - * Load the saved state - * @returns {Object|null} The loaded state object or null if no save exists + * Load the saved game state + * @returns {Object|null} The loaded state or null if no state exists */ loadState() { - try { - const inkJson = this.storage.getItem(this.saveStateKey); - const historyJson = this.storage.getItem(this.saveHistoryKey); - - if (!inkJson && !historyJson) { - return null; - } - - const result = {}; - - if (inkJson) { - result.inkJson = inkJson; - } - - if (historyJson) { - result.history = JSON.parse(historyJson); - } - - return result; - } catch (error) { - console.error('Error loading state:', error); - return null; - } - } - - /** - * Check if a saved state exists - * @returns {boolean} Whether a saved state exists - */ - hasSavedState() { - return this.storage.getItem(this.saveStateKey) !== null; - } - - /** - * Delete the saved state - * @returns {boolean} Whether the deletion was successful - */ - deleteSavedState() { - try { - this.storage.removeItem(this.saveStateKey); - this.storage.removeItem(this.saveHistoryKey); - return true; - } catch (error) { - console.error('Error deleting saved state:', error); - return false; - } - } - - /** - * Export the saved state as a JSON string - * @returns {string|null} The exported state as a JSON string or null if no save exists - */ - exportState() { - const state = this.loadState(); - if (!state) { + if (!this.storage) { + console.warn('No storage available, cannot load game state.'); return null; } - return JSON.stringify(state); - } - - /** - * Import a state from a JSON string - * @param {string} jsonString - The JSON string to import - * @returns {boolean} Whether the import was successful - */ - importState(jsonString) { try { - const state = JSON.parse(jsonString); - return this.saveState(state); + const stateString = this.storage.getItem(this.stateKey); + if (!stateString) { + console.info('No saved game state found.'); + return null; + } + + const state = JSON.parse(stateString); + console.log('Game state loaded successfully.'); + return state; } catch (error) { - console.error('Error importing state:', error); + console.error('Error loading game state:', error); + return null; + } + } + + /** + * Check if a saved game state exists + * @returns {boolean} Whether a saved state exists + */ + hasSavedState() { + if (!this.storage) return false; + return !!this.storage.getItem(this.stateKey); + } + + /** + * Delete the saved game state + * @returns {boolean} Whether the state was successfully deleted + */ + clearState() { + if (!this.storage) return false; + try { + this.storage.removeItem(this.stateKey); + console.log('Game state cleared.'); + return true; + } catch (error) { + console.error('Error clearing game state:', error); return false; } } + + /** + * Save user preferences + * @param {Object} [preferences] - Preferences to save (defaults to current preferences) + * @returns {boolean} Whether preferences were successfully saved + */ + savePreferences(preferences = null) { + if (!this.storage) { + console.warn('No storage available, preferences not saved.'); + return false; + } + + // Use provided preferences or current preferences + const prefsToSave = preferences || this.preferences; + + try { + const prefsString = JSON.stringify(prefsToSave); + this.storage.setItem(this.prefsKey, prefsString); + console.log('Preferences saved successfully.'); + + // Update current preferences + if (preferences) { + this.preferences = { ...this.preferences, ...preferences }; + } + + return true; + } catch (error) { + console.error('Error saving preferences:', error); + return false; + } + } + + /** + * Load user preferences + * @returns {Object} The loaded preferences or default preferences if none exist + */ + loadPreferences() { + if (!this.storage) { + console.warn('No storage available, using default preferences.'); + return { ...this.defaultPreferences }; + } + + try { + const prefsString = this.storage.getItem(this.prefsKey); + if (!prefsString) { + console.info('No saved preferences found, using defaults.'); + this.preferences = { ...this.defaultPreferences }; + return this.preferences; + } + + const loadedPrefs = JSON.parse(prefsString); + + // Merge with default preferences to ensure all fields exist + this.preferences = this.mergeWithDefaults(loadedPrefs, this.defaultPreferences); + + console.log('Preferences loaded successfully.'); + return this.preferences; + } catch (error) { + console.error('Error loading preferences:', error); + this.preferences = { ...this.defaultPreferences }; + return this.preferences; + } + } + + /** + * Merge loaded preferences with default values to ensure all fields exist + * @param {Object} loaded - The loaded preferences + * @param {Object} defaults - The default preferences + * @returns {Object} Merged preferences + * @private + */ + mergeWithDefaults(loaded, defaults) { + const result = {}; + + // Start with defaults + for (const key in defaults) { + if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) { + // Recurse for nested objects + if (loaded && loaded[key]) { + result[key] = this.mergeWithDefaults(loaded[key], defaults[key]); + } else { + result[key] = { ...defaults[key] }; + } + } else { + // Use loaded value if available, otherwise default + result[key] = (loaded && loaded[key] !== undefined) ? loaded[key] : defaults[key]; + } + } + + return result; + } + + /** + * Update specific preferences + * @param {string} category - The preference category (e.g., 'tts', 'audio') + * @param {string} setting - The specific setting name + * @param {any} value - The new value + * @param {boolean} [saveImmediately=true] - Whether to save immediately + */ + updatePreference(category, setting, value, saveImmediately = true) { + // Ensure the category exists + if (!this.preferences[category]) { + console.warn(`Preference category '${category}' doesn't exist.`); + return false; + } + + // Update the preference + this.preferences[category][setting] = value; + + // Save if requested + if (saveImmediately) { + return this.savePreferences(); + } + + return true; + } + + /** + * Get a specific preference value + * @param {string} category - The preference category + * @param {string} setting - The specific setting name + * @param {any} [defaultValue] - Default value if the preference doesn't exist + * @returns {any} The preference value + */ + getPreference(category, setting, defaultValue = null) { + // Check if category exists + if (!this.preferences[category]) { + return defaultValue; + } + + // Check if setting exists in category + if (this.preferences[category].hasOwnProperty(setting)) { + return this.preferences[category][setting]; + } + + return defaultValue; + } + + /** + * Reset preferences to defaults + * @param {string} [category] - Optional category to reset (resets all if not specified) + * @param {boolean} [saveImmediately=true] - Whether to save immediately + */ + resetPreferences(category = null, saveImmediately = true) { + if (category) { + // Reset only specified category + if (this.defaultPreferences[category]) { + this.preferences[category] = { ...this.defaultPreferences[category] }; + } + } else { + // Reset all preferences + this.preferences = { ...this.defaultPreferences }; + } + + // Save if requested + if (saveImmediately) { + return this.savePreferences(); + } + + return true; + } + + /** + * Get all preferences + * @returns {Object} The current preferences + */ + getAllPreferences() { + return { ...this.preferences }; + } } + +// Create the singleton instance +const PersistenceManager = new PersistenceManagerModule(); + +// Register with the module registry +moduleRegistry.register(PersistenceManager); + +// Export the module +export { PersistenceManager }; + +// Keep a reference in window for loader system +window.PersistenceManager = PersistenceManager; diff --git a/public/js/socket-client.js b/public/js/socket-client.js index f522cc6..b853e38 100644 --- a/public/js/socket-client.js +++ b/public/js/socket-client.js @@ -1,176 +1,439 @@ /** * Socket Client Module - * Manages WebSocket communication with the game server. + * Handles WebSocket communication for receiving text fragments and game state */ -export class SocketClient { - constructor(serverUrl) { - this.socket = null; - this.serverUrl = serverUrl || window.location.origin; // Default to current origin - this.eventListeners = { - connect: [], - disconnect: [], - connect_error: [], - gameIntroduction: [], - narrativeResponse: [], - gameSaved: [], - gameLoaded: [], - error: [], - }; - } +import { BaseModule } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; - /** - * Connects to the WebSocket server. - */ - connect() { - if (this.socket && this.socket.connected) { - console.log('SocketClient: Already connected.'); - return; +class SocketClientModule extends BaseModule { + constructor() { + super('socket-client', 'Socket Client'); + this.socket = null; + this.textBuffer = null; + this.isConnected = false; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 5; + this.reconnectDelay = 2000; // 2 seconds + this.url = null; + this.eventListeners = {}; + this.defaultHost = 'localhost:3000'; // Default to localhost:3000 if not running in same origin } - - console.log(`SocketClient: Connecting to ${this.serverUrl}...`); - // Ensure io is available (it should be loaded globally) - if (typeof io === 'undefined') { - console.error('Socket.IO client library (io) not found. Make sure it is loaded.'); - this.triggerEvent('error', { message: 'Socket.IO library not loaded.' }); - return; - } - - this.socket = io(this.serverUrl, { - reconnectionAttempts: 5, - timeout: 10000, - }); - - this.initializeSocketEventHandlers(); - } - - /** - * Disconnects from the server. - */ - disconnect() { - if (this.socket) { - console.log('SocketClient: Disconnecting...'); - this.socket.disconnect(); - } - } - - /** - * Checks if the client is currently connected. - * @returns {boolean} True if connected, false otherwise. - */ - isConnected() { - return this.socket && this.socket.connected; - } - - /** - * Sets up the listeners for standard socket events. - */ - initializeSocketEventHandlers() { - if (!this.socket) return; - - this.socket.on('connect', () => { - console.log('SocketClient: Connected to server.'); - this.triggerEvent('connect'); - }); - - this.socket.on('disconnect', (reason) => { - console.log(`SocketClient: Disconnected from server. Reason: ${reason}`); - this.triggerEvent('disconnect', reason); - }); - - this.socket.on('connect_error', (error) => { - console.error('SocketClient: Connection error:', error); - this.triggerEvent('connect_error', error); - }); - - // --- Game-specific events --- - - this.socket.on('gameIntroduction', (data) => { - console.log('SocketClient: Received gameIntroduction'); - this.triggerEvent('gameIntroduction', data); - }); - - this.socket.on('narrativeResponse', (data) => { - console.log('SocketClient: Received narrativeResponse'); - this.triggerEvent('narrativeResponse', data); - }); - - this.socket.on('gameSaved', (data) => { - console.log('SocketClient: Received gameSaved confirmation'); - this.triggerEvent('gameSaved', data); // Pass data if any - }); - - this.socket.on('gameLoaded', (data) => { - console.log('SocketClient: Received gameLoaded confirmation'); - this.triggerEvent('gameLoaded', data); - }); - - this.socket.on('error', (data) => { - console.error('SocketClient: Received error from server:', data); - this.triggerEvent('error', data); - }); - } - - /** - * Registers a listener for a specific event. - * @param {string} eventName - The name of the event. - * @param {function} callback - The function to call when the event occurs. - */ - on(eventName, callback) { - if (this.eventListeners[eventName]) { - this.eventListeners[eventName].push(callback); - } else { - console.warn(`SocketClient: Attempted to register listener for unknown event "${eventName}"`); - } - } - - /** - * Triggers a specific event, calling all registered listeners. - * @param {string} eventName - The name of the event. - * @param {*} data - Data to pass to the listeners. - */ - triggerEvent(eventName, data) { - if (this.eventListeners[eventName]) { - this.eventListeners[eventName].forEach(callback => { + + /** + * Load module dependencies + * @returns {Promise} - Resolves when dependencies are loaded + */ + async loadDependencies() { try { - callback(data); + // We depend on the text-buffer module + this.reportProgress(30, "Waiting for text buffer"); + + // Dynamically load Socket.IO client if not already loaded + if (!window.io) { + this.reportProgress(40, "Loading Socket.IO client"); + await this.loadSocketIO(); + this.reportProgress(45, "Socket.IO client loaded"); + } + + return true; } catch (error) { - console.error(`SocketClient: Error in event listener for "${eventName}":`, error); + console.error("Error loading Socket Client dependencies:", error); + return false; } - }); } - } - - /** - * Emits an event to the server. - * @param {string} eventName - The name of the event to emit. - * @param {object} data - The data to send with the event. - */ - emit(eventName, data) { - if (this.socket && this.socket.connected) { - console.log(`SocketClient: Emitting "${eventName}"`, data || ''); - this.socket.emit(eventName, data); - } else { - console.error(`SocketClient: Cannot emit "${eventName}", not connected.`); - // Optionally trigger an error event or queue the message - this.triggerEvent('error', { message: `Cannot send command "${eventName}", not connected.` }); + + /** + * Load Socket.IO client library + * @returns {Promise} + */ + loadSocketIO() { + return new Promise((resolve, reject) => { + // Check if Socket.IO is already loaded + if (typeof window.io !== 'undefined') { + resolve(); + return; + } + + // Load the Socket.IO client from the same server that served this page + const script = document.createElement('script'); + script.src = '/socket.io/socket.io.js'; // Socket.IO automatically serves this + script.async = true; + + script.onload = () => { + if (typeof window.io !== 'undefined') { + resolve(); + } else { + reject(new Error('Failed to load Socket.IO client')); + } + }; + + script.onerror = () => { + reject(new Error('Failed to load Socket.IO client script')); + }; + + document.head.appendChild(script); + }); + } + + /** + * Wait for dependencies to be ready + */ + async waitForDependencies() { + try { + // Wait for the text buffer module to be available + const textBufferReady = await moduleRegistry.waitForModule('text-buffer', 10000); + + if (textBufferReady) { + this.textBuffer = window.TextBuffer; + this.reportProgress(60, "Text buffer module ready"); + return true; + } else { + console.warn("Text buffer module not ready, Socket Client will have limited functionality"); + return true; // Continue anyway for graceful degradation + } + } catch (error) { + console.error("Error waiting for dependencies:", error); + return false; + } + } + + /** + * Initialize the module + * @returns {Promise} - Resolves with success status + */ + async initialize() { + try { + // Use the current origin for the socket connection + // This automatically handles the Docker port mapping situation + const currentUrl = window.location.origin; + + console.log(`Socket Client: Using origin for connection: ${currentUrl}`); + this.url = currentUrl; + + this.reportProgress(100, "Socket client initialized"); + return true; + } catch (error) { + console.error("Error initializing Socket Client:", error); + return false; + } + } + + /** + * Connect to the Socket.IO server + * @param {string} url - Optional custom WebSocket URL + * @returns {Promise} - Resolves with connection success + */ + connect(url = null) { + return new Promise((resolve) => { + if (this.isConnected) { + resolve(true); + return; + } + + // Use provided URL or default + const socketUrl = url || this.url; + + try { + console.log(`Socket Client: Connecting to ${socketUrl}`); + + // Create Socket.IO connection (will automatically use /socket.io endpoint) + this.socket = window.io(socketUrl, { + reconnection: false, // We handle reconnection ourselves + transports: ['websocket', 'polling'] // Prefer WebSocket + }); + + this.socket.on('connect', () => { + console.log('Socket Client: Connected to server with ID:', this.socket.id); + this.isConnected = true; + this.reconnectAttempts = 0; + this.emitEvent('connect'); + resolve(true); + }); + + this.socket.on('disconnect', (reason) => { + console.log(`Socket Client: Connection closed: ${reason}`); + this.isConnected = false; + this.emitEvent('disconnect', reason); + this.attemptReconnect(); + resolve(false); + }); + + this.socket.on('connect_error', (error) => { + console.error('Socket Client: Connection error:', error); + this.emitEvent('connect_error', error); + resolve(false); + }); + + // Set up game-specific event handlers + this.setupGameEventHandlers(); + + } catch (error) { + console.error('Socket Client: Connection error:', error); + this.emitEvent('connect_error', error); + resolve(false); + } + }); + } + + /** + * Set up event handlers for game-specific Socket.IO events + */ + setupGameEventHandlers() { + if (!this.socket) return; + + // Map all incoming Socket.IO events to our internal event system + this.socket.onAny((event, ...args) => { + console.log(`Socket Client: Received ${event} event from server`, args); + this.emitEvent(event, args[0]); + }); + + // Special handling for narrative text + this.socket.on('narrativeResponse', (data) => { + if (data && data.text && this.textBuffer) { + this.processTextFragment(data.text); + } + }); + + // Special handling for introduction text + this.socket.on('gameIntroduction', (data) => { + if (data && data.introduction && this.textBuffer) { + this.processTextFragment(data.introduction); + } + + if (data && data.initialRoomDescription && this.textBuffer) { + this.processTextFragment(data.initialRoomDescription); + } + }); + } + + /** + * Process a text fragment by adding it to the TextBuffer + * @param {string} text - Text fragment to process + */ + processTextFragment(text) { + if (!text) return; + + // Add text to the buffer if available + if (this.textBuffer) { + console.log(`Socket Client: Processing text fragment: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); + this.textBuffer.addText(text); + } else { + console.error('Socket Client: Text buffer not available'); + // Attempt to get text buffer again + this.textBuffer = moduleRegistry.getModule('text-buffer'); + if (this.textBuffer) { + this.textBuffer.addText(text); + } else { + // Emit a text event as fallback if no text buffer + this.emitEvent('text', text); + } + } + } + + /** + * Attempt to reconnect to the server + */ + attemptReconnect() { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('Socket Client: Max reconnect attempts reached'); + return; + } + + this.reconnectAttempts++; + const delay = this.reconnectDelay * this.reconnectAttempts; + + console.log(`Socket Client: Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`); + + setTimeout(() => { + if (!this.isConnected) { + this.connect(); + } + }, delay); + } + + /** + * Disconnect from the server + */ + disconnect() { + if (this.socket && this.isConnected) { + this.socket.disconnect(); + this.isConnected = false; + } + } + + /** + * Send a message to the server + * @param {Object|string} data - Data to send + * @returns {boolean} - Success status + */ + send(data) { + if (!this.isConnected || !this.socket) { + console.error('Socket Client: Not connected'); + return false; + } + + try { + // For Socket.IO we send structured events + if (typeof data === 'object') { + const { type, ...restData } = data; + if (type) { + // Use the type as the event name + this.socket.emit(type, restData); + } else { + // Default to 'message' event + this.socket.emit('message', data); + } + } else { + // Plain strings go to 'message' event + this.socket.emit('message', { text: data }); + } + return true; + } catch (error) { + console.error('Socket Client: Error sending message:', error); + return false; + } } - } - // --- Convenience methods for game actions --- + /** + * Send a command to the server + * @param {string} command - The player's command + * @returns {boolean} - Success status + */ + sendCommand(command) { + if (!this.isConnected || !this.socket) { + console.error('Socket Client: Not connected, cannot send command'); + return false; + } + + try { + this.socket.emit('playerCommand', { command }); + return true; + } catch (error) { + console.error('Socket Client: Error sending command:', error); + return false; + } + } - requestStartGame() { - this.emit('startGame'); - } + /** + * Request to start a new game + * @returns {boolean} - Success status + */ + requestStartGame() { + if (!this.isConnected || !this.socket) { + console.error('Socket Client: Not connected, cannot start game'); + return false; + } + + try { + this.socket.emit('startGame'); + return true; + } catch (error) { + console.error('Socket Client: Error starting game:', error); + return false; + } + } - sendCommand(command) { - this.emit('playerCommand', { command }); - } + /** + * Request to save the current game state + * @returns {boolean} - Success status + */ + requestSaveGame() { + if (!this.isConnected || !this.socket) { + console.error('Socket Client: Not connected, cannot save game'); + return false; + } + + try { + this.socket.emit('saveGame'); + return true; + } catch (error) { + console.error('Socket Client: Error saving game:', error); + return false; + } + } - requestSaveGame() { - this.emit('saveGame'); - } + /** + * Request to load a saved game state + * @returns {boolean} - Success status + */ + requestLoadGame() { + if (!this.isConnected || !this.socket) { + console.error('Socket Client: Not connected, cannot load game'); + return false; + } + + try { + this.socket.emit('loadGame'); + return true; + } catch (error) { + console.error('Socket Client: Error loading game:', error); + return false; + } + } - requestLoadGame() { - this.emit('loadGame'); - } + /** + * Register an event handler + * @param {string} event - Event name + * @param {Function} callback - Event callback + */ + on(event, callback) { + if (!this.eventListeners[event]) { + this.eventListeners[event] = []; + } + this.eventListeners[event].push(callback); + } + + /** + * Remove an event handler + * @param {string} event - Event name + * @param {Function} callback - Event callback to remove + */ + off(event, callback) { + if (!this.eventListeners[event]) return; + + if (callback) { + // Remove specific callback + this.eventListeners[event] = this.eventListeners[event].filter(cb => cb !== callback); + } else { + // Remove all callbacks for this event + delete this.eventListeners[event]; + } + } + + /** + * Emit an event to all registered listeners + * @param {string} event - Event name + * @param {*} data - Event data + */ + emitEvent(event, data) { + if (!this.eventListeners[event]) return; + + for (const callback of this.eventListeners[event]) { + try { + callback(data); + } catch (error) { + console.error(`Socket Client: Error in '${event}' event handler:`, error); + } + } + } + + /** + * Check if the socket is connected + * @returns {boolean} - Connection status + */ + getConnectionStatus() { + return this.isConnected; + } } + +// Create the singleton instance +const SocketClient = new SocketClientModule(); + +// Register with the module registry +moduleRegistry.register(SocketClient); + +// Export the module +export { SocketClient }; + +// Keep a reference in window for loader system +window.SocketClient = SocketClient; diff --git a/public/js/speech.js b/public/js/speech.js deleted file mode 100644 index 820c60e..0000000 --- a/public/js/speech.js +++ /dev/null @@ -1,54 +0,0 @@ -const axios = require('axios'); -const fs = require('fs'); -const crypto = require('crypto'); -const player = require('play-sound')(opts = {}); -const { ipcMain } = require('electron'); - -// Directory where audio files will be cached -const cacheDirectory = './speech_cache/'; - -// Create cache directory if it does not exist -if (!fs.existsSync(cacheDirectory)) { - fs.mkdirSync(cacheDirectory); -} - -ipcMain.handle('getSpeech', async (event, text) => { - // Create a hash of the text to use as a unique filename - const filename = crypto.createHash('md5').update(text).digest('hex') + '.mp3'; - - // Full path of the audio file in the cache directory - const filepath = cacheDirectory + filename; - - // Check if audio file already exists in the cache - if (!fs.existsSync(filepath)) { - // If audio file does not exist, make API request - try { - const response = await axios({ - method: 'post', - url: 'https://api.elevenlabs.io/v1/text-to-speech/8JNqTOY3RaSYcHTVJZ0G', - headers: { - 'Content-Type': 'application/json', - 'xi-api-key': 'd191e27c2e5b07573b39fe70f0783f48' - }, - data: { - text: text, - model_id: 'eleven_multilingual_v1', - voice_settings: { - stability: 0, - similarity_boost: 0, - style: 0.5, - use_speaker_boost: true - } - }, - responseType: 'arraybuffer' - }); - - // Write the audio data to a file in the cache directory - fs.writeFileSync(filepath, response.data); - - } catch (error) { - console.error(`Error making API request: ${error}`); - } - } - return filepath -}); \ No newline at end of file diff --git a/public/js/text-buffer.js b/public/js/text-buffer.js new file mode 100644 index 0000000..73d294f --- /dev/null +++ b/public/js/text-buffer.js @@ -0,0 +1,182 @@ +/** + * TextBuffer Module + * Manages text processing and sentence detection for the UI + */ +import { BaseModule } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; + +class TextBufferModule extends BaseModule { + constructor() { + super('text-buffer', 'Text Buffer'); + this.buffer = ''; + this.sentenceEndRegex = /[.!?]\s+/g; // Detect sentence endings + this.onSentenceReadyCallback = null; // Callback for complete sentences + this.processingLock = false; // Lock to prevent concurrent processing + } + + /** + * Initialize the module + * @returns {Promise} - Resolves with success status + */ + async initialize() { + try { + this.reportProgress(100, "Text buffer ready"); + return true; + } catch (error) { + console.error("Error initializing Text Buffer:", error); + return false; + } + } + + /** + * Set callback function for when a sentence is ready + * @param {Function} callback - Function to call with the sentence and completion callback + */ + setOnSentenceReady(callback) { + if (typeof callback === 'function') { + this.onSentenceReadyCallback = callback; + console.log("Text Buffer: Sentence ready callback set"); + } else { + console.warn("Text Buffer: Invalid sentence ready callback provided"); + } + } + + /** + * Add text to the buffer and process sentences + * @param {string} text - Text to add to the buffer + */ + addText(text) { + if (!text) return; + + console.log(`TextBuffer: Adding text: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); + + // Add text to buffer + this.buffer += text; + + // If we have a trailing newline as a complete sentence, add a period + if (this.buffer.endsWith('\n') && !this.buffer.endsWith('.\n')) { + const lastChar = this.buffer.charAt(this.buffer.length - 2); + if (lastChar !== '.' && lastChar !== '!' && lastChar !== '?') { + this.buffer = this.buffer.slice(0, -1) + '.\n'; + } + } + + // Process any complete sentences + this.processSentences(); + } + + /** + * Process complete sentences in the buffer + */ + processSentences() { + // Prevent concurrent processing + if (this.processingLock) return; + this.processingLock = true; + + try { + // Check for sentence endings (including newlines as sentence endings) + const sentenceEndings = [/[.!?]\s+/g, /[.!?]$/m, /\n/g]; + + let foundSentence = false; + + for (const pattern of sentenceEndings) { + if (this.buffer.match(pattern)) { + foundSentence = true; + break; + } + } + + if (!foundSentence) { + // No complete sentences yet + this.processingLock = false; + return; + } + + // Process each complete sentence + this.processNextSentence(); + } catch (error) { + console.error("Error processing sentences:", error); + this.processingLock = false; + } + } + + /** + * Process the next sentence in the buffer + */ + processNextSentence() { + // Check for different sentence endings + const patterns = [/[.!?]\s+/, /[.!?]$/, /\n/]; + let match = null; + let endIndex = -1; + + // Try to find the first sentence ending + for (const pattern of patterns) { + match = this.buffer.match(pattern); + if (match) { + endIndex = match.index + match[0].length; + break; + } + } + + if (endIndex === -1) { + // No complete sentence found + this.processingLock = false; + return; + } + + const sentence = this.buffer.substring(0, endIndex); + + // Remove the processed sentence from buffer + this.buffer = this.buffer.substring(endIndex); + + console.log(`TextBuffer: Processing sentence: "${sentence.trim()}"`); + + // Call the callback if set + if (this.onSentenceReadyCallback) { + this.onSentenceReadyCallback(sentence, () => { + // After processing is complete, check for more sentences + setTimeout(() => { + if (this.buffer.length > 0) { + this.processSentences(); + } else { + this.processingLock = false; + } + }, 0); + }); + } else { + // No callback set, just process the next sentence + if (this.buffer.length > 0) { + this.processSentences(); + } else { + this.processingLock = false; + } + } + } + + /** + * Clear the text buffer + */ + clear() { + this.buffer = ''; + } + + /** + * Get the current buffer content + * @returns {string} - Current buffer content + */ + getBuffer() { + return this.buffer; + } +} + +// Create the singleton instance +const TextBuffer = new TextBufferModule(); + +// Register with the module registry +moduleRegistry.register(TextBuffer); + +// Export the module +export { TextBuffer }; + +// Keep a reference in window for loader system +window.TextBuffer = TextBuffer; \ No newline at end of file diff --git a/public/js/text-processor.js b/public/js/text-processor.js index ea8291a..7d0fac3 100644 --- a/public/js/text-processor.js +++ b/public/js/text-processor.js @@ -1,71 +1,314 @@ /** - * TextProcessor Module - * Encapsulates text pre-processing steps required before layout calculation. + * Text Processor Module + * Handles text formatting and typography enhancements like smart quotes and hyphenation */ -export class TextProcessor { - /** - * Create a new TextProcessor - * @param {Object} smartyPants - The SmartyPants library - * @param {Function} [hyphenator] - Optional: The hyphenation function (can be set later) - */ - constructor(smartyPants, hyphenator = null) { // Make hyphenator optional - this.smartyPants = smartyPants; - this.hyphenator = hyphenator; - this.hyphenationClass = '.hyphenatePipe'; // Default hyphenation class for Knuth-Plass with pipe character +import { BaseModule } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; + +class TextProcessorModule extends BaseModule { + constructor() { + super('text-processor', 'Text Processor'); + this.smartyPants = null; // Store the function reference here + this.smartypantsu = null; // Store the function reference here + this.hyphenator = null; // For hyphenation function + this.hyphenatorReady = false; + this.locale = 'en-us'; } /** - * Process text with typographic enhancements and hyphenation + * Load module dependencies + * @returns {Promise} - Resolves with success status + */ + async loadDependencies() { + try { + this.reportProgress(10, "Loading dependencies"); + + // Load SmartyPants script dynamically + await this.loadSmartyPantsScript(); + this.reportProgress(50, "SmartyPants loaded"); + + // Initialize hyphenation in the background, but don't wait for it + this.initializeHyphenation(); + + this.reportProgress(90, "Dependencies loaded"); + return true; + } catch (error) { + console.error("Error loading Text Processor dependencies:", error); + return false; + } + } + + /** + * Load the SmartyPants script dynamically and wait for it to be ready + * @returns {Promise} + */ + loadSmartyPantsScript() { + return new Promise((resolve, reject) => { + // Check if already loaded globally + if (typeof window.SmartyPants === 'object' && typeof window.SmartyPants.smartypants === 'function') { + this.smartyPants = window.SmartyPants.smartypants; + this.smartypantsu = window.SmartyPants.smartypantsu; + console.log("SmartyPants already loaded globally"); + resolve(); + return; + } + + // Load the script using a script tag + const script = document.createElement('script'); + script.src = '/js/smartypants.js'; + script.async = false; // Load synchronously relative to other scripts + + script.onload = () => { + // Use a microtask to ensure the script has executed + Promise.resolve().then(() => { + if (typeof window.SmartyPants === 'object' && typeof window.SmartyPants.smartypants === 'function') { + this.smartyPants = window.SmartyPants.smartypants; + this.smartypantsu = window.SmartyPants.smartypantsu; + console.log("SmartyPants loaded successfully via script tag"); + resolve(); + } else { + console.error("SmartyPants script loaded but functions not found on window.SmartyPants"); + reject(new Error('SmartyPants functions not found after loading')); + } + }); + }; + + script.onerror = () => { + console.error('Failed to load smartypants.js script'); + reject(new Error('Failed to load smartypants.js script')); + }; + + document.head.appendChild(script); + }); + } + + /** + * Initialize the module + * @returns {Promise} - Resolves with success status + */ + async initialize() { + try { + this.reportProgress(70, "Initializing text processor"); + + // Get locale from Localization module if available + const localizationModule = moduleRegistry.getModule('localization'); + if (localizationModule) { + this.locale = localizationModule.getLocale(); + // Register as an observer for locale changes + localizationModule.registerObserver(this, (newLocale) => { + this.setLocale(newLocale); + }); + } + + // Ensure global locale is set for SmartyPants + window.locale = this.locale; + + // Verify SmartyPants is available via the stored references + if (typeof this.smartyPants !== 'function') { + console.error("SmartyPants function not available for initialization"); + return false; + } + + // Final initialization steps + this.reportProgress(100, "Text processor ready"); + return true; + } catch (error) { + console.error("Error initializing Text Processor:", error); + return false; + } + } + + /** + * Initialize hyphenation using Hyphenopoly + */ + initializeHyphenation() { + // Create custom events for hyphenation loading status + const hyphenationLoadedEvent = new CustomEvent('hyphenation-loaded'); + + // Add listener for hyphenation loaded event + document.addEventListener('hyphenation-loaded', () => { + console.log('Hyphenation module loaded'); + this.hyphenatorReady = true; + }, { once: true }); + + // Check if Hyphenopoly is loaded + if (window.Hyphenopoly) { + this.setupHyphenopoly(); + } else { + // Set up listener for when Hyphenopoly might be loaded later + window.addEventListener('hyphenopoly-loaded', () => { + this.setupHyphenopoly(); + }); + + // Try loading Hyphenopoly if not already loading + if (!document.querySelector('script[src*="Hyphenopoly_Loader.js"]')) { + this.loadHyphenopolyScript(); + } + } + } + + /** + * Load the Hyphenopoly script + */ + loadHyphenopolyScript() { + // Create script element for loader + const script = document.createElement('script'); + script.src = '/js/Hyphenopoly_Loader.js'; + script.async = true; + + script.onload = () => { + document.dispatchEvent(new CustomEvent('hyphenopoly-script-loaded')); + }; + + script.onerror = (error) => { + console.error('Failed to load Hyphenopoly:', error); + document.dispatchEvent(new CustomEvent('hyphenation-error', { + detail: { error: 'Failed to load Hyphenopoly script' } + })); + }; + + document.head.appendChild(script); + + // Set up configuration for Hyphenopoly + window.Hyphenopoly = { + require: { + 'en-us': 'FORCEHYPHENATION' + }, + paths: { + maindir: '/js/', + patterndir: '/js/patterns/' + }, + setup: { + selectors: { + '.hyphenate': {} + } + } + }; + } + + /** + * Set up Hyphenopoly when it's available + */ + setupHyphenopoly() { + // Wait for hyphenator to be available + if (window.Hyphenopoly && window.Hyphenopoly.hyphenators) { + // Get hyphenator for English + window.Hyphenopoly.hyphenators['en-us'].then((hyphenator) => { + console.log('Hyphenator ready'); + this.hyphenator = hyphenator; + this.hyphenatorReady = true; + + // Dispatch event that hyphenation is ready + document.dispatchEvent(new CustomEvent('hyphenation-loaded')); + }).catch(err => { + console.error('Error loading hyphenator:', err); + }); + } + } + + /** + * Set the hyphenator function + * @param {Function} hyphenatorFunc - The hyphenator function + */ + setHyphenator(hyphenatorFunc) { + if (typeof hyphenatorFunc === 'function') { + this.hyphenator = hyphenatorFunc; + this.hyphenatorReady = true; + console.log("Hyphenator function set explicitly"); + } else { + console.warn("Invalid hyphenator provided"); + } + } + + /** + * Process text with SmartyPants and optional hyphenation * @param {string} text - The text to process - * @returns {string} The processed text + * @param {boolean} useHyphenation - Whether to apply hyphenation + * @returns {string} - The processed text */ - process(text) { - // First apply SmartyPants for typographic enhancement - const smartyPantsText = this.smartyPants.smartypantsu(text, 1) - // Remove these replacements that were causing the spacing issues - // .replace(/\.\s*$/g, '.') - // .replace(/\?\s*$/g, '?') - // .replace(/!\s*$/g, '!') - - // Instead, ensure proper spacing between sentences - .replace(/\.\s+/g, '. ') // Normalize spaces after periods - .replace(/\?\s+/g, '? ') // Normalize spaces after question marks - .replace(/!\s+/g, '! '); // Normalize spaces after exclamation marks + process(text, useHyphenation = false) { + if (!text) return ''; - // Then apply hyphenation if available - if (typeof this.hyphenator === 'function') { - return this.hyphenator(smartyPantsText, this.hyphenationClass); - } else { - console.warn('TextProcessor: Hyphenator not set, skipping hyphenation.'); - return smartyPantsText; // Return text without hyphenation if not set + let processed = text; + + // Apply SmartyPants for typographic punctuation using stored references + try { + if (typeof this.smartyPants === 'function') { + processed = this.smartyPants(processed); + } else { + console.warn("SmartyPants function not available for processing"); + } + + // Convert HTML entities to UTF-8 characters + if (typeof this.smartypantsu === 'function') { + processed = this.smartypantsu(processed); + } else { + console.warn("smartypantsu function not available for processing"); + } + } catch (error) { + console.error("Error applying SmartyPants:", error); + } + + // Apply hyphenation if enabled and available + if (useHyphenation && this.hyphenatorReady && this.hyphenator) { + try { + processed = this.hyphenator(processed); + } catch (error) { + console.error("Error applying hyphenation:", error); + } + } + + return processed; + } + + /** + * Check if hyphenation is available + * @returns {boolean} - Whether hyphenation is available + */ + isHyphenationAvailable() { + return this.hyphenatorReady && this.hyphenator !== null; + } + + /** + * Apply only hyphenation to text + * @param {string} text - The text to hyphenate + * @returns {string} - The hyphenated text + */ + hyphenate(text) { + if (!text || !this.hyphenatorReady || !this.hyphenator) { + return text; + } + + try { + return this.hyphenator(text); + } catch (error) { + console.error("Error hyphenating text:", error); + return text; } } /** - * Set the hyphenator function after initialization. - * @param {Function} hyphenatorFunction - The hyphenation function provided by Hyphenopoly. + * Set the locale for text processing + * @param {string} locale - The locale code (e.g., 'en-us', 'de') */ - setHyphenator(hyphenatorFunction) { - if (typeof hyphenatorFunction === 'function') { - this.hyphenator = hyphenatorFunction; - } else { - console.error('TextProcessor: Invalid hyphenator function provided.'); + setLocale(locale) { + if (locale && typeof locale === 'string') { + this.locale = locale.toLowerCase(); + // Update global locale for SmartyPants + window.locale = this.locale; + console.log(`TextProcessor: Locale set to ${locale}`); } } - - /** - * Set the hyphenation class - * @param {string} className - The CSS class for hyphenation - */ - setHyphenationClass(className) { - this.hyphenationClass = className; - } - - /** - * Get the current hyphenation class - * @returns {string} The current hyphenation class - */ - getHyphenationClass() { - return this.hyphenationClass; - } } + +// Create the singleton instance +const TextProcessor = new TextProcessorModule(); + +// Register with the module registry +moduleRegistry.register(TextProcessor); + +// Export the module +export { TextProcessor }; + +// Keep a reference in window for loader system +window.TextProcessor = TextProcessor; diff --git a/public/js/tts-factory.js b/public/js/tts-factory.js index 5c65714..beeb8a3 100644 --- a/public/js/tts-factory.js +++ b/public/js/tts-factory.js @@ -1,172 +1,555 @@ /** * TTS Factory for AI Interactive Fiction - * Attempts to use Kokoro TTS first, then falls back to browser TTS if needed + * Manages different TTS implementations with a common interface */ -import { kokoroHandler } from './kokoro-handler.js'; -import { browserTtsHandler } from './tts-handler.js'; - -export class TTSFactory { +class TTSFactory { constructor() { - this.activeTTSHandler = null; + this.ttsHandler = null; + this.handlers = {}; this.initializationAttempted = false; - this.usingKokoro = false; - this.initializationPromise = null; // Promise for the factory initialization + this.initializationPromise = null; + this.ttsEnabled = true; + this.progressCallback = null; + this.persistenceManager = null; + } - // Initialize on DOM ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => this.initialize()); - } else { - // Use requestAnimationFrame to ensure scripts are parsed - requestAnimationFrame(() => this.initialize()); + /** + * Initialize the TTS Factory - Static method for the module loader + * @param {Function} reportProgress - Function to report loading progress to the loader + * @returns {Promise} - Resolves when TTS is initialized + */ + static async initializeInterface(reportProgress = null) { + console.log('TTS Factory: Initializing interface'); + + // Create singleton instance if needed + if (!window.ttsFactory) { + window.ttsFactory = new TTSFactory(); + } + + // Initialize TTS with the progress callback + window.ttsFactory.progressCallback = reportProgress; + + try { + // Start initialization process + await window.ttsFactory.initialize(); + return true; + } catch (error) { + console.error('Error initializing TTS Factory:', error); + return false; } } /** - * Initialize available TTS handlers + * Initialize the TTS Factory + * This will load and initialize all available TTS handlers + * @returns {Promise} - Resolves when initialization is complete */ async initialize() { - // Prevent multiple initializations - if (this.initializationAttempted) return this.initializationPromise; - this.initializationAttempted = true; - - console.log('Initializing TTS Factory...'); + if (this.initializationPromise) { + return this.initializationPromise; + } this.initializationPromise = new Promise(async (resolve) => { - let kokoroInitialized = false; - // Try to initialize Kokoro first (preferred option) - try { - console.log('Attempting to initialize Kokoro TTS...'); - - // --- Increase Timeout for Kokoro Initialization --- - // Wait for KokoroHandler's internal initialization promise - // Use Promise.race to add a longer timeout specifically for Kokoro init - const kokoroTimeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('Kokoro initialization timed out in factory')), 60000) // 60 seconds timeout - ); + this.initializationAttempted = true; + + const reportProgress = (percent, message) => { + console.log(`TTS progress: ${percent}% - ${message}`); + if (this.progressCallback && typeof this.progressCallback === 'function') { + this.progressCallback(percent, message); + } + }; - try { - kokoroInitialized = await Promise.race([ - kokoroHandler.initializationPromise, - kokoroTimeoutPromise - ]); - } catch (timeoutError) { - console.error(timeoutError.message); // Log the timeout error - kokoroInitialized = false; - } - // --- End Increase Timeout --- + try { + // Report starting initialization + reportProgress(10, 'Loading TTS modules'); + + // Get persistence manager if available + if (window.PersistenceManager) { + this.persistenceManager = window.PersistenceManager; + reportProgress(15, 'Persistence manager found, loading preferences'); - if (kokoroInitialized) { - console.log('Kokoro Handler reported successful initialization.'); - } else { - console.warn('Kokoro Handler reported failed or timed out initialization.'); + // Load preferences to determine TTS enabled state and preferred provider + const prefs = this.persistenceManager.getAllPreferences(); + if (prefs && prefs.tts) { + this.ttsEnabled = prefs.tts.enabled; + console.log(`TTS Factory: Setting initial TTS enabled state to ${this.ttsEnabled ? 'enabled' : 'disabled'} from preferences`); } - } catch (error) { - console.error('Error initializing Kokoro Handler:', error); - kokoroInitialized = false; // Ensure it's marked as failed + } + + // Import needed modules dynamically + const [{ BrowserTTSHandler }, { KokoroHandler }, { ApiTTSHandler }] = await Promise.all([ + import('./browser-tts-handler.js'), + import('./kokoro-handler.js'), + import('./api-tts-handler.js') + ]); + + reportProgress(20, 'TTS modules loaded'); + + // Create handlers + const browserHandler = new BrowserTTSHandler(); + const kokoroHandler = new KokoroHandler(); + const apiHandler = new ApiTTSHandler(); + + // Store handlers + this.handlers = { + browser: browserHandler, + kokoro: kokoroHandler, + api: apiHandler + }; + + // Get preferred TTS mode from options + const preferredTTSMode = this.getPreferredTTSMode(); + + // Initialize the preferred handler first + if (preferredTTSMode === 'browser') { + // User prefers browser TTS + await this.initializeBrowserTTS(browserHandler, reportProgress); + } else if (preferredTTSMode === 'api') { + // User prefers API TTS + await this.initializeApiTTS(apiHandler, reportProgress); + + // Fallback to browser TTS if API fails + if (!apiHandler.isAvailable()) { + await this.initializeBrowserTTS(browserHandler, reportProgress); + } + } else { + // Default flow: prefer Kokoro, with browser as immediate fallback + // Initialize browser TTS immediately for a responsive experience + await this.initializeBrowserTTS(browserHandler, reportProgress); + + // Then schedule Kokoro loading in the background + reportProgress(75, 'Scheduling Kokoro TTS initialization'); + this.scheduleKokoroInitialization(kokoroHandler, reportProgress).then((kokoroAvailable) => { + if (kokoroAvailable) { + // Switch to Kokoro as it's the best option and set as preferred + this.ttsHandler = kokoroHandler; + this.setPreferredTTSMode('kokoro'); + this.dispatchTTSReadyEvent(true, 'kokoro', kokoroHandler); + reportProgress(100, 'Kokoro TTS ready'); + + // Apply voice settings from preferences if available + this.applyVoiceSettingsFromPreferences(); + } else if (!this.getPreferredTTSMode()) { + // If Kokoro failed and no preference was previously set, + // set browser as preferred mode + this.setPreferredTTSMode('browser'); + } + }); } - // Decide which handler to use based on Kokoro's success - this.selectActiveHandler(kokoroInitialized); - resolve(); // Resolve the factory's promise + // Apply voice settings from preferences for initial handler + this.applyVoiceSettingsFromPreferences(); + + // Resolve initialization even though Kokoro is still loading in background + reportProgress(80, 'TTS interface ready' + + (preferredTTSMode !== 'kokoro' ? '' : ' (Kokoro loading in background)')); + resolve(true); + + } catch (error) { + console.error('Error initializing TTS Factory:', error); + + // If we have any handler working, consider initialization successful + if (this.ttsHandler) { + reportProgress(100, `Using ${this.ttsHandler.getId()} TTS (fallback)`); + resolve(true); + } else { + this.dispatchTTSReadyEvent(false); + reportProgress(100, 'TTS initialization failed'); + resolve(false); + } + } }); return this.initializationPromise; } /** - * Select which TTS handler to use - * @param {boolean} kokoroInitialized - Whether Kokoro initialization succeeded + * Apply stored voice settings from preferences + * @private */ - selectActiveHandler(kokoroInitialized) { - // First choice: Kokoro if it's available and initialized successfully - if (kokoroInitialized && kokoroHandler.kokoroReady) { - console.log('Using Kokoro TTS as primary TTS system'); - this.activeTTSHandler = kokoroHandler; - this.usingKokoro = true; - } - // Fallback to browser TTS if available - else if (browserTtsHandler) { - console.log('Falling back to browser TTS.'); - this.activeTTSHandler = browserTtsHandler; - this.usingKokoro = false; - } - // No TTS available - else { - console.error('No TTS system available.'); - this.activeTTSHandler = null; - this.usingKokoro = false; - } - - // Expose the active handler as the global ttsHandler for compatibility - window.ttsHandler = this.activeTTSHandler; - - // Log the active TTS system - if (this.usingKokoro) { - console.log('TTS Factory initialized with Kokoro TTS'); - } else if (this.activeTTSHandler) { - console.log('TTS Factory initialized with browser TTS'); - } else { - console.log('TTS Factory initialized with no available TTS'); - } + applyVoiceSettingsFromPreferences() { + if (!this.ttsHandler || !this.persistenceManager) return; - // Dispatch an event to notify the UI that TTS is ready (or not) - const ttsReadyEvent = new CustomEvent('tts-ready', { - detail: { - available: !!this.activeTTSHandler, - type: this.usingKokoro ? 'kokoro' : (this.activeTTSHandler ? 'browser' : 'none'), - handler: this.activeTTSHandler + const prefs = this.persistenceManager.getAllPreferences(); + if (prefs && prefs.tts) { + if (prefs.tts.voice) { + console.log(`TTS Factory: Setting voice to ${prefs.tts.voice} from preferences`); + // Check if setVoice exists, otherwise try setting through voiceOptions + if (typeof this.ttsHandler.setVoice === 'function') { + this.ttsHandler.setVoice(prefs.tts.voice); + } else if (typeof this.ttsHandler.setVoiceOptions === 'function') { + this.ttsHandler.setVoiceOptions({ voice: prefs.tts.voice }); } - }); - window.dispatchEvent(ttsReadyEvent); + } + + if (prefs.tts.rate !== undefined) { + console.log(`TTS Factory: Setting speech rate to ${prefs.tts.rate} from preferences`); + // Check if setSpeed exists, otherwise try setting through voiceOptions + if (typeof this.ttsHandler.setSpeed === 'function') { + this.ttsHandler.setSpeed(prefs.tts.rate); + } else if (typeof this.ttsHandler.setVoiceOptions === 'function') { + this.ttsHandler.setVoiceOptions({ rate: prefs.tts.rate }); + } + } + + if (prefs.tts.volume !== undefined && typeof this.ttsHandler.setVolume === 'function') { + console.log(`TTS Factory: Setting volume to ${prefs.tts.volume} from preferences`); + this.ttsHandler.setVolume(prefs.tts.volume); + } + } } /** - * Get info about the active TTS system + * Initialize browser TTS + * @param {BrowserTTSHandler} handler - The browser TTS handler + * @param {Function} reportProgress - Progress reporting function + * @returns {Promise} - Resolves with availability status */ - getActiveTTSInfo() { - if (!this.activeTTSHandler) { - return { available: false, type: 'none', name: 'None' }; + async initializeBrowserTTS(handler, reportProgress) { + reportProgress(30, 'Initializing browser TTS'); + const browserAvailable = await handler.initialize(); + + if (browserAvailable) { + this.ttsHandler = handler; + this.dispatchTTSReadyEvent(true, 'browser', handler); + reportProgress(40, 'Browser TTS ready'); + } else { + reportProgress(40, 'Browser TTS not available'); } - return { - available: true, - type: this.usingKokoro ? 'kokoro' : 'browser', - name: this.usingKokoro ? 'Kokoro TTS' : 'Browser TTS' - }; + return browserAvailable; + } + + /** + * Initialize API TTS + * @param {ApiTTSHandler} handler - The API TTS handler + * @param {Function} reportProgress - Progress reporting function + * @returns {Promise} - Resolves with availability status + */ + async initializeApiTTS(handler, reportProgress) { + reportProgress(50, 'Initializing API TTS'); + const apiAvailable = await handler.initialize(); + + if (apiAvailable) { + this.ttsHandler = handler; + this.dispatchTTSReadyEvent(true, 'api', handler); + reportProgress(70, 'API TTS ready'); + } + + return apiAvailable; } /** - * Force switching to a specific TTS system - * @param {string} type - Either 'kokoro' or 'browser' + * Get preferred TTS mode from storage + * @returns {string|null} - Preferred TTS mode or null if not set */ - switchTTS(type) { - if (type === 'kokoro' && kokoroHandler && kokoroHandler.kokoroReady) { - this.activeTTSHandler = kokoroHandler; - this.usingKokoro = true; - window.ttsHandler = this.activeTTSHandler; - console.log('Switched to Kokoro TTS'); - // Dispatch event on switch - const ttsReadyEvent = new CustomEvent('tts-ready', { detail: { available: true, type: 'kokoro', handler: this.activeTTSHandler } }); - window.dispatchEvent(ttsReadyEvent); - return true; - } else if (type === 'browser' && browserTtsHandler) { - this.activeTTSHandler = browserTtsHandler; - this.usingKokoro = false; - window.ttsHandler = this.activeTTSHandler; - console.log('Switched to browser TTS'); - // Dispatch event on switch - const ttsReadyEvent = new CustomEvent('tts-ready', { detail: { available: true, type: 'browser', handler: this.activeTTSHandler } }); - window.dispatchEvent(ttsReadyEvent); - return true; + getPreferredTTSMode() { + // First check persistent settings if available + if (this.persistenceManager) { + const prefs = this.persistenceManager.getAllPreferences(); + if (prefs && prefs.tts && prefs.tts.provider) { + console.log(`TTS Factory: Using preferred TTS mode '${prefs.tts.provider}' from persistence manager`); + return prefs.tts.provider; + } } - console.error(`Failed to switch to ${type} TTS - not available`); - return false; + // Fallback to localStorage if persistence manager is not available + try { + const savedMode = localStorage.getItem('preferred-tts-mode'); + if (savedMode) { + console.log(`TTS Factory: Using preferred TTS mode '${savedMode}' from localStorage`); + return savedMode; + } + } catch (e) { + console.warn('Could not read TTS preference from localStorage'); + } + + // Default to Kokoro if no preference is found + return "kokoro"; + } + + /** + * Set preferred TTS mode in storage + * @param {string} mode - The TTS mode to save as preferred + */ + setPreferredTTSMode(mode) { + // Update in persistence manager if available + if (this.persistenceManager) { + this.persistenceManager.updatePreference('tts', 'provider', mode); + console.log(`TTS Factory: Saved preferred TTS mode '${mode}' to persistence manager`); + } + + // Also save to localStorage as backup + try { + localStorage.setItem('preferred-tts-mode', mode); + } catch (e) { + console.warn('Could not save TTS preference to localStorage'); + } + } + + /** + * Schedule Kokoro initialization during idle time + * @param {Object} kokoroHandler - The Kokoro handler instance + * @param {Function} reportProgress - Progress reporting function + * @returns {Promise} - Resolves with success status + */ + scheduleKokoroInitialization(kokoroHandler, reportProgress) { + // Immediately dispatch the loading started event so tts-player can catch it + window.dispatchEvent(new CustomEvent('kokoro-loading-started')); + + return new Promise((resolve) => { + // Create the initialization function + const startKokoroInit = async () => { + try { + // Initialize Kokoro with progress callback + const kokoroAvailable = await kokoroHandler.initialize((percent, message) => { + // Scale progress to 80-95% range for the TTS module's overall progress + const scaledProgress = 80 + Math.floor(percent * 0.15); + reportProgress(scaledProgress, message || `Loading Kokoro TTS: ${percent}%`); + }); + + // Mark completion + if (kokoroAvailable) { + reportProgress(95, "Kokoro TTS initialized successfully"); + } else { + reportProgress(95, "Kokoro TTS unavailable - using fallback"); + } + + // Always dispatch event to indicate completion status + window.dispatchEvent(new CustomEvent('kokoro-loading-complete', { + detail: { success: kokoroAvailable } + })); + + resolve(kokoroAvailable); + } catch (error) { + console.error('Error initializing Kokoro:', error); + reportProgress(95, 'Kokoro TTS failed to initialize - using fallback'); + + // Dispatch completion event with error information + window.dispatchEvent(new CustomEvent('kokoro-loading-complete', { + detail: { success: false, error: error.message } + })); + + resolve(false); + } + }; + + // Add timeout protection with a reasonable timeout (30 seconds for resource-intensive operations) + const timeoutId = setTimeout(() => { + reportProgress(95, 'Kokoro initialization timed out - using fallback'); + window.dispatchEvent(new CustomEvent('kokoro-loading-complete', { + detail: { success: false, error: "Timeout" } + })); + resolve(false); + }, 30000); // Increased timeout to 30 seconds since model loading is resource intensive + + // Use requestIdleCallback to start initialization during idle time + if (window.requestIdleCallback) { + reportProgress(75, 'Scheduling Kokoro TTS for background loading'); + + window.requestIdleCallback(() => { + startKokoroInit().then(() => clearTimeout(timeoutId)); + }, { timeout: 10000 }); + } else { + reportProgress(75, 'Background loading not available, loading Kokoro normally'); + + // Use a microtask to avoid blocking the UI thread + Promise.resolve().then(() => startKokoroInit().then(() => clearTimeout(timeoutId))); + } + }); + } + + /** + * Dispatch a custom event when TTS is ready + * @param {boolean} available - Whether TTS is available + * @param {string} type - The type of TTS + * @param {Object} handler - The TTS handler object + */ + dispatchTTSReadyEvent(available, type = null, handler = null) { + const event = new CustomEvent('tts-ready', { + detail: { + available, + type, + handler, + enabled: this.ttsEnabled + } + }); + window.dispatchEvent(event); + } + + /** + * Get information about the active TTS system + * @returns {Object} - TTS system info + */ + getActiveTTSInfo() { + if (!this.ttsHandler) { + return { available: false, type: 'none', name: 'None' }; + } + + const id = this.ttsHandler.getId(); + const name = { + 'browser': 'Browser TTS', + 'kokoro': 'Kokoro Neural TTS', + 'api': 'ElevenLabs API TTS' + }[id] || 'Unknown TTS'; + + return { + available: true, + type: id, + name: name + }; + } + + /** + * Switch to a specific TTS handler + * @param {string} type - The handler ID to use + * @returns {boolean} - Success status + */ + switchTTS(type) { + if (!this.handlers[type] || !this.handlers[type].isAvailable()) { + return false; + } + + this.ttsHandler = this.handlers[type]; + this.dispatchTTSReadyEvent(true, type, this.ttsHandler); + + // Update preferred TTS mode + this.setPreferredTTSMode(type); + + return true; + } + + /** + * Speak text using the active TTS handler + * @param {string} text - Text to speak + * @param {Function} callback - Called when speech completes + * @returns {boolean} - True if speech started successfully + */ + speak(text, callback = null) { + if (!this.ttsEnabled || !this.ttsHandler) { + console.warn("TTSFactory: No active TTS handler available or TTS disabled"); + if (callback) callback("No TTS handler"); + return false; + } + + const handlerType = this.ttsHandler.getId(); + console.log(`TTSFactory: Using ${handlerType} handler to speak "${text}"`); + + try { + this.ttsHandler.speak(text, (result) => { + console.log(`TTSFactory: Speech completed using ${handlerType}`, result); + if (callback) callback(result); + }); + return true; + } catch (error) { + console.error('Error speaking:', error); + if (callback) callback(error); + return false; + } + } + + /** + * Stop any ongoing speech + */ + stop() { + if (this.ttsHandler) { + this.ttsHandler.stop(); + } + } + + /** + * Set voice options for the active handler + * @param {Object} options - Voice options + */ + setVoiceOptions(options = {}) { + if (this.ttsHandler && typeof this.ttsHandler.setVoiceOptions === 'function') { + this.ttsHandler.setVoiceOptions(options); + + // Save settings to persistence manager if available + if (this.persistenceManager) { + if (options.voice !== undefined) { + this.persistenceManager.updatePreference('tts', 'voice', options.voice, false); + } + if (options.rate !== undefined) { + this.persistenceManager.updatePreference('tts', 'rate', options.rate, false); + } + if (options.volume !== undefined) { + this.persistenceManager.updatePreference('tts', 'volume', options.volume, false); + } + // Save all changes at once + this.persistenceManager.savePreferences(); + } + } + } + + /** + * Toggle TTS on/off + * @returns {boolean} - New TTS enabled state + */ + toggle() { + this.ttsEnabled = !this.ttsEnabled; + console.log(`TTS Factory: Toggling TTS to ${this.ttsEnabled ? 'enabled' : 'disabled'}`); + + if (!this.ttsEnabled && this.ttsHandler) { + this.ttsHandler.stop(); + } + + // Save the new state to preferences if persistence manager is available + if (this.persistenceManager) { + this.persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled); + console.log(`TTS Factory: Saved enabled state (${this.ttsEnabled}) to persistence manager`); + } + + return this.ttsEnabled; + } + + /** + * Check if TTS is enabled + * @returns {boolean} - Current TTS enabled state + */ + isEnabled() { + return this.ttsEnabled; + } + + /** + * Get available handlers + * @returns {Object} - Map of available handlers + */ + getAvailableHandlers() { + const available = {}; + + Object.entries(this.handlers).forEach(([id, handler]) => { + if (handler.isAvailable()) { + available[id] = handler; + } + }); + + return available; + } + + /** + * Get available voices from active handler + * @returns {Promise} - Array of available voices + */ + async getVoices() { + if (!this.ttsHandler || typeof this.ttsHandler.getVoices !== 'function') { + return []; + } + + try { + return await this.ttsHandler.getVoices(); + } catch (error) { + console.error('Error getting voices:', error); + return []; + } } } -// Create and export singleton instance -export const ttsFactory = new TTSFactory(); +// Create singleton instance +const ttsFactory = new TTSFactory(); -// Keep a reference in window for compatibility with existing code +// Export the factory +export { ttsFactory }; + +// Keep global reference window.ttsFactory = ttsFactory; \ No newline at end of file diff --git a/public/js/tts-handler.js b/public/js/tts-handler.js index cbf251c..8209d14 100644 --- a/public/js/tts-handler.js +++ b/public/js/tts-handler.js @@ -1,414 +1,110 @@ -/** - * Text-to-Speech Handler for AI Interactive Fiction - * Enhanced version with improved voice selection, caching, and playback controls - */ - -export class TTSHandler { - constructor() { - this.enabled = false; - this.speaking = false; - this.paused = false; - this.utterance = null; - this.voiceCache = []; - this.preferredVoice = null; - this.audioCache = new Map(); // Cache for audio segments - this.currentSpeed = 1.0; - this.hasUserActivation = false; - this.permissionError = false; - this.speakQueue = []; - this.isSpeakingFromQueue = false; - - // Flag to track when we're deliberately stopping speech - this.intentionalStop = false; - - // Initialize if speech synthesis is available - if ('speechSynthesis' in window) { - this.synth = window.speechSynthesis; - - // Load voices when they become available - if (this.synth.getVoices().length > 0) { - this.voiceCache = this.synth.getVoices(); - this.selectPreferredVoice(); - } - - this.synth.onvoiceschanged = () => { - this.voiceCache = this.synth.getVoices(); - this.selectPreferredVoice(); - console.log("Voices loaded:", this.voiceCache.length); - }; - - // Disabled by default until user activates it - this.enabled = false; - - // Set up periodic check to detect and fix stuck speech - setInterval(() => { - // If we think we're speaking but the browser doesn't, reset state - if (this.speaking && !this.synth.speaking && !this.isSpeakingFromQueue) { - console.log("Detected stuck speech state, resetting"); - this.speaking = false; - - // Try to continue the queue if there are more items - if (this.speakQueue.length > 0) { - this.processSpeakQueue(); - } - } - }, 1000); - } else { - console.warn("Text-to-speech functionality not available in this browser."); - } - } - - /** - * Select the preferred voice based on language and quality - */ - selectPreferredVoice() { - // Prefer high-quality voices - ordered by preference - const preferredVoiceNames = [ - "Google UK English Female", - "Microsoft Hazel Desktop", - "Microsoft Susan", - "Daniel", - "Karen" - ]; - - // Debug: Print all available voices - console.log("Available voices:", this.voiceCache.map(v => v.name + " (" + v.lang + ")").join(", ")); - - // Try to find one of our preferred voices - for (const name of preferredVoiceNames) { - const voice = this.voiceCache.find(v => v.name === name); - if (voice) { - this.preferredVoice = voice; - console.log("Selected preferred voice:", name); - return; - } - } - - // Fall back to any English voice if preferred not found - const englishVoice = this.voiceCache.find(v => v.lang.startsWith('en')); - if (englishVoice) { - this.preferredVoice = englishVoice; - console.log("Selected English voice:", englishVoice.name); - return; - } - - // Last resort: use the first available voice - if (this.voiceCache.length > 0) { - this.preferredVoice = this.voiceCache[0]; - console.log("Selected fallback voice:", this.voiceCache[0].name); - } - } - - /** - * Toggle TTS functionality on/off - * @returns {boolean} New state of TTS (enabled/disabled) - */ - toggle() { - if (!this.synth) return false; - - // Set user activation flag when toggle is called - this.hasUserActivation = true; - - // Clear permission error on toggle - this.permissionError = false; - - this.enabled = !this.enabled; - console.log("TTS toggled:", this.enabled ? "ON" : "OFF"); - - // Stop any ongoing speech when disabling - if (!this.enabled && this.speaking) { - this.stop(); - } - - // Try a test utterance to request permissions - if (this.enabled) { - try { - // Reset any current utterance first - this.synth.cancel(); - this.speakQueue = []; - this.isSpeakingFromQueue = false; - - // Create a silent utterance to trigger permission request - const testUtterance = new SpeechSynthesisUtterance("Hello"); - testUtterance.volume = 0.05; // Very quiet but not silent to ensure it works - testUtterance.rate = 1.0; - - // Handle any errors that might occur - testUtterance.onerror = (event) => { - console.warn("Permission error for TTS:", event); - if (event.error === "not-allowed") { - this.permissionError = true; - this.enabled = false; - alert("Text-to-speech was blocked by your browser. Please allow speech in your browser settings."); - } - }; - - // Try to speak the test utterance - this.synth.speak(testUtterance); - } catch (e) { - console.error("Failed to initialize TTS:", e); - } - } - - return this.enabled; - } - - /** - * Set the speech rate/speed - * @param {number} speed - Speed multiplier (0.1 to 2.0) - */ - setSpeed(speed) { - this.currentSpeed = Math.max(0.1, Math.min(2.0, speed)); - } - - /** - * Process text for better speech synthesis - * @param {string} text - Text to process - * @returns {string} - Processed text - */ - processTextForSpeech(text) { - if (!text) return ""; - - // Remove markdown/formatting that would sound strange when read - text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); // Bold - text = text.replace(/\*([^*]+)\*/g, '$1'); // Italic - text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // Links - - // Clean up any HTML tags - text = text.replace(/<[^>]+>/g, ''); - - return text; - } - - /** - * Split text into sentences for better speech handling - * @param {string} text - Text to split - * @returns {string[]} - Array of sentences - */ - splitIntoSentences(text) { - if (!text) return []; - - // Split by sentence terminators, keeping the terminator with the sentence - const sentenceRegex = /[^.!?]+[.!?]+/g; - const sentences = text.match(sentenceRegex) || [text]; - - // If we have very long sentences, break them up by commas too - return sentences.reduce((result, sentence) => { - if (sentence.length > 150 && sentence.includes(',')) { - // Split long sentences at commas - const parts = sentence.split(/,\s*/); - for (let i = 0; i < parts.length - 1; i++) { - result.push(parts[i] + ','); - } - result.push(parts[parts.length - 1]); - return result; - } - result.push(sentence); - return result; - }, []); - } - - /** - * Speak a single utterance with proper configuration - * @param {string} text - Text to speak - * @param {function} onEndCallback - Callback to execute when finished - * @private - */ - speakUtterance(text, onEndCallback) { - if (!text || text.trim() === '') { - if (onEndCallback) onEndCallback(); - this.processSpeakQueue(); - return; - } - - try { - const utterance = new SpeechSynthesisUtterance(text); - - if (this.preferredVoice) { - utterance.voice = this.preferredVoice; - console.log("Using voice:", this.preferredVoice.name); - } - - utterance.rate = this.currentSpeed; - utterance.pitch = 1.0; - utterance.volume = 1.0; - - utterance.onstart = () => { - this.speaking = true; - console.log("TTS started speaking:", text.substring(0, 30) + "..."); - }; - - utterance.onend = () => { - console.log("TTS finished speaking utterance"); - if (onEndCallback) onEndCallback(); - this.processSpeakQueue(); - }; - - utterance.onerror = (event) => { - // Don't treat interrupted errors as real errors when we're deliberately stopping - if (event.error === "interrupted" && this.intentionalStop) { - console.log("Speech intentionally interrupted"); - } else { - console.error("Speech synthesis error:", event); - if (event.error === "not-allowed") { - this.permissionError = true; - this.enabled = false; - } - } - - if (onEndCallback) onEndCallback(); - this.processSpeakQueue(); - }; - - // Actually speak - this.synth.speak(utterance); - - // Workaround for Chrome bug where speech synthesis gets stuck - if (!this.synth.speaking) { - this.synth.pause(); - this.synth.resume(); - } - - } catch (e) { - console.error("Error in speakUtterance:", e); - if (onEndCallback) onEndCallback(); - this.processSpeakQueue(); - } - } - - /** - * Process the next item in the speak queue - * @private - */ - processSpeakQueue() { - if (this.speakQueue.length === 0) { - this.isSpeakingFromQueue = false; - this.speaking = false; - return; - } - - // Skip processing if we're already speaking (prevent overlapping sentences) - if (this.synth.speaking) { - setTimeout(() => this.processSpeakQueue(), 100); - return; - } - - this.isSpeakingFromQueue = true; - const queueItem = this.speakQueue.shift(); - - console.log(`Speaking queue item (${this.speakQueue.length} remaining):`, queueItem.text.substring(0, 30) + "..."); - - this.speakUtterance(queueItem.text, queueItem.callback); - } - - /** - * Speak the provided text by queueing sentences - * @param {string} text - Text to be spoken - * @param {function} onEndCallback - Callback when all speech ends - */ - speak(text, onEndCallback = null) { - if (!this.synth || !this.enabled || !text) { - if (onEndCallback) onEndCallback(); - return; - } - - // Don't attempt to speak if there's been a permission error - if (this.permissionError) { - console.warn("Not attempting to speak due to permission error"); - if (onEndCallback) onEndCallback(); - return; - } - - // Don't attempt to speak without user activation - if (!this.hasUserActivation) { - console.warn("Not attempting to speak because there hasn't been user interaction yet"); - if (onEndCallback) onEndCallback(); - return; - } - - // Process text for better speech - const processedText = this.processTextForSpeech(text); - console.log("TTS attempting to speak:", processedText.substring(0, 50) + "..."); - - // Stop any existing speech - this.stop(); - - // Split into sentences for better handling - const sentences = this.splitIntoSentences(processedText); - - // Last sentence gets the callback - for (let i = 0; i < sentences.length; i++) { - this.speakQueue.push({ - text: sentences[i], - callback: i === sentences.length - 1 ? onEndCallback : null - }); - } - - // Start processing the queue if not already processing - if (!this.isSpeakingFromQueue) { - this.processSpeakQueue(); - } - } - - /** - * Pause the current speech - */ - pause() { - if (!this.synth || !this.speaking) return; - - this.synth.pause(); - this.paused = true; - } - - /** - * Resume paused speech - */ - resume() { - if (!this.synth || !this.paused) return; - - this.synth.resume(); - this.paused = false; - } - - /** - * Stop the current speech - */ - stop() { - if (!this.synth) return; - - // Set flag to indicate this is an intentional stop before canceling - this.intentionalStop = true; - - // Cancel any current speech synthesis - this.synth.cancel(); - - // Reset state - this.speaking = false; - this.paused = false; - this.utterance = null; - this.speakQueue = []; - this.isSpeakingFromQueue = false; - - // Reset the intentional stop flag after a short delay - setTimeout(() => { - this.intentionalStop = false; - }, 100); - } - - /** - * Check if TTS is currently active/enabled - */ - isEnabled() { - return this.enabled && !this.permissionError; - } - - /** - * Check if speech is currently in progress - */ - isSpeaking() { - return this.speaking; - } -} - -// Create and export a singleton instance -export const browserTtsHandler = new TTSHandler(); \ No newline at end of file +/** + * TTS Handler Base Class + * Abstract base class defining the interface for all TTS handlers + */ +export class TTSHandler { + constructor() { + this.voiceOptions = {}; + this.isReady = false; + + // Set up event dispatcher + this.eventTarget = document.createElement('div'); + } + + /** + * Get handler ID + * @returns {string} - Handler identifier + */ + getId() { + throw new Error('getId() must be implemented by subclass'); + } + + /** + * Initialize the TTS handler + * @param {Function} progressCallback - Optional progress callback + * @returns {Promise} - Resolves with success status + */ + async initialize(progressCallback = null) { + throw new Error('initialize() must be implemented by subclass'); + } + + /** + * Check if this TTS handler is available + * @returns {boolean} - True if handler is ready to use + */ + isAvailable() { + return this.isReady; + } + + /** + * Check if voice is currently speaking + * @returns {boolean} - True if speaking + */ + isSpeaking() { + return false; // Default implementation + } + + /** + * Speak text using this handler + * @param {string} text - The text to speak + * @param {Function} callback - Optional callback when speech completes + */ + speak(text, callback = null) { + throw new Error('speak() must be implemented by subclass'); + } + + /** + * Stop speech + */ + stop() { + throw new Error('stop() must be implemented by subclass'); + } + + /** + * Set voice options + * @param {Object} options - Voice options + */ + setVoiceOptions(options = {}) { + // Default implementation merges options + this.voiceOptions = { ...this.voiceOptions, ...options }; + } + + /** + * Get available voices + * @returns {Promise} - Resolves with array of voice objects + */ + async getVoices() { + return []; + } + + /** + * Dispatch a custom event + * @param {string} eventName - Name of the event + * @param {Object} detail - Event details + */ + dispatchEvent(eventName, detail = {}) { + const event = new CustomEvent(eventName, { + detail: { handlerId: this.getId(), ...detail }, + bubbles: true + }); + this.eventTarget.dispatchEvent(event); + } + + /** + * Add event listener + * @param {string} eventName - Name of the event + * @param {Function} callback - Event handler function + */ + addEventListener(eventName, callback) { + this.eventTarget.addEventListener(eventName, callback); + } + + /** + * Remove event listener + * @param {string} eventName - Name of the event + * @param {Function} callback - Event handler function + */ + removeEventListener(eventName, callback) { + this.eventTarget.removeEventListener(eventName, callback); + } +} diff --git a/public/js/tts-player.js b/public/js/tts-player.js index 1c3bbbd..8bc3978 100644 --- a/public/js/tts-player.js +++ b/public/js/tts-player.js @@ -1,137 +1,269 @@ /** - * TTS Player Module - * Manages text-to-speech playback integration with animation queue. + * TTS Player Module for AI Interactive Fiction + * Handles Text-to-Speech functionality with resource-aware loading and progress reporting */ -export class TtsPlayer { - /** - * Create a new TtsPlayer - * @param {Object} config - Configuration options - * @param {string} config.apiKey - API key for TTS service (if applicable) - * @param {Object} config.animationQueue - AnimationQueue instance for synchronization - */ - constructor(config = {}) { - this.config = config; - this.animationQueue = config.animationQueue; - this.ttsHandler = null; - this.enabled = false; // Start with TTS disabled by default - this.currentAudio = null; +import { BaseModule, ModuleEvent } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; - // Bind methods to ensure 'this' context - this.speak = this.speak.bind(this); - this.stop = this.stop.bind(this); +class TTSPlayerModule extends BaseModule { + constructor() { + super('tts', 'Text-to-Speech'); + this.ttsFactory = null; + this.isInitialized = false; + this.kokoroLoadingPromise = null; + this.kokoroLoadingStarted = false; } - + /** - * Set the TTS handler - * @param {Object} ttsHandler - The TTS handler instance + * Load module dependencies + * @returns {Promise} - Resolves when dependencies are loaded */ - setTtsHandler(ttsHandler) { - if (!ttsHandler) { - console.warn("TtsPlayer: No valid TTS handler provided."); - return; - } - - console.log("TtsPlayer: Handler set to", ttsHandler.constructor.name); - this.ttsHandler = ttsHandler; - - // Make sure the window.ttsHandler is also set for global access - if (!window.ttsHandler) { - window.ttsHandler = ttsHandler; + async loadDependencies() { + try { + // Import the TTS Factory module + const { ttsFactory } = await import('./tts-factory.js'); + this.ttsFactory = ttsFactory; + this.reportProgress(20, "TTS Factory loaded"); + + // Set up event listeners + window.addEventListener('tts-ready', this.handleTTSReadyEvent.bind(this)); + + // Create a Promise that resolves when Kokoro is loaded + this.kokoroLoadingPromise = new Promise(resolve => { + // Listen for when Kokoro starts loading + window.addEventListener('kokoro-loading-started', () => { + this.kokoroLoadingStarted = true; + this.reportProgress(50, "Loading Kokoro TTS"); + }); + + // Listen for when Kokoro completes loading + window.addEventListener('kokoro-loading-complete', (event) => { + // Check if loading was successful from the event details + if (event.detail && event.detail.success === false) { + this.reportProgress(95, "Kokoro TTS failed to load - using fallback"); + console.warn("Kokoro failed to load:", event.detail?.error || "unknown error"); + } else { + this.reportProgress(95, "Kokoro TTS loaded"); + } + resolve(); + }); + }); + + return true; + } catch (error) { + console.error("Error loading TTS dependencies:", error); + return false; } } - + /** - * Enable or disable TTS - * @param {boolean} enabled - Whether TTS should be enabled + * Initialize the module + * @returns {Promise} - Resolves with success status */ - setEnabled(enabled = true) { - this.enabled = enabled; - console.log(`TtsPlayer: TTS ${enabled ? 'enabled' : 'disabled'}`); - - // If disabling while audio is playing, stop it - if (!enabled && this.currentAudio) { - this.stop(); - } - - // Also set the handler's state if available - if (this.ttsHandler && typeof this.ttsHandler.setEnabled === 'function') { - this.ttsHandler.setEnabled(enabled); - } else if (window.ttsHandler && typeof window.ttsHandler.setEnabled === 'function') { - window.ttsHandler.setEnabled(enabled); + async initialize() { + try { + // Initialize TTS Factory + await this.ttsFactory.constructor.initializeInterface((percent, message) => { + // Scale to 20-90% of our progress range + const scaledPercent = 20 + (percent * 0.7); + this.reportProgress(scaledPercent, message); + }); + + // IMPORTANT: Always wait for Kokoro's loading promise to resolve + this.reportProgress(90, "Waiting for Kokoro TTS to complete loading"); + + // Wait for the Kokoro loading promise to complete with a timeout + try { + // Add a timeout to prevent waiting forever + const timeoutPromise = new Promise(resolve => setTimeout(() => { + console.log("TTS Player: Kokoro loading timed out, continuing without Kokoro"); + resolve(false); + }, 10000)); // 10 second timeout + + // Race between normal completion and timeout + await Promise.race([this.kokoroLoadingPromise, timeoutPromise]); + + this.reportProgress(95, "Kokoro TTS loading completed or timed out"); + } catch (err) { + console.warn("TTS Player: Error waiting for Kokoro:", err); + this.reportProgress(95, "Error waiting for Kokoro, continuing anyway"); + } + + this.isInitialized = true; + + // Final status check + const ttsInfo = this.ttsFactory.getActiveTTSInfo(); + if (ttsInfo.available) { + this.reportProgress(100, `TTS Player initialized using ${ttsInfo.name}`); + return true; + } else { + this.reportProgress(100, "TTS initialization complete but no voices available"); + return true; // Still consider this a success, just with no voices + } + } catch (error) { + console.error("Error initializing TTS Player:", error); + this.reportProgress(100, "TTS initialization failed, continuing without TTS"); + this.isInitialized = true; // Mark as initialized anyway to not block other modules + return true; // Return true to not block the application } } - + /** - * Toggle TTS state - * @returns {boolean} The new enabled state + * Handle TTS ready event from the factory + * @param {CustomEvent} event - The TTS ready event + */ + handleTTSReadyEvent(event) { + const { available, type } = event.detail; + + if (available && type) { + this.reportProgress(95, `TTS system ready: ${type}`); + } else { + this.reportProgress(95, "No TTS system available"); + } + } + + // Public API methods + + /** + * Get information about the active TTS system + * @returns {Object} - TTS system info + */ + getTTSInfo() { + if (!this.ttsFactory) return { available: false, type: 'none', name: 'None' }; + return this.ttsFactory.getActiveTTSInfo(); + } + + /** + * Toggle TTS functionality on/off + * @returns {boolean} - New TTS enabled state */ toggle() { - this.setEnabled(!this.enabled); - return this.enabled; + if (!this.ttsFactory) return false; + return this.ttsFactory.toggle(); } - + /** - * Check if TTS is enabled - * @returns {boolean} Whether TTS is enabled + * Speak text using the active TTS system + * @param {string} text - Text to speak + * @param {Function} callback - Called when speech completes */ - isEnabled() { - return this.enabled; - } - - /** - * Speak text - * @param {string} text - The text to speak - */ - speak(text) { - if (!this.enabled || !text) return; - - // Stop any current speech - this.stop(); - - console.log(`TtsPlayer: Speaking - "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); - - // Try to use our handler first - if (this.ttsHandler && typeof this.ttsHandler.speak === 'function') { - this.ttsHandler.speak(text); - } - // Fall back to window.ttsHandler if available - else if (window.ttsHandler && typeof window.ttsHandler.speak === 'function') { - window.ttsHandler.speak(text); - } - else { - console.warn("TtsPlayer: No TTS handler available to speak text"); + speak(text, callback) { + if (!this.ttsFactory) { + console.warn("TTS Factory not available for speak"); + if (callback) callback("TTS not available"); + return; } + + console.log(`TTS Player speaking: "${text}"`); + this.ttsFactory.speak(text, (result) => { + console.log("TTS Player speak complete", result); + if (callback) callback(result); + }); } - + /** - * Stop current speech + * Stop any ongoing speech */ stop() { - // Try to use our handler first - if (this.ttsHandler && typeof this.ttsHandler.stop === 'function') { - this.ttsHandler.stop(); - } - // Fall back to window.ttsHandler if available - else if (window.ttsHandler && typeof window.ttsHandler.stop === 'function') { - window.ttsHandler.stop(); + if (this.ttsFactory) { + this.ttsFactory.stop(); } } - + /** - * Fast forward current speech (may skip or speed up) + * Set voice options for the active TTS system + * @param {Object} options - Voice options */ - fastForward() { - // Try to use our handler first - if (this.ttsHandler && typeof this.ttsHandler.fastForward === 'function') { - this.ttsHandler.fastForward(); + setVoiceOptions(options) { + if (this.ttsFactory) { + this.ttsFactory.setVoiceOptions(options); } - // Fall back to window.ttsHandler if available - else if (window.ttsHandler && typeof window.ttsHandler.fastForward === 'function') { - window.ttsHandler.fastForward(); - } - // If no fastForward method, just stop the speech - else { - this.stop(); + } + + /** + * Set speech rate/speed + * @param {number} speed - Speech rate (0.5-2.0) + */ + setSpeed(speed) { + this.setVoiceOptions({ rate: speed }); + } + + /** + * Set the volume for speech + * @param {number} volume - Volume level (0.0-1.0) + */ + setVolume(volume) { + this.setVoiceOptions({ volume: volume }); + } + + /** + * Set the voice for speech + * @param {string} voice - Voice identifier + */ + setVoice(voice) { + this.setVoiceOptions({ voice: voice }); + } + + /** + * Switch to a specific TTS system + * @param {string} type - The TTS system to use ('kokoro', 'browser', or 'api') + * @returns {boolean} - Success status + */ + switchTTS(type) { + if (!this.ttsFactory) return false; + const result = this.ttsFactory.switchTTS(type); + + // If the switch was successful, refresh the voice list + if (result) { + // Notify listeners that the TTS system changed + window.dispatchEvent(new CustomEvent('tts-system-changed', { + detail: { + type, + info: this.getTTSInfo() + } + })); } + + return result; + } + + /** + * Get available TTS systems + * @returns {Array} - Array of available TTS system IDs + */ + getAvailableSystems() { + if (!this.ttsFactory) return []; + const handlers = this.ttsFactory.getAvailableHandlers(); + return Object.keys(handlers); + } + + /** + * Get available voices for the active TTS system + * @returns {Promise} - Array of voice objects + */ + async getVoices() { + if (!this.ttsFactory) return []; + return this.ttsFactory.getVoices(); + } + + /** + * Is TTS enabled currently + * @returns {boolean} - Whether TTS is enabled + */ + isEnabled() { + if (!this.ttsFactory) return false; + return this.ttsFactory.isEnabled(); } } + +// Create the singleton instance +const TTSPlayer = new TTSPlayerModule(); + +// Register with the module registry +moduleRegistry.register(TTSPlayer); + +// Export the module +export { TTSPlayer }; + +// Keep a reference in window for loader system +window.TTSPlayer = TTSPlayer; diff --git a/public/js/ui-controller.js b/public/js/ui-controller.js index 5b2d90d..ef5246f 100644 --- a/public/js/ui-controller.js +++ b/public/js/ui-controller.js @@ -1,441 +1,445 @@ -/** - * UiController Module - * Manages user interface interactions and updates UI elements. - */ -export class UiController { - /** - * Create a new UiController - * @param {Object} config - Configuration options - * @param {Object} config.animationQueue - The AnimationQueue instance - * @param {Object} config.ttsPlayer - The TtsPlayer instance - * @param {Object} config.inputHandler - The InputHandler instance - * @param {Object} config.socketClient - The SocketClient instance (or rely on callbacks) - * @param {HTMLElement} config.commandHistoryContainerElement - The command history container - * @param {HTMLElement} config.storyContainerElement - The story container - * @param {HTMLElement} config.speedSliderElement - The speed slider element - * @param {HTMLElement} config.rewindButtonElement - The rewind button element - * @param {HTMLElement} config.saveButtonElement - The save button element - * @param {HTMLElement} config.loadButtonElement - The load button element - * @param {HTMLElement} config.speechButtonElement - The speech button element - * @param {HTMLElement} config.speedResetElement - The speed reset button element - * @param {Object} config.translations - Translations object - * @param {string} config.locale - Locale string - */ - constructor(config = {}) { - // Store dependencies - this.animationQueue = config.animationQueue; - this.ttsPlayer = config.ttsPlayer; // Handles enabling/disabling TTS via its own logic - this.inputHandler = config.inputHandler; // Needed for focus, suggestions? - this.socketClient = config.socketClient; // Direct access or use callbacks +import { BaseModule } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; +import { ModuleEvent } from './base-module.js'; - // Callbacks for actions (to be set by AnimatedFiction) - this.onRestartRequest = null; - this.onSaveRequest = null; - this.onLoadRequest = null; - - // Active TTS handler (set via setTtsHandler) +class UIController extends BaseModule { + constructor() { + super('ui-controller'); + + // Declare dependencies on TTS, animation-queue, and our new UI modules + this.dependencies = ['tts', 'animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects']; + + // 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; + + // Bind methods that use 'this' internally or are used as callbacks/event handlers + this.initialize = this.initialize.bind(this); // Bind initialize as it calls dispatchEvent + this.handleCommand = this.handleCommand.bind(this); // Bind event handler + this.displayText = this.displayText.bind(this); // Bind if passed as callback + this.setupBookInterface = this.setupBookInterface.bind(this); + this.applyBookSizing = this.applyBookSizing.bind(this); + this.setupEventListeners = this.setupEventListeners.bind(this); + this.setupMainUI = this.setupMainUI.bind(this); + this.initializeTextBuffer = this.initializeTextBuffer.bind(this); + this.showUI = this.showUI.bind(this); + this.hideUI = this.hideUI.bind(this); + this.clearDisplay = this.clearDisplay.bind(this); + this.sendCommand = this.sendCommand.bind(this); + this.updateButtonStates = this.updateButtonStates.bind(this); - // UI elements - this.speedSlider = config.speedSliderElement || document.getElementById('speed'); - this.commandHistoryContainer = config.commandHistoryContainerElement; // Added - this.storyContainer = config.storyContainerElement; // Added - this.rewindButton = config.rewindButtonElement || document.getElementById('rewind'); - this.saveButton = config.saveButtonElement || document.getElementById('save'); - this.loadButton = config.loadButtonElement || document.getElementById('reload'); - this.speechButton = config.speechButtonElement || document.getElementById('speech'); - this.speedReset = config.speedResetElement || document.getElementById('speed_reset'); - - // Translations - this.translations = config.translations || {}; - this.locale = config.locale || 'en-us'; - - // Initial UI state - this.updateButtonStates({ started: false, canLoad: false }); // Start with buttons disabled - this.updateSpeechButtonAvailability(false); // Start with speech disabled + // Store a bound version of dispatchEvent for use in methods + this._dispatchModuleEvent = (name, detail) => { + document.dispatchEvent(new CustomEvent(name, { + detail: { moduleId: this.id, ...detail }, + bubbles: true + })); + }; } - /** - * Set up event listeners - */ - setupEventListeners() { - // Speed slider - if (this.speedSlider) { - this.speedSlider.addEventListener('input', this.handleSpeedChange.bind(this)); - } + async initialize() { + this.reportProgress(0, 'Initializing UI Controller'); - // Speed reset button - if (this.speedReset) { - this.speedReset.addEventListener('click', this.handleSpeedReset.bind(this)); - } - - // Rewind button - if (this.rewindButton) { - this.rewindButton.addEventListener('click', this.handleRewindClick.bind(this)); - } - - // Save button - if (this.saveButton) { - this.saveButton.addEventListener('click', this.handleSaveClick.bind(this)); - } - - // Load button - if (this.loadButton) { - this.loadButton.addEventListener('click', this.handleLoadClick.bind(this)); - } - - // Speech button - if (this.speechButton) { - this.speechButton.addEventListener('click', this.handleSpeechToggle.bind(this)); - } - - // Fast forward (spacebar or click on right page) - window.addEventListener('keydown', (event) => { - if (event.code === 'Space') { - this.handleFastForward(); + try { + this.reportProgress(20, 'Setting up book interface'); + + // Set up book interface + this.setupBookInterface(); + + this.reportProgress(30, 'Setting up UI components'); + + // Get module references + this.displayHandler = moduleRegistry.getModule('ui-display-handler'); + this.inputHandler = moduleRegistry.getModule('ui-input-handler'); + this.effects = moduleRegistry.getModule('ui-effects'); + + // Get additional dependencies + this.textBuffer = moduleRegistry.getModule('text-buffer'); + this.ttsHandler = moduleRegistry.getModule('tts'); + this.socketClient = moduleRegistry.getModule('socket-client'); + this.animationQueue = moduleRegistry.getModule('animation-queue'); + + if (!this.displayHandler || !this.inputHandler || !this.effects) { + console.error('UI Controller: Required UI modules not found'); + return false; } + + this.reportProgress(50, 'Setting up event listeners'); + + // Set up event listeners between components + this.setupEventListeners(); + + this.reportProgress(80, 'Finalizing UI initialization'); + + // Initialize main UI container + await this.setupMainUI(); + + // Initialize text buffer handler + this.initializeTextBuffer(); + + this.isReady = true; + this.isVisible = true; + this.reportProgress(100, 'UI Controller ready'); + + // Start ambient effects + this.effects.startAmbientEffects(); + + // Use the DOM event API directly instead of this.dispatchEvent + this._dispatchModuleEvent('ui:ready', { controller: this }); + + 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 + window.addEventListener('resize', () => this.applyBookSizing()); + } + + applyBookSizing() { + // Apply book sizing based on viewport dimensions + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const aspectRatio = viewportWidth / viewportHeight; + + document.documentElement.style.setProperty('--viewport-aspect-ratio', aspectRatio); + + const maxBookHeight = viewportHeight * 0.9; + document.documentElement.style.setProperty('--book-height', `${maxBookHeight}px`); + + const bookWidth = maxBookHeight * Math.min(aspectRatio, 1.613); + document.documentElement.style.setProperty('--book-width', `${bookWidth}px`); + } + + setupEventListeners() { + // Listen for command events from input handler - use arrow function to preserve context + document.addEventListener('ui:command', (event) => { + this.handleCommand(event.detail); }); - document.getElementById('page_right')?.addEventListener('click', this.handleFastForward.bind(this)); + // Listen for text display events - use arrow function to preserve context + document.addEventListener('ui:text:complete', () => { + // Use the DOM event API directly + this._dispatchModuleEvent('ui:ready:for:next', {}); + }); - // Window resize - window.addEventListener('resize', this.handleWindowResize.bind(this)); - } - - /** - * Handle speed slider change - * @param {Event} event - The input event - */ - handleSpeedChange(event) { - if (!this.animationQueue) return; + // Listen for socket connection events + document.addEventListener('socket:connected', () => { + console.log('UI Controller: Socket connected'); + }); - const value = parseFloat(event.target.value); - const speed = Math.pow(100.0 - value, 3) / 10000 * 10 + 0.01; - this.animationQueue.setSpeed(speed); - } - - /** - * Handle speed reset button click - */ - handleSpeedReset() { - if (!this.speedSlider || !this.animationQueue) return; + document.addEventListener('socket:disconnected', () => { + console.log('UI Controller: Socket disconnected'); + }); - this.speedSlider.value = 50; - const speed = Math.pow(100.0 - 50, 3) / 10000 * 10 + 0.01; - this.animationQueue.setSpeed(speed); - } - - /** - * Handle rewind button click - */ - handleRewindClick() { - if (this.rewindButton.getAttribute('disabled') === 'disabled') { - return; + // Handle speed reset + const speedReset = document.getElementById('speed_reset'); + if (speedReset) { + speedReset.addEventListener('click', (e) => { + e.preventDefault(); + const speedSlider = document.getElementById('speed'); + if (speedSlider) { + speedSlider.value = 50; + if (this.animationQueue) { + this.animationQueue.setSpeed(1.0); + } + } + }); } - // Use localized confirm message if available - const confirmMsg = this.translations[this.locale]?.confirm_restart || 'Are you sure you want to restart the game? All progress will be lost.'; - if (confirm(confirmMsg)) { - if (this.onRestartRequest) { - this.onRestartRequest(); - } else { - console.warn("UiController: onRestartRequest callback not set."); - } - } - } - - /** - * Handle save button click - */ - handleSaveClick() { - if (this.saveButton.getAttribute('disabled') === 'disabled') { - return; - } - if (this.onSaveRequest) { - this.onSaveRequest(); - } else { - console.warn("UiController: onSaveRequest callback not set."); - } - } - - /** - * Handle load button click - */ - handleLoadClick() { - if (this.loadButton.getAttribute('disabled') === 'disabled') { - return; - } - if (this.onLoadRequest) { - this.onLoadRequest(); - } else { - console.warn("UiController: onLoadRequest callback not set."); - } - } - - /** - * Handle speech toggle button click - */ - handleSpeechToggle() { - if (!this.ttsHandler) { - console.warn("UiController: ttsHandler not set. Cannot toggle speech."); - // Attempt to use ttsPlayer as fallback if needed, but prefer ttsHandler - if (this.ttsPlayer && this.speechButton.getAttribute('disabled') !== 'disabled') { - const enabled = this.ttsPlayer.toggle(); - this.updateSpeechButtonStyling(enabled); - } - return; - } - - if (this.speechButton.getAttribute('disabled') === 'disabled') { - return; - } - - // Ensure AudioContext is resumed on user interaction if using Kokoro - if (window.ttsFactory && window.ttsFactory.usingKokoro && this.ttsHandler.audioContext && this.ttsHandler.audioContext.state === 'suspended') { - this.ttsHandler.audioContext.resume().catch(err => console.error('Error resuming AudioContext on click:', err)); - } - - // Set user activation flag for the handler - this.ttsHandler.hasUserActivation = true; - const enabled = this.ttsHandler.toggle(); - this.updateSpeechButtonStyling(enabled); // Update visual style - - if (enabled) { - // Speak the last narrative if speech was just enabled and story container is available - if (this.storyContainer) { - const lastNarrative = this.storyContainer.lastElementChild; - if (lastNarrative && lastNarrative.classList.contains('narrative')) { // Check if it's narrative text - console.log("Speaking last narrative on toggle"); - // Use a slight delay to ensure audio context is resumed - setTimeout(() => this.ttsHandler.speak(lastNarrative.textContent), 50); + + // Handle speed slider change for animation speed + const speedSlider = document.getElementById('speed'); + if (speedSlider) { + speedSlider.addEventListener('input', (e) => { + if (this.animationQueue) { + // Convert slider value (0-100) to animation speed + // Using formula from Documentation.md: lower values = slower speed + const value = parseInt(e.target.value); + const speed = Math.pow(100.0 - value, 3) / 10000 * 10 + 0.01; + this.animationQueue.setSpeed(speed); + console.log(`UI Controller: Animation speed set to ${speed.toFixed(3)}`); + + // Save to persistence manager if available + if (window.PersistenceManager) { + window.PersistenceManager.updatePreference('animation', 'speed', value); + } + } + }); + + // Set initial speed from persistence manager if available + if (window.PersistenceManager) { + const savedSpeed = window.PersistenceManager.getPreference('animation', 'speed', 50); + speedSlider.value = savedSpeed; + // Apply initial speed + if (this.animationQueue) { + const speed = Math.pow(100.0 - savedSpeed, 3) / 10000 * 10 + 0.01; + this.animationQueue.setSpeed(speed); } } - } else { - // If disabling, ensure speech stops - this.ttsHandler.stop(); - } - } - - /** - * Handle fast forward (spacebar or click) - */ - handleFastForward() { - if (!this.animationQueue) return; - - this.animationQueue.fastForward(); - } - - /** - * Handle window resize - */ - handleWindowResize() { - this.updateBookDimensions(); - this.updateParagraphHeight(); - } - - /** - * Sets the active TTS handler. - * @param {object} handler - The TTS handler instance (e.g., KokoroHandler, BrowserTtsHandler). - */ - setTtsHandler(handler) { - this.ttsHandler = handler; - console.log("UiController: TTS Handler set.", handler); - // Update button state based on the new handler's status - this.updateSpeechButtonStyling(this.ttsHandler ? this.ttsHandler.isEnabled() : false); - } - - /** - * Update the book dimensions based on viewport size - */ - updateBookDimensions() { - const vw = window.innerWidth; - const vh = window.innerHeight; - const viewportAspectRatio = vw / vh; - const imageAspectRatio = 2727 / 1691; - - let bookWidth, bookHeight; - - if (viewportAspectRatio > imageAspectRatio) { - bookWidth = vh * imageAspectRatio; - bookHeight = vh; - } else { - bookWidth = vw; - bookHeight = vw / imageAspectRatio; } - document.documentElement.style.setProperty('--book-width', `${bookWidth}px`); - document.documentElement.style.setProperty('--book-height', `${bookHeight}px`); - - // Setting a CSS variable that will be either vw or vh depending on the viewport aspect ratio - document.documentElement.style.setProperty( - "--viewport-dimension", - viewportAspectRatio > imageAspectRatio ? 'vw' : 'vh' - ); - - document.documentElement.style.setProperty('--viewport-aspect-ratio', viewportAspectRatio); - - const story = document.getElementById("story"); - if (story) { - const paddingTop = window.getComputedStyle(story).paddingTop; - const paddingBottom = window.getComputedStyle(story).paddingBottom; - document.documentElement.style.setProperty('--story-line-height', (story.clientHeight - paddingTop - paddingBottom) / 28); + // Handle speech toggle with proper state management + const speechToggle = document.getElementById('speech'); + if (speechToggle && this.ttsHandler) { + // Remove disabled attribute to make it clickable + speechToggle.removeAttribute('disabled'); + + speechToggle.addEventListener('click', (e) => { + e.preventDefault(); + console.log('Speech toggle clicked'); + + // Toggle TTS state + if (this.ttsHandler && typeof this.ttsHandler.toggle === 'function') { + this.ttsEnabled = this.ttsHandler.toggle(); + + // Update button text + speechToggle.textContent = this.ttsEnabled ? 'mute' : 'speech'; + + // Save preference if persistence manager is available + const persistenceManager = moduleRegistry.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled); + } + + console.log(`UI Controller: TTS ${this.ttsEnabled ? 'enabled' : 'disabled'}`); + } else { + console.warn('TTS Handler does not have toggle method'); + } + }); } - } - - /** - * Update paragraph heights based on viewport - */ - updateParagraphHeight() { - document.querySelectorAll("#story p").forEach((element) => { - if (element.dataset.vpc) { - const pHeight = parseFloat(window.getComputedStyle(document.getElementById('page_right')).height); - const newHeight = pHeight * element.dataset.vpc / 100 + 'px'; - element.style.height = newHeight; + + // Add options button to controls section + const controlsSection = document.getElementById('controls'); + if (controlsSection) { + // Check if options button already exists + if (!document.getElementById('options-button')) { + const optionsButton = document.createElement('a'); + optionsButton.id = 'options-button'; + optionsButton.href = '#'; + optionsButton.textContent = 'options'; + optionsButton.title = 'Show game options'; + + // Add event listener + optionsButton.addEventListener('click', (e) => { + e.preventDefault(); + const optionsUI = moduleRegistry.getModule('options-ui'); + if (optionsUI && optionsUI.toggle) { + optionsUI.toggle(); + } + }); + + // Add to controls + controlsSection.appendChild(document.createTextNode(' | ')); + controlsSection.appendChild(optionsButton); + } + } + + // Enable all controls buttons + const controlButtons = document.querySelectorAll('#controls a'); + controlButtons.forEach(button => { + button.removeAttribute('disabled'); + }); + + // Book click for fast-forwarding - make sure it triggers the animation queue + if (this.bookElement) { + this.bookElement.addEventListener('click', (event) => { + // Only if not clicking on a link or control + if (event.target.tagName !== 'A' && + !event.target.closest('#controls') && + !event.target.closest('#command_input')) { + if (this.animationQueue) { + console.log('UI Controller: Fast-forwarding animations'); + this.animationQueue.fastForward(); + } + } + }); + } + + // Space key for fast-forwarding + document.addEventListener('keydown', (e) => { + if (e.key === ' ' && + document.activeElement.tagName !== 'TEXTAREA' && + document.activeElement.tagName !== 'INPUT') { + if (this.animationQueue) { + console.log('UI Controller: Fast-forwarding animations (space key)'); + this.animationQueue.fastForward(); + e.preventDefault(); // Prevent page scrolling + } } }); } - - /** - * Update the speech button styling based on enabled state. - * @param {boolean} enabled - Whether speech is enabled. - */ - updateSpeechButtonStyling(enabled = false) { - if (!this.speechButton) return; - - if (enabled) { - this.speechButton.style.fontWeight = 'bold'; - this.speechButton.style.color = '#000'; - this.speechButton.style.backgroundColor = '#eee'; - } else { - this.speechButton.style.fontWeight = 'normal'; - this.speechButton.style.color = '#333'; - this.speechButton.style.backgroundColor = ''; + + 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'); } } - - /** - * Updates the enabled/disabled state and title of the speech button. - * @param {boolean} available - Whether any TTS system is available. - * @param {string} [type] - The type of TTS system available ('kokoro', 'browser', etc.). - */ - updateSpeechButtonAvailability(available, type) { - if (!this.speechButton) return; - - if (available) { - this.speechButton.removeAttribute('disabled'); - const ttsName = type === 'kokoro' ? 'Kokoro TTS' : (type === 'browser' ? 'Browser TTS' : 'TTS'); - const title = this.translations[this.locale]?.title_speech || `Toggle Text-to-Speech (${ttsName})`; - this.speechButton.setAttribute('title', title); - // Update style based on current handler state if available - this.updateSpeechButtonStyling(this.ttsHandler ? this.ttsHandler.isEnabled() : false); - } else { - this.speechButton.setAttribute('disabled', 'disabled'); - const title = this.translations[this.locale]?.title_speech_unavailable || 'Text-to-Speech not available'; - this.speechButton.setAttribute('title', title); - this.updateSpeechButtonStyling(false); // Ensure style is off - } - } - - /** - * Updates the enabled/disabled state of control buttons based on game state. - * @param {object} gameState - The current game state from AnimatedFiction. - * @param {boolean} gameState.started - Whether the game has started. - * @param {boolean} [gameState.canLoad] - Whether a saved game exists to be loaded. - */ - updateButtonStates(gameState) { - if (this.rewindButton) { - if (gameState.started) { - this.rewindButton.removeAttribute('disabled'); - } else { - this.rewindButton.setAttribute('disabled', 'disabled'); - } - } - if (this.saveButton) { - if (gameState.started) { - this.saveButton.removeAttribute('disabled'); - } else { - this.saveButton.setAttribute('disabled', 'disabled'); - } - } - if (this.loadButton) { - // Enable load button if a save exists (indicated by canLoad flag or similar) - // We might need a more robust way to check for saved state existence. - // For now, enable if game started OR if canLoad is explicitly true. - if (gameState.started || gameState.canLoad) { - this.loadButton.removeAttribute('disabled'); - } else { - this.loadButton.setAttribute('disabled', 'disabled'); - } - } - // Speech button availability is handled separately by updateSpeechButtonAvailability - } - - /** - * Updates the visual display of the speed slider. - * @param {number} value - The speed value (0-100). - */ - updateSpeedDisplay(value) { - if (this.speedSlider) { - this.speedSlider.value = value; - } - } - - /** - * Insert an element after a delay (Helper, potentially move elsewhere or keep if used) - * @param {number} delay - The delay in milliseconds - * @param {HTMLElement} target - The target element to append to - * @param {HTMLElement} el - The element to insert - * @param {boolean} fadeIn - Whether to fade in the element - */ - insertAfter(delay, target, el, fadeIn = true) { - if (this.animationQueue) { - if (fadeIn) { - el.classList.add("fade-in"); - this.animationQueue.schedule(function() { - target.appendChild(el); - }, delay); - } else { - this.animationQueue.schedule(function() { - target.appendChild(el); - }, delay); - } - } else { - // Fallback if no animation queue - if (fadeIn) { - el.classList.add("fade-in"); - setTimeout(() => { - target.appendChild(el); - }, delay); - } else { - setTimeout(() => { - target.appendChild(el); - }, delay); - } - } - } - - /** - * Set the locale for translations - * @param {string} locale - The locale code - */ - setLocale(locale) { - this.locale = locale; - - if (this.translations[locale]) { - Object.keys(this.translations[locale]).forEach(key => { - const prefix = key.substring(0, 5); - const postfix = key.substring(6, key.length); - const elements = document.querySelectorAll(`.l10n-${(prefix === 'title' ? postfix : key)}`); - - elements.forEach(element => { - if (prefix === "title") { - element.title = this.translations[locale][key]; - } else { - element.innerHTML = this.translations[locale][key]; - } - }); + + initializeTextBuffer() { + // Initialize text buffer handling + if (this.textBuffer) { + this.textBuffer.setOnSentenceReady((text, callback) => { + console.log('UI Controller: Displaying sentence'); + this.displayText(text).then(callback); }); - } else { - console.error(`Locale ${locale} is not defined`); } } + + 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': + if (this.animationQueue) { + this.animationQueue.fastForward(); + } + break; + case 'input': + if (this.socketClient) { + this.socketClient.sendCommand(command.text); + } + break; + case 'menu': + // Toggle options menu + const optionsUI = moduleRegistry.getModule('options-ui'); + if (optionsUI) { + optionsUI.toggle(); + } + break; + default: + // Handle general UI commands or pass to game logic + this._dispatchModuleEvent('ui:command', command); + } + } + + /** + * Update UI button states based on game state + * @param {Object} state - Game state information + */ + 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'); + + // Update save button state + if (saveButton) { + if (canSave) { + saveButton.removeAttribute('disabled'); + } else { + saveButton.setAttribute('disabled', 'disabled'); + } + } + + // Update load button state + if (loadButton) { + if (canLoad) { + loadButton.removeAttribute('disabled'); + } else { + loadButton.setAttribute('disabled', 'disabled'); + } + } + + // Update restart button state + if (restartButton) { + if (canRestart) { + restartButton.removeAttribute('disabled'); + } else { + restartButton.setAttribute('disabled', 'disabled'); + } + } + } + + // 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 UIController(); + +// Register with the module registry +moduleRegistry.register(uiController); + +// Export the module +export { uiController as UIController }; + +// Keep a reference in window for loader system +window.UIController = uiController; diff --git a/public/js/ui-display-handler.js b/public/js/ui-display-handler.js new file mode 100644 index 0000000..fe28c78 --- /dev/null +++ b/public/js/ui-display-handler.js @@ -0,0 +1,621 @@ +import { BaseModule } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; +import { ModuleEvent } from './base-module.js'; + +class UIDisplayHandler extends BaseModule { + constructor() { + super('ui-display-handler'); + + // Dependencies + this.dependencies = ['animation-queue', 'tts', 'text-processor', 'paragraph-layout']; + + // Display state + this.container = null; + this.textBuffer = []; + this.currentAnimation = null; + this.textElements = []; + this.maxParagraphs = 5; // Number of paragraphs to keep in view + + // Required module references + this.animationQueue = null; + this.tts = null; + this.textProcessor = null; + this.paragraphLayout = null; + + // Formatting settings + this.formatting = { + fontSize: '1.1rem', + lineHeight: '1.5', + paragraphSpacing: '1.2rem' + }; + + // Resources to preload + this.cssPath = '/css/style.css'; + this.imagesToPreload = [ + '/images/book-3057904.png', + '/images/brown-wooden-flooring.jpg' + ]; + + // Bind methods used as event handlers or passed as callbacks + this.handleAnimationEnd = this.handleAnimationEnd.bind(this); + this.displayText = this.displayText.bind(this); + this.measureText = this.measureText.bind(this); + this.typesetParagraph = this.typesetParagraph.bind(this); + + // Store a bound version of dispatchEvent for use in methods + this._dispatchModuleEvent = (name, detail) => { + document.dispatchEvent(new CustomEvent(name, { + detail: { moduleId: this.id, ...detail }, + bubbles: true + })); + }; + + // Add flag to track if we're currently animating text + this.isAnimating = false; + + console.log('UIDisplayHandler: Constructor initialized'); + } + + /** + * Load dependencies and resources + * @returns {Promise} - Resolves when dependencies are loaded + */ + async loadDependencies() { + try { + this.reportProgress(10, "Loading CSS stylesheets"); + + // Load CSS file + await this.loadCSS(this.cssPath); + this.reportProgress(30, "CSS loaded successfully"); + + // Preload images + this.reportProgress(40, "Preloading UI images"); + await this.preloadImages(this.imagesToPreload); + this.reportProgress(80, "UI images preloaded"); + + return true; + } catch (error) { + console.error("Error loading UI display resources:", error); + return false; + } + } + + /** + * Load CSS file asynchronously and wait for it to be applied + * @param {string} cssPath - Path to CSS file + * @returns {Promise} + */ + loadCSS(cssPath) { + return new Promise((resolve, reject) => { + // Check if the stylesheet is already loaded + const existingLinks = document.querySelectorAll('link[rel="stylesheet"]'); + for (const link of existingLinks) { + if (link.href.includes(cssPath)) { + console.log(`UIDisplayHandler: CSS ${cssPath} already loaded`); + resolve(); + return; + } + } + + // Create link element + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = cssPath; + + // Set up load and error handlers + link.onload = () => { + console.log(`UIDisplayHandler: CSS ${cssPath} loaded successfully`); + + // Give a small delay for the CSS to be applied + setTimeout(() => { + resolve(); + }, 50); + }; + + link.onerror = () => { + const error = new Error(`Failed to load CSS: ${cssPath}`); + console.error(error); + reject(error); + }; + + // Append to document head + document.head.appendChild(link); + }); + } + + /** + * Preload images + * @param {Array} imagePaths - Array of image paths to preload + * @returns {Promise} + */ + preloadImages(imagePaths) { + return new Promise((resolve) => { + if (!imagePaths || imagePaths.length === 0) { + resolve(); + return; + } + + let loaded = 0; + const totalImages = imagePaths.length; + + const checkAllLoaded = () => { + loaded++; + + // Update progress proportionally + const percent = Math.round((loaded / totalImages) * 100); + this.reportProgress(40 + percent * 0.4, `Preloaded ${loaded}/${totalImages} images`); + + if (loaded === totalImages) { + resolve(); + } + }; + + // Preload each image + imagePaths.forEach(path => { + const img = new Image(); + img.onload = checkAllLoaded; + img.onerror = () => { + console.warn(`UIDisplayHandler: Failed to preload image: ${path}`); + checkAllLoaded(); + }; + img.src = path; + }); + }); + } + + async initialize() { + this.reportProgress(0, 'Initializing UI Display Handler'); + + try { + this.reportProgress(20, 'Setting up display container'); + + // Create book structure first + this.setupBookStructure(); + + // Create or get the text display container + this.container = document.getElementById('story') || this.createDisplayContainer(); + + this.reportProgress(40, 'Configuring display settings'); + + // Apply initial formatting + this.applyFormatting(); + + this.reportProgress(60, 'Setting up animation and text processing dependencies'); + + // Get references to required modules + this.animationQueue = moduleRegistry.getModule('animation-queue'); + this.tts = moduleRegistry.getModule('tts'); + this.textProcessor = moduleRegistry.getModule('text-processor'); + this.paragraphLayout = moduleRegistry.getModule('paragraph-layout'); + + // Set up our text measuring function for the paragraph layout + if (this.paragraphLayout) { + this.paragraphLayout.setMeasureFunction(this.measureText); + } + + // Check if we have all required modules + if (!this.animationQueue) { + console.error('UIDisplayHandler: animation-queue module not found'); + return false; + } + + if (!this.textProcessor) { + console.warn('UIDisplayHandler: text-processor module not found, text will not be formatted properly'); + } + + if (!this.paragraphLayout) { + console.warn('UIDisplayHandler: paragraph-layout module not found, text will not be justified properly'); + } + + // Set up event listeners for animation sync + this.setupEventListeners(); + + this.reportProgress(100, 'UI Display Handler ready'); + + // Notify that display handler is ready + this._dispatchModuleEvent('ui:display:ready', {}); + + return true; + } catch (error) { + console.error('Error initializing UI Display Handler:', error); + this.changeState('ERROR'); + return false; + } + } + + setupBookStructure() { + // Create book structure based on reference HTML + const book = document.getElementById('book') || this.createBookElement(); + + // Create page_left if it doesn't exist + const pageLeft = document.getElementById('page_left') || + this.createElement('div', 'page_left', book); + + // Create page_right if it doesn't exist + const pageRight = document.getElementById('page_right') || + this.createElement('div', 'page_right', book); + + // Create header in page_left if needed + let header = pageLeft.querySelector('.header'); + if (!header) { + header = this.createElement('div', null, pageLeft, 'header'); + + // Add header content + const byline = this.createElement('h2', null, header, 'byline l10n-by'); + byline.textContent = 'powered by Generative AI'; + + const title = this.createElement('h1', null, header, 'title'); + title.textContent = 'AI Interactive Fiction'; + + const subtitle = this.createElement('h3', null, header, 'subtitle'); + subtitle.textContent = 'An open-world text adventure'; + + const separator = this.createElement('div', null, header, 'separator'); + const double = this.createElement('double', null, separator); + double.textContent = '❦'; + } + + // Create controls if needed + if (!document.getElementById('controls')) { + const controls = this.createElement('div', 'controls', pageLeft, 'buttons'); + + // Add speech toggle + const speechLink = this.createElement('a', 'speech', controls, 'l10n-speech'); + speechLink.title = 'Toggle text to speech'; + speechLink.disabled = 'disabled'; + speechLink.textContent = 'speech'; + + // Add speed control + const speedSpan = this.createElement('span', null, controls); + const speedReset = this.createElement('a', 'speed_reset', speedSpan); + const speedSpanInner = this.createElement('span', null, speedReset, 'l10n-speed'); + speedSpanInner.innerHTML = 'speed*'; + + const speedInput = document.createElement('input'); + speedInput.type = 'range'; + speedInput.min = '0'; + speedInput.max = '100'; + speedInput.value = '50'; + speedInput.id = 'speed'; + speedInput.name = 'speed'; + speedSpan.appendChild(speedInput); + + // Add restart button + const restartLink = this.createElement('a', 'rewind', controls, 'l10n-restart'); + restartLink.title = 'Restart story from beginning'; + restartLink.disabled = 'disabled'; + restartLink.textContent = 'restart'; + + // Add save button + const saveLink = this.createElement('a', 'save', controls, 'l10n-save'); + saveLink.title = 'Save progress'; + saveLink.textContent = 'save'; + + // Add load button + const loadLink = this.createElement('a', 'reload', controls, 'l10n-load'); + loadLink.title = 'Reload from save point'; + loadLink.disabled = 'disabled'; + loadLink.textContent = 'load'; + } + + // Create remark section if needed + if (!document.getElementById('remark')) { + const remark = this.createElement('div', 'remark', pageLeft, 'l10n-remark'); + remark.innerHTML = '*click on page or press spacebar to fast forward text animation'; + } + + // Create story container in page_right if needed + if (!document.getElementById('story')) { + const story = this.createElement('div', 'story', pageRight, 'container'); + } + + // Create lighting element if needed + if (!document.getElementById('lighting')) { + const lighting = this.createElement('div', 'lighting', document.body); + } + + // Create ruler and indent elements if needed + if (!document.getElementById('ruler')) { + this.createElement('div', 'ruler', document.body); + } + + if (!document.getElementById('indent')) { + const indent = this.createElement('div', 'indent', document.body, 'l10n-prompt'); + indent.textContent = 'What do you want to do next?'; + } + } + + createBookElement() { + const book = this.createElement('div', 'book', document.body); + return book; + } + + createElement(tagName, id, parent, className) { + const element = document.createElement(tagName); + if (id) element.id = id; + if (className) element.className = className; + if (parent) parent.appendChild(element); + return element; + } + + createDisplayContainer() { + const storyContainer = document.getElementById('story'); + if (storyContainer) return storyContainer; + + // If not found, create necessary structure + const book = document.getElementById('book') || this.createBookElement(); + const pageRight = document.getElementById('page_right') || + this.createElement('div', 'page_right', book); + + // Create story container + return this.createElement('div', 'story', pageRight, 'container'); + } + + setupEventListeners() { + // Use the bound method directly as the listener + document.addEventListener('animationend', this.handleAnimationEnd); + } + + handleAnimationEnd(event) { + // Check if the event target is a story paragraph before proceeding + if (!event.target.classList.contains('story-paragraph')) { + return; + } + + const paragraph = event.target; + paragraph.classList.remove('fade-in'); + + // Notify that text display is complete + this._dispatchModuleEvent('ui:text:complete', {}); + } + + applyFormatting() { + if (this.container) { + this.container.style.fontSize = this.formatting.fontSize; + this.container.style.lineHeight = this.formatting.lineHeight; + } + } + + /** + * Measure text width for paragraph layout + * @param {string} text - Text to measure + * @param {string} [style] - Optional CSS style + * @returns {number} - Text width in pixels + */ + measureText(text, style = '') { + // Create a temporary span for text measurement + const ruler = document.getElementById('ruler') || this.createRuler(); + + // Apply any custom style if provided + if (style) { + ruler.style.cssText = style; + } + + // Set text and measure + ruler.textContent = text; + return ruler.offsetWidth; + } + + /** + * Create a ruler element for text measurement + * @returns {HTMLElement} - The ruler element + */ + createRuler() { + const ruler = document.createElement('div'); + ruler.id = 'ruler'; + ruler.style.position = 'absolute'; + ruler.style.visibility = 'hidden'; + ruler.style.whiteSpace = 'nowrap'; + ruler.style.font = window.getComputedStyle(this.container || document.body).font; + document.body.appendChild(ruler); + return ruler; + } + + /** + * Typeset a paragraph based on calculated line breaks + * @param {Object} paragraphData - Line breaking data from ParagraphLayout + * @param {number} delay - Initial delay for animation + * @param {Array} measures - Line width measurements + * @returns {Array} - [Paragraph element, final delay] + */ + typesetParagraph(paragraphData, delay = 0, measures = []) { + // Create paragraph element + const p = document.createElement('p'); + p.className = 'story-paragraph'; + + // Set up initial styling + p.style.position = 'relative'; + p.style.width = '100%'; + + let lineHeight = parseInt(this.formatting.lineHeight) || 1.5; + let lineTop = 0; + + // Iterate through lines from paragraph_data.breaks + for(let i = 1; i < paragraphData.breaks.length; i++) { + // Get the current line (from the previous break position to the current break position) + let lineStart = paragraphData.breaks[i-1].position; + let lineEnd = paragraphData.breaks[i].position; + + // Process each node (word, space, tag) within the line + for(let j = lineStart; j <= lineEnd; j++) { + const node = paragraphData.nodes[j]; + + if (!node || !node.type) continue; // Skip invalid nodes + + // Handle different node types + if (node.type === 'box' || node.type === 'tag') { + // Create span for word or tag + const span = document.createElement('span'); + span.style.position = 'absolute'; + span.style.left = `${node.left || 0}px`; + span.style.top = `${lineTop}px`; + span.style.opacity = '0'; // Start invisible for fade-in + + // Set content based on node type + if (node.type === 'box') { + span.textContent = node.value; + } else if (node.type === 'tag') { + // Handle HTML tags (e.g., , , etc.) + span.innerHTML = node.value; + } + + // Add to paragraph + p.appendChild(span); + + // Schedule animation using AnimationQueue + if (this.animationQueue) { + const wordLength = node.value ? node.value.length : 1; + this.animationQueue.schedule(() => { + span.style.opacity = '1'; // Fade in + span.classList.add('animated'); + }, delay); + + // Calculate delay for next element based on word length + delay += (wordLength * 50); // Adjust timing as needed + } else { + // Without animation queue, make visible immediately + span.style.opacity = '1'; + } + } + // Glue (spaces) don't need visible elements + } + + // Update line top position for next line + lineTop += lineHeight * 16; // Assuming 1em = 16px, adjust based on font size + } + + // Set paragraph height based on final line position + p.style.height = `${lineTop + lineHeight}px`; + + return [p, delay]; + } + + /** + * Display text with formatting, animation, and optional TTS + * @param {string} text - Text to display + * @param {Object} options - Display options + * @returns {Promise} - Resolves when text display is complete + */ + async displayText(text, options = {}) { + if (!this.container || !text) return false; + + console.log(`UIDisplayHandler: Processing text for display: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); + + // Set animating flag + this.isAnimating = true; + + // Process text + let processedText = text; + if (this.textProcessor) { + try { + processedText = this.textProcessor.process(text, true); + console.log('UIDisplayHandler: Text processed with typography enhancements'); + } catch (error) { + console.error('Error processing text:', error); + // Continue with unprocessed text + } + } + + // Create a simple paragraph to display the text + const paragraph = document.createElement('p'); + paragraph.className = 'story-paragraph fade-in'; + paragraph.textContent = processedText; + + // Apply any custom styling from options + if (options.style && paragraph) { + Object.assign(paragraph.style, options.style); + } + + // Add to DOM + this.container.appendChild(paragraph); + this.textElements.push(paragraph); + + // Limit the number of paragraphs + this.limitParagraphs(); + + // Scroll to the new paragraph + this.scrollToBottom(); + + // If TTS is available and enabled, speak the text + if (this.tts) { + console.log('UIDisplayHandler: Starting TTS playback'); + this.tts.speak(text); + } + + // Return a promise that resolves when animation is complete + return new Promise(resolve => { + // Use a simple timeout for animation completion + setTimeout(() => { + console.log('UIDisplayHandler: Text animation complete'); + this.isAnimating = false; + + // Dispatch text complete event + document.dispatchEvent(new CustomEvent('ui:text:complete', { + detail: { moduleId: this.id } + })); + + resolve(); + }, 1000); // Default animation time + }); + } + + limitParagraphs() { + while (this.textElements.length > this.maxParagraphs) { + const oldestElement = this.textElements.shift(); + if (oldestElement && oldestElement.parentElement) { + oldestElement.parentElement.removeChild(oldestElement); + } + } + } + + scrollToBottom() { + if (this.container) { + this.container.scrollTop = this.container.scrollHeight; + } + } + + clear() { + if (this.container) { + this.container.innerHTML = ''; + this.textElements = []; + } + } + + show() { + if (this.container) { + this.container.style.display = 'block'; + } + } + + hide() { + if (this.container) { + this.container.style.display = 'none'; + } + } + + processCommand(command) { + switch (command.action) { + case 'display': + this.displayText(command.text, command.options); + break; + case 'clear': + this.clear(); + break; + default: + console.warn(`Unknown display command: ${command.action}`); + } + } +} + +// Create the singleton instance +const uiDisplayHandler = new UIDisplayHandler(); + +// Register with the module registry +moduleRegistry.register(uiDisplayHandler); + +// Export the module +export { uiDisplayHandler as UIDisplayHandler }; + +// Keep a reference in window for loader system +console.log('UIDisplayHandler: Registering with window'); +window.UIDisplayHandler = uiDisplayHandler; diff --git a/public/js/ui-effects.js b/public/js/ui-effects.js new file mode 100644 index 0000000..0b4a5e8 --- /dev/null +++ b/public/js/ui-effects.js @@ -0,0 +1,319 @@ +import { BaseModule } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; +import { ModuleEvent } from './base-module.js'; + +class UIEffects extends BaseModule { + constructor() { + super('ui-effects'); + + // No external dependencies + this.dependencies = []; + + // Effects state + this.activeEffects = new Map(); + this.ambientEffectsActive = false; + + // Effects configuration + this.effectsConfig = { + candleFlicker: { + intensity: 0.5, + speed: 0.8 + }, + textShadow: { + enabled: true, + color: 'rgba(0, 0, 0, 0.5)' + }, + backgroundEffects: { + enabled: true + } + }; + + // Bind methods that use 'this' internally or are used as callbacks/event handlers + this.initialize = this.initialize.bind(this); // Bind initialize as it calls dispatchEvent + this.updateCandleEffect = this.updateCandleEffect.bind(this); // Used with requestAnimationFrame + this.setupEffectElements = this.setupEffectElements.bind(this); + this.createEffectsOverlay = this.createEffectsOverlay.bind(this); + this.createCandleEffect = this.createCandleEffect.bind(this); + this.createLightingElement = this.createLightingElement.bind(this); + this.setupAmbientEffects = this.setupAmbientEffects.bind(this); + this.setupCandleFlickerEffect = this.setupCandleFlickerEffect.bind(this); + this.startAmbientEffects = this.startAmbientEffects.bind(this); + this.stopAmbientEffects = this.stopAmbientEffects.bind(this); + this.applyEffect = this.applyEffect.bind(this); + this.applyShakeEffect = this.applyShakeEffect.bind(this); + this.applyFlashEffect = this.applyFlashEffect.bind(this); + this.applyTextEmphasis = this.applyTextEmphasis.bind(this); + this.processCommand = this.processCommand.bind(this); + + // Store a bound version of dispatchEvent for use in methods + this._dispatchModuleEvent = (name, detail) => { + document.dispatchEvent(new CustomEvent(name, { + detail: { moduleId: this.id, ...detail }, + bubbles: true + })); + }; + + console.log('UIEffects: Constructor initialized'); + } + + async initialize() { + this.reportProgress(0, 'Initializing UI Effects'); + + try { + this.reportProgress(30, 'Setting up effect elements'); + + // Create or get effect elements + this.setupEffectElements(); + + this.reportProgress(60, 'Preparing ambient effects'); + + // Set up ambient effect animations + this.setupAmbientEffects(); + + this.reportProgress(100, 'UI Effects ready'); + + // Use the DOM event API directly instead of this.dispatchEvent + this._dispatchModuleEvent('ui:effects:ready', {}); + + return true; + } catch (error) { + console.error('Error initializing UI Effects:', error); + this.changeState('ERROR'); + return false; + } + } + + setupEffectElements() { + console.log('UIEffects: Setting up effect elements'); + // Create overlay for effects if it doesn't exist + this.effectsOverlay = document.getElementById('effects-overlay') || this.createEffectsOverlay(); + + // Create candle effect element + this.candleEffectElement = document.getElementById('candle-effect') || this.createCandleEffect(); + + // Ensure lighting element exists + this.lightingElement = document.getElementById('lighting') || this.createLightingElement(); + } + + createEffectsOverlay() { + const overlay = document.createElement('div'); + overlay.id = 'effects-overlay'; + overlay.className = 'effects-overlay'; + document.body.appendChild(overlay); + return overlay; + } + + createCandleEffect() { + const candleEffect = document.createElement('div'); + candleEffect.id = 'candle-effect'; + candleEffect.className = 'candle-effect'; + if (this.effectsOverlay) { + this.effectsOverlay.appendChild(candleEffect); + } else { + document.body.appendChild(candleEffect); + } + return candleEffect; + } + + createLightingElement() { + const lighting = document.createElement('div'); + lighting.id = 'lighting'; + document.body.appendChild(lighting); + return lighting; + } + + setupAmbientEffects() { + // Initialize candle flicker effect + if (this.candleEffectElement && this.effectsConfig.candleFlicker.enabled !== false) { + this.setupCandleFlickerEffect(); + } + } + + setupCandleFlickerEffect() { + // Store the animation frame ID for later cancellation + this.candleAnimationId = null; + } + + updateCandleEffect() { + if (!this.candleEffectElement || !this.ambientEffectsActive) return; + + const { intensity, speed } = this.effectsConfig.candleFlicker; + + // Create subtle random flickering effect + const flickerAmount = Math.random() * intensity; + const opacity = 0.2 + flickerAmount * 0.2; + + this.candleEffectElement.style.opacity = opacity; + + // Schedule next update + this.candleAnimationId = requestAnimationFrame(this.updateCandleEffect); + } + + // Public methods + + startAmbientEffects() { + if (this.ambientEffectsActive) return; + + this.ambientEffectsActive = true; + + // Start candle flicker + if (this.candleEffectElement) { + this.updateCandleEffect(); + } + + // Apply lighting animation + if (this.lightingElement) { + this.lightingElement.style.animation = 'gradient-animation-shrink 2s infinite alternate'; + } + } + + stopAmbientEffects() { + this.ambientEffectsActive = false; + + // Stop candle flicker + if (this.candleAnimationId) { + cancelAnimationFrame(this.candleAnimationId); + this.candleAnimationId = null; + } + + // Stop lighting animation + if (this.lightingElement) { + this.lightingElement.style.animation = ''; + } + } + + applyEffect(effectName, options = {}) { + switch (effectName) { + case 'shake': + return this.applyShakeEffect(options); + case 'flash': + return this.applyFlashEffect(options); + case 'emphasis': + return this.applyTextEmphasis(options.text, options); + default: + console.warn(`Unknown effect: ${effectName}`); + return null; + } + } + + applyShakeEffect(options = {}) { + const target = options.target || document.getElementById('book'); + if (!target) return null; + + const intensity = options.intensity || 'medium'; + const duration = options.duration || 500; + + // Store original styles + const originalTransition = target.style.transition; + const originalTransform = target.style.transform; + + // Apply the shake animation + target.style.transition = `transform ${duration}ms ease`; + target.style.transform = 'translate(0, 0)'; + target.style.animation = `shake ${duration}ms 1`; + + // Return to normal after animation + const effectId = setTimeout(() => { + target.style.transition = originalTransition; + target.style.transform = originalTransform; + target.style.animation = ''; + this.activeEffects.delete(effectId); + }, duration); + + this.activeEffects.set(effectId, { name: 'shake', target }); + return effectId; + } + + applyFlashEffect(options = {}) { + const color = options.color || 'white'; + const duration = options.duration || 300; + + // Create flash overlay if not exists + let flashOverlay = document.getElementById('flash-overlay'); + if (!flashOverlay) { + flashOverlay = document.createElement('div'); + flashOverlay.id = 'flash-overlay'; + flashOverlay.style.position = 'fixed'; + flashOverlay.style.top = '0'; + flashOverlay.style.left = '0'; + flashOverlay.style.width = '100%'; + flashOverlay.style.height = '100%'; + flashOverlay.style.pointerEvents = 'none'; + flashOverlay.style.zIndex = '9999'; + flashOverlay.style.opacity = '0'; + flashOverlay.style.transition = `opacity ${duration / 2}ms ease-in-out`; + document.body.appendChild(flashOverlay); + } + + // Set color and make visible + flashOverlay.style.backgroundColor = color; + + // Start animation + setTimeout(() => { flashOverlay.style.opacity = '0.8'; }, 10); + + const effectId = setTimeout(() => { + flashOverlay.style.opacity = '0'; + this.activeEffects.delete(effectId); + + // Remove element after fade out + setTimeout(() => { + if (flashOverlay.parentNode) { + flashOverlay.parentNode.removeChild(flashOverlay); + } + }, duration / 2); + }, duration / 2); + + this.activeEffects.set(effectId, { name: 'flash' }); + return effectId; + } + + applyTextEmphasis(text, options = {}) { + // Use existing display handler to show emphasized text + const displayHandler = moduleRegistry.getModule('ui-display-handler'); + if (!displayHandler) return null; + + const style = { + fontWeight: 'bold', + color: options.color || '#990000', + fontSize: options.size || '1.2em' + }; + + return displayHandler.displayText(text, { style, speak: true }); + } + + processCommand(command) { + switch (command.action) { + case 'apply': + return this.applyEffect(command.effect, command.options); + case 'cancel': + const effectId = command.effectId; + if (this.activeEffects.has(effectId)) { + clearTimeout(effectId); + this.activeEffects.delete(effectId); + } + break; + case 'ambient': + if (command.state === 'start') { + this.startAmbientEffects(); + } else if (command.state === 'stop') { + this.stopAmbientEffects(); + } + break; + default: + console.warn(`Unknown effect command: ${command.action}`); + } + } +} + +// Create the singleton instance +const uiEffects = new UIEffects(); + +// Register with the module registry +moduleRegistry.register(uiEffects); + +// Export the module +export { uiEffects as UIEffects }; + +// Keep a reference in window for loader system +console.log('UIEffects: Registering with window'); +window.UIEffects = uiEffects; diff --git a/public/js/ui-input-handler.js b/public/js/ui-input-handler.js new file mode 100644 index 0000000..b687662 --- /dev/null +++ b/public/js/ui-input-handler.js @@ -0,0 +1,449 @@ +import { BaseModule } from './base-module.js'; +import { moduleRegistry } from './module-registry.js'; +import { ModuleEvent } from './base-module.js'; + +class UIInputHandler extends BaseModule { + constructor() { + super('ui-input-handler'); + + // Explicitly declare ui-display-handler as a dependency + this.dependencies = ['ui-display-handler']; + + // Reference to display handler + this.displayHandler = null; + + // Input elements + this.inputArea = null; + this.playerInput = null; + this.cursor = null; + this.commandHistoryElement = null; // Changed: renamed to avoid conflict + + // Input state + this.inputEnabled = true; + this.historyIndex = -1; + this.commandHistory = []; // Now this is clearly the array of previous commands + this.inputBuffer = ''; + + // Add this method to properly dispatch custom events + this._dispatchModuleEvent = (name, detail) => { + document.dispatchEvent(new CustomEvent(name, { + detail: { moduleId: this.id, ...detail }, + bubbles: true + })); + }; + + // Bind method contexts + this.setupInputElements = this.setupInputElements.bind(this); + this.handlePlayerInput = this.handlePlayerInput.bind(this); + this.handleInputKeyDown = this.handleInputKeyDown.bind(this); + this.positionCursor = this.positionCursor.bind(this); + this.handleKeyboardInput = this.handleKeyboardInput.bind(this); + + console.log('UIInputHandler: Constructor initialized'); + } + + /** + * Wait for dependencies before initializing + * This ensures displayHandler is ready before we try to use it + */ + async waitForDependencies() { + try { + // Explicitly wait for the display handler to be ready + console.log('UIInputHandler: Waiting for display handler to be ready'); + + // Get reference to the display handler + this.displayHandler = moduleRegistry.getModule('ui-display-handler'); + + if (!this.displayHandler) { + console.error('UIInputHandler: Display handler dependency not found'); + return false; + } + + // Wait for display handler to reach FINISHED state + const displayHandlerReady = await moduleRegistry.waitForModule('ui-display-handler'); + if (!displayHandlerReady) { + console.error('UIInputHandler: Display handler not ready after waiting'); + return false; + } + + console.log('UIInputHandler: Display handler is ready'); + return true; + } catch (error) { + console.error('UIInputHandler: Error waiting for dependencies:', error); + return false; + } + } + + /** + * Initialize input handler + */ + async initialize() { + this.reportProgress(0, 'Initializing UI Input Handler'); + + try { + // Double-check display handler reference + if (!this.displayHandler) { + this.displayHandler = moduleRegistry.getModule('ui-display-handler'); + + if (!this.displayHandler) { + console.error('UIInputHandler: Display handler still not available'); + return false; + } + } + + this.reportProgress(30, 'Setting up keyboard listeners'); + + // Set up keyboard event listeners + document.addEventListener('keydown', (event) => { + this.handleKeyboardInput(event); + }); + + this.reportProgress(60, 'Setting up input elements'); + + // Set up input elements + this.setupInputElements(); + + this.reportProgress(100, 'UI Input Handler ready'); + return true; + } catch (error) { + console.error('Error initializing UI Input Handler:', error); + return false; + } + } + + /** + * Handle keyboard shortcuts and input globally + * @param {KeyboardEvent} event - The keyboard event + */ + handleKeyboardInput(event) { + // Handle global keyboard shortcuts here + // This is different from the input field's specific key handling + + // For example: Escape key to blur the input + if (event.key === 'Escape') { + if (document.activeElement === this.playerInput) { + this.playerInput.blur(); + } + } + } + + setupInputElements() { + console.log("UIInputHandler: Setting up input elements in document flow"); + + // Find the left page - this is created by the display handler + const pageLeft = document.getElementById('page_left'); + if (!pageLeft) { + console.error('UIInputHandler: Left page not found, cannot create input elements'); + return; + } + + // Only create choices container if it doesn't already exist + let choicesContainer = document.getElementById('choices'); + if (!choicesContainer) { + choicesContainer = document.createElement('div'); + choicesContainer.id = 'choices'; + choicesContainer.className = 'container'; + + // Use natural document flow, not absolute positioning + // Do NOT add a separator here, as it already exists in the CSS + + pageLeft.appendChild(choicesContainer); + } + + // Create command history container if needed + let commandHistory = document.getElementById('command_history'); + if (!commandHistory) { + commandHistory = document.createElement('div'); + commandHistory.id = 'command_history'; + choicesContainer.appendChild(commandHistory); + this.commandHistoryElement = commandHistory; // Changed: store in renamed property + } else { + this.commandHistoryElement = commandHistory; // Changed: store in renamed property + } + + // Create input container if needed + let commandInput = document.getElementById('command_input'); + if (!commandInput) { + commandInput = document.createElement('div'); + commandInput.id = 'command_input'; + choicesContainer.appendChild(commandInput); + } + + // Create input wrapper if needed + let inputWrapper = commandInput.querySelector('.input-wrapper'); + if (!inputWrapper) { + inputWrapper = document.createElement('div'); + inputWrapper.className = 'input-wrapper'; + commandInput.appendChild(inputWrapper); + } + + // Create the textarea if needed + let playerInput = document.getElementById('player_input'); + if (!playerInput) { + playerInput = document.createElement('textarea'); + playerInput.id = 'player_input'; + playerInput.rows = 1; + playerInput.placeholder = 'What will you do?'; + playerInput.setAttribute('autocomplete', 'off'); + playerInput.setAttribute('spellcheck', 'true'); + + // Fix horizontal scrolling by ensuring the textbox wraps text + playerInput.style.overflowX = 'hidden'; + playerInput.style.wordWrap = 'break-word'; + playerInput.style.whiteSpace = 'pre-wrap'; + + inputWrapper.appendChild(playerInput); + this.playerInput = playerInput; + } + + // Create the cursor if needed + let cursor = document.getElementById('cursor'); + if (!cursor) { + cursor = document.createElement('span'); + cursor.id = 'cursor'; + inputWrapper.appendChild(cursor); + this.cursor = cursor; + } + + // Set up input event handlers + if (playerInput) { + playerInput.addEventListener('input', this.handlePlayerInput); + playerInput.addEventListener('keydown', this.handleInputKeyDown); + + // Auto-resize input field + playerInput.addEventListener('input', () => { + playerInput.style.height = 'auto'; + playerInput.style.height = playerInput.scrollHeight + 'px'; + }); + } + + // Position the cursor + if (playerInput && cursor) { + this.positionCursor(playerInput, cursor); + + // Focus the input to let user start typing immediately + setTimeout(() => { + playerInput.focus(); + }, 100); + } + + console.log('UIInputHandler: Input elements setup complete'); + } + + /** + * Handle player input changes + * @param {Event} e - Input event + */ + handlePlayerInput(e) { + if (!this.playerInput) return; + + // Auto-resize the input field based on content + this.playerInput.style.height = 'auto'; + this.playerInput.style.height = `${this.playerInput.scrollHeight}px`; + + // Update the cursor position with the current input text + if (this.cursor) { + this.positionCursor(this.playerInput, this.cursor); + } + + // Dispatch event using the properly defined method + this._dispatchModuleEvent('ui:input:change', { + text: this.playerInput.value + }); + } + + /** + * Handle key down events in the input field + * @param {KeyboardEvent} e - Keyboard event + */ + handleInputKeyDown(e) { + if (!this.playerInput) return; + + // Check for Enter key + if (e.key === 'Enter') { + if (!e.shiftKey) { + // Prevent default (new line) if not holding shift + e.preventDefault(); + + // Submit command + this.submitCommand(); + } + } + } + + /** + * Submit the current input as a command + */ + submitCommand() { + if (!this.playerInput || !this.playerInput.value.trim()) return; + + const command = this.playerInput.value.trim(); + console.log(`UIInputHandler: Submitting command: "${command}"`); + + // Add command to history + this.addToHistory(command); + + // Dispatch command event + this._dispatchModuleEvent('ui:command', { + type: 'input', + text: command + }); + + // Clear input field + this.playerInput.value = ''; + this.playerInput.style.height = 'auto'; + + // Update cursor position + if (this.cursor) { + this.positionCursor(this.playerInput, this.cursor); + } + + // Focus input field + this.playerInput.focus(); + } + + /** + * Add command to history + * @param {string} command - Command to add to history + */ + addToHistory(command) { + // Add to history array + this.commandHistory.push(command); + + // Limit history size + if (this.commandHistory.length > 50) { + this.commandHistory.shift(); + } + + // Reset history index + this.historyIndex = -1; + + // Update visual history if element exists + if (this.commandHistoryElement && this.commandHistoryElement.appendChild) { + const historyItem = document.createElement('div'); + historyItem.className = 'history-item'; + historyItem.textContent = `> ${command}`; + this.commandHistoryElement.appendChild(historyItem); + + // Limit visible history items + while (this.commandHistoryElement.childElementCount > 10) { + this.commandHistoryElement.removeChild(this.commandHistoryElement.firstChild); + } + + // Scroll to bottom + this.commandHistoryElement.scrollTop = this.commandHistoryElement.scrollHeight; + } + } + + /** + * Resets the cursor position to the start. + */ + resetCursorPosition() { + if (this.cursor) { + this.cursor.style.left = '0px'; + // Adjust top based on computed style padding or a default + const computedStyle = window.getComputedStyle(this.playerInput); + const paddingTop = parseFloat(computedStyle.paddingTop) || 6; + this.cursor.style.top = `${paddingTop}px`; + } + } + + + /** + * Position cursor based on input text position + * @param {HTMLTextAreaElement} inputElement - The input element + * @param {HTMLElement} cursorElement - The visual cursor element + */ + positionCursor(inputElement, cursorElement) { + if (!inputElement || !cursorElement) return; + + this.cursor = cursorElement; + this.playerInput = inputElement; + const updatePosition = () => { + try { + const input = this.playerInput; + const cursor = this.cursor; + const caretPosition = input.selectionStart || 0; + const inputText = input.value; + + // If no text, position cursor at the beginning based on padding + if (inputText.length === 0 && caretPosition === 0) { + this.resetCursorPosition(); + return; + } + + // Create a temporary measurement div + const div = document.createElement('div'); + const style = getComputedStyle(input); + + // Apply relevant styles from the textarea to the div + div.style.position = 'absolute'; + div.style.top = '-9999px'; + div.style.left = '-9999px'; + div.style.width = style.width; + div.style.height = 'auto'; + div.style.padding = style.padding; + div.style.border = style.border; + div.style.fontFamily = style.fontFamily; + div.style.fontSize = style.fontSize; + div.style.fontWeight = style.fontWeight; + div.style.lineHeight = style.lineHeight; + div.style.whiteSpace = 'pre-wrap'; + div.style.wordWrap = 'break-word'; + div.style.boxSizing = style.boxSizing; + + // Create spans for text before and after the caret, and a marker span + const preCaretText = document.createTextNode(inputText.substring(0, caretPosition)); + const caretMarker = document.createElement('span'); + caretMarker.innerHTML = ' '; // Use non-breaking space for measurement + const postCaretText = document.createTextNode(inputText.substring(caretPosition)); + + // Append spans to the div + div.appendChild(preCaretText); + div.appendChild(caretMarker); + div.appendChild(postCaretText); + + // Append div to body for measurement + document.body.appendChild(div); + + // Get position relative to the div's content box + const markerRect = caretMarker.getBoundingClientRect(); + const divRect = div.getBoundingClientRect(); + + // Calculate position relative to the input's top-left, considering scroll + const cursorLeft = markerRect.left - divRect.left; + const cursorTop = markerRect.top - divRect.top - input.scrollTop; + + // Set cursor position + cursor.style.left = `${cursorLeft}px`; + cursor.style.top = `${cursorTop}px`; + + // Clean up the temporary div + document.body.removeChild(div); } catch (error) { + console.error('Error positioning cursor:', error); + } + }; + + // Update on various events + inputElement.addEventListener('input', updatePosition); + inputElement.addEventListener('click', updatePosition); + inputElement.addEventListener('keyup', updatePosition); + inputElement.addEventListener('focus', updatePosition); + + // Initial position update + updatePosition(); + } +} + +// Create the singleton instance +const uiInputHandler = new UIInputHandler(); + +// Register with the module registry +moduleRegistry.register(uiInputHandler); + +// Export the module +export { uiInputHandler as UIInputHandler }; + +// Keep a reference in window for loader system +console.log('UIInputHandler: Registering with window'); +window.UIInputHandler = uiInputHandler;