/** * 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 });