diff --git a/Screenshot 2025-04-01 080304.png b/Screenshot 2025-04-01 080304.png deleted file mode 100644 index 6efdb94..0000000 Binary files a/Screenshot 2025-04-01 080304.png and /dev/null differ diff --git a/TODO.md b/TODO.md index eb2fc0e..4fff76b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,99 +1,106 @@ -# Project Implementation Plan +# Module System Refactoring TODO -## Phase 1: Project Setup and Basic Structure -- [x] Define project goals and specifications -- [x] Set up project structure - - [x] Create core directories (src, data, tests) - - [x] Initialize Node.js/npm project - - [x] Set up TypeScript configuration - - [ ] Configure ESLint and Prettier for code quality -- [ ] Choose and set up testing framework -- [x] Create basic documentation structure +## High Priority (Critical Architectural Issues) -## Phase 2: World Model Implementation -- [ ] Define YAML schema for world elements - - [ ] Room schema (description, exits, objects, characters) - - [ ] Object schema (description, properties, allowed actions) - - [ ] NPC schema (description, dialogue, behavior) - - [ ] Action schema (conditions, effects) -- [x] Implement YAML parser and validator -- [ ] Create the world model core - - [ ] Game state management - - [ ] Room navigation - - [ ] Object interaction - - [ ] NPC interaction - - [ ] Action processing logic -- [x] Create a simple test world in YAML format -- [ ] Implement unit tests for world model +### 1. Asynchronous Flow Control Improvements +- [ ] Remove all `setTimeout` calls used for synchronization in modules + - [X] Replace timeout in `browser-tts-handler.js` with proper Promise handling for voice loading + - [X] Eliminate race condition in `tts-player.js` that uses a hard-coded 1000ms timeout + - [ ] Remove all `setTimeout` calls in `ui-controller.js` for UI updates +- [ ] Implement proper Promise-based flow control in all modules + - [ ] Update `kokoro-handler.js` to correctly handle loading events + - [ ] Ensure all `async/await` patterns follow best practices + - [ ] Fix race conditions in module loading sequences -## Phase 3: LLM Integration -- [x] Research and select appropriate OpenRouter model -- [x] Implement OpenRouter API client - - [x] Configuration and authentication - - [x] API request/response handling - - [ ] Rate limiting and error handling -- [ ] Design LLM prompting strategy - - [ ] System prompts for action translation - - [ ] System prompts for narrative generation - - [ ] Context management for conversation history -- [ ] Create adapter between LLM and world model - - [ ] Define the interface for action translation - - [ ] Define the interface for narrative generation +### 2. Module State Management +- [ ] Fix premature reporting of `FINISHED` state + - [ ] Ensure `tts-player.js` properly waits for Kokoro loading before reporting FINISHED + - [ ] Add proper state checks in all modules before reporting FINISHED +- [ ] Implement proper state transition reporting + - [ ] Update modules to use event system for reporting state transitions + - [ ] Add better error handling during module initialization -## Phase 4: Game Engine Core -- [x] Implement the game loop - - [x] Input handling - - [ ] Action processing via LLM - - [ ] World model updating - - [ ] Response generation via LLM - - [ ] Output formatting -- [ ] Implement saving/loading game state -- [ ] Add game configuration options -- [ ] Implement logging for debugging +### 3. Module Dependencies & Loading +- [ ] Fix missing dependency declarations + - [ ] Update `ui-controller.js` to properly declare its TTS dependency + - [ ] Ensure all modules correctly specify all dependencies +- [ ] Remove dependency availability checks within modules + - [ ] Remove conditional checks like `if (!this.ttsHandler)` in `ui-controller.js` + - [ ] Rely on the module loader for dependency management -## Phase 5: User Interface -- [x] Create a command-line interface - - [x] Input handling - - [x] Text output formatting - - [ ] Command history -- [x] Implement a simple web interface - - [x] Basic HTML/CSS structure - - [x] JavaScript for interaction - - [x] Responsive design -- [x] Text processing utilities - - [x] Implement smartypants.js for typographical improvements - - [ ] Add hyphenation support +## Medium Priority (Functionality & Implementation Issues) -## Phase 6: Advanced Features -- [ ] Implement integration layer for Z-machine - - [ ] Research Z-machine libraries - - [ ] Create adapter for Z-machine to world model interface - - [ ] Test with classic Infocom games -- [ ] Add advanced LLM features - - [ ] Character styles and narrative tones - - [ ] Memory and reference to past events - - [ ] Player character personality modeling -- [ ] Create plugin system for extending world model capabilities +### 4. TTS Handler Implementation +- [ ] Implement missing `tts-handler.js` file content + - [ ] Create proper implementation with consistent interface + - [ ] Ensure it uses proper event-based communication +- [ ] Fix inconsistent event usage across TTS handlers + - [ ] Replace direct callbacks with event system + - [ ] Standardize event names and parameters -## Phase 7: Testing and Refinement -- [ ] Comprehensive testing - - [ ] Unit tests for core components - - [ ] Integration tests for LLM integration - - [ ] End-to-end game flow tests - - [ ] User testing and feedback -- [ ] Performance optimization - - [ ] Minimize LLM token usage - - [ ] Optimize world model for larger games -- [ ] Refine prompting strategies based on testing +### 5. Animation Queue Enhancements +- [ ] Implement proper queue control mechanisms + - [ ] Add pause/resume functionality + - [ ] Implement more robust animation timing + - [ ] Add priority management for animations -## Phase 8: Documentation and Release -- [x] Complete user documentation - - [x] Installation guide - - [ ] World creation guide - - [ ] Configuration reference -- [ ] Complete developer documentation - - [ ] Architecture overview - - [ ] API reference - - [ ] Extension guide -- [ ] Create example worlds and games -- [ ] Prepare for initial release \ No newline at end of file +### 6. UI Controller Cleanup +- [ ] Fix duplicate methods in UI Controller + - [ ] Deduplicate code for creating UI elements + - [ ] Consolidate event handling functions +- [ ] Remove redundant `ModuleEvent` class implementation + - [ ] Use the shared implementation from `base-module.js` + +### 7. Kokoro Loading Implementation +- [ ] Implement proper `requestIdleCallback` for Kokoro loading + - [ ] Follow the pattern described in the specification + - [ ] Add progress reporting during Kokoro loading +- [ ] Fix event handling for loading completion + +## Lower Priority (Refinements & Optimizations) + +### 8. Code Quality & Consistency +- [ ] Standardize module registration pattern + - [ ] Ensure all modules follow the same pattern + - [ ] Fix inconsistencies in export approaches +- [ ] Improve module progress reporting + - [ ] Make progress reporting more granular + - [ ] Add more descriptive status messages + +### 9. Error Handling Improvements +- [ ] Add better error recovery mechanisms + - [ ] Implement fallbacks for critical failures + - [ ] Add user-friendly error messages +- [ ] Improve error logging + - [ ] Add structured error reporting + - [ ] Implement debugging tools + +### 10. Performance Optimizations +- [ ] Optimize module loading sequence + - [ ] Prioritize critical modules + - [ ] Defer non-essential loading +- [ ] Improve resource utilization + - [ ] Minimize memory footprint + - [ ] Reduce CPU usage during animations + +## Documentation & Testing + +### 11. Documentation +- [ ] Add JSDoc comments to all public methods +- [ ] Create architectural documentation + - [ ] Document module dependencies + - [ ] Explain event system +- [ ] Add example usage for modules + +### 12. Testing +- [ ] Create unit tests for modules +- [ ] Implement integration tests for module system +- [ ] Add browser compatibility tests + +## Future Enhancements + +### 13. New Features +- [ ] Add module versioning support +- [ ] Implement module hot-reloading +- [ ] Create plugin system for extending modules +- [ ] Add internationalization support for UI \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 68bff76..07d116b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^5.1.0", + "hyphenopoly": "^6.0.0", "js-yaml": "^4.1.0", "kokoro-js": "^1.2.0", "openai": "^4.91.0", @@ -4321,6 +4322,15 @@ "ms": "^2.0.0" } }, + "node_modules/hyphenopoly": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hyphenopoly/-/hyphenopoly-6.0.0.tgz", + "integrity": "sha512-42M87fsJSu0jRiCZqlVsaBwY5onH6/6y5akaLW794wsc2M4hLj875ZeloQG8yLhlaSQRZEgxz/SQAVn5LVVthg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", diff --git a/package.json b/package.json index 551272a..f878d53 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^5.1.0", + "hyphenopoly": "^6.0.0", "js-yaml": "^4.1.0", "kokoro-js": "^1.2.0", "openai": "^4.91.0", diff --git a/references/SPECIFICATION.md b/references/SPECIFICATION.md new file mode 100644 index 0000000..a8417d9 --- /dev/null +++ b/references/SPECIFICATION.md @@ -0,0 +1,259 @@ +# Code Guidlines + +**1. Asynchronous Programming Principles:** + +* **Primary Mechanism:** Use `async`/`await` and Promises for handling asynchronous operations. +* **Non-Blocking:** Ensure the main thread remains responsive. Long-running operations (like Kokoro loading) should be handled in a way that doesn't block UI updates or animations (e.g., using `requestIdleCallback` if appropriate, or careful yielding). +* **Event-Driven Communication:** Use a dedicated event system (like the `ModuleEvent` class created) for communication between the loader and modules (e.g., for progress updates, state changes, messages) instead of injecting callbacks directly from the loader into module methods. + +**2. Module System Standards & Dependency Management:** + +* **Native ES Modules:** Utilize the browser's native ES Module system (`import`/`export`, ` + + + + + + + + + + + + + + + + + diff --git a/references/input-handler.js b/references/input-handler.js new file mode 100644 index 0000000..9584af3 --- /dev/null +++ b/references/input-handler.js @@ -0,0 +1,290 @@ +/** + * 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/references/ui-controller.js b/references/ui-controller.js new file mode 100644 index 0000000..5b2d90d --- /dev/null +++ b/references/ui-controller.js @@ -0,0 +1,441 @@ +/** + * 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 + + // Callbacks for actions (to be set by AnimatedFiction) + this.onRestartRequest = null; + this.onSaveRequest = null; + this.onLoadRequest = null; + + // Active TTS handler (set via setTtsHandler) + this.ttsHandler = null; + + // 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 + } + + /** + * Set up event listeners + */ + setupEventListeners() { + // Speed slider + if (this.speedSlider) { + this.speedSlider.addEventListener('input', this.handleSpeedChange.bind(this)); + } + + // 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(); + } + }); + + document.getElementById('page_right')?.addEventListener('click', this.handleFastForward.bind(this)); + + // 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; + + 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; + + 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; + } + // 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); + } + } + } 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); + } + } + + /** + * 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; + } + }); + } + + /** + * 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 = ''; + } + } + + /** + * 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]; + } + }); + }); + } else { + console.error(`Locale ${locale} is not defined`); + } + } +}