Minor cleanup.

This commit is contained in:
2025-04-04 00:02:28 +00:00
parent aa29a6fd93
commit 02c7b9ef28
9 changed files with 2202 additions and 91 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

+98 -91
View File
@@ -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
### 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
+10
View File
@@ -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",
+1
View File
@@ -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",
+259
View File
@@ -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`, `<script type="module">`) without relying on build tools.
* **Lean Loader:** The `loader.js` file should be focused *only* on:
* Orchestrating the loading of module scripts.
* Monitoring module initialization progress and state via the event system.
* Displaying the loading status UI.
* Hiding the overlay and potentially starting the main application loop *after* all modules are finished.
* **Module Responsibility:** All module-specific logic, configuration, resource loading (like CSS, images, or specific libraries like Kokoro), and detailed progress reporting should reside *within* the respective module file, not in `loader.js`.
* **Dependency Declaration:** Modules must declare their dependencies (e.g., `ui-controller` depends on `tts` and `animation-queue`).
* **Loader Enforces Order:** The loader is responsible for ensuring that a module's `init` phase only begins *after* all its declared dependencies have reached the `FINISHED` state.
* **Rely on Dependency Management:** Modules should *assume* their dependencies will be loaded and ready before their `init` function is called by the loader. There should be **no** conditional checks within a module like `if (dependencyModule)` with fallbacks for when the dependency isn't ready.
**3. Module Interface & Code Sharing:**
* **Base Class:** Use a `BaseModule` class that all modules extend. This enforces a consistent interface (e.g., `initializeInterface`, `getState`) and provides shared functionality (e.g., `changeState`, `reportProgress`, event dispatching).
* **Module Registry:** Use a central `moduleRegistry` to register modules and facilitate dependency checking and management.
* **Preserve Functionality:** When adapting existing modules (like `ui-controller`) to the new `BaseModule` interface, all original functionality must be preserved and integrated correctly, not replaced with placeholders.
**4. State Management:**
* **Defined States:** Modules must adhere to the defined states: `PENDING`, `LOADING` (script loading), `WAITING` (waiting for dependencies), `INITIALIZING` (running `init` logic), `FINISHED`, `ERROR`.
* **Accurate Reporting:** Modules must accurately report their state transitions via the event system. A module (like `tts`) should not report `FINISHED` until all its critical internal operations (including background loading like Kokoro) are complete. The loader's UI must display these states correctly.
**5. Handling `setTimeout` and Fallbacks:**
* **`setTimeout` for Flow Control/Synchronization:** **Strictly prohibited.** Using `setTimeout` to wait for asynchronous operations to complete, fix timing issues, or manage dependencies is considered a hack and indicates a flaw in the asynchronous architecture. Proper use of `async`/`await`, Promises, and the loader's dependency management should make this unnecessary.
* **`setTimeout` for Delays:** Acceptable *only* within well-encapsulated components for specific, justifiable reasons (like debouncing, throttling, or potentially *very* short delays *if absolutely unavoidable* after direct DOM manipulation, though this should also be minimized). It must **not** be used to paper over asynchronous race conditions or timing problems. The `AnimationQueue` is an acceptable place for internal scheduling timeouts, but application code calling it should rely on its event-driven nature.
* **Fallbacks for Missing Dependencies:** **Strictly prohibited.** Code within a module should not check if a dependency exists and provide a fallback path. The module loader's responsibility is to guarantee dependencies are met before initializing the module. Errors should be handled for *actual* failures during initialization, not for unmet dependencies (which indicates a loader bug).
Adhering to these principles will lead to a cleaner, more robust, and maintainable asynchronous module loading system.
# Module Loader System Architecture
The module loader system is designed to manage the loading and initialization of modular components in a structured, dependency-aware manner with visual progress reporting.
## Overall Architecture
1. **Module Registry Pattern**: Uses a centralized registry to track and manage all modules and their states.
2. **Event-Driven Communication**: Modules communicate with the loader and each other through custom events.
3. **Progress Visualization**: Provides a visual loading overlay with per-module progress tracking.
4. **State Management**: Tracks each module through defined states (PENDING, LOADING, WAITING, INITIALIZING, FINISHED, ERROR).
5. **Dependency Resolution**: Handles module dependencies to ensure proper initialization order.
## Core Components
1. **ModuleRegistry**: Central repository for all modules
- Tracks registration and availability of modules
- Manages promises for module readiness
- Provides dependency resolution through `waitForModule` and `waitForModules`
2. **BaseModule**: Abstract base class that all modules extend
- Implements standard lifecycle methods
- Handles progress reporting and state changes
- Provides consistent interface for the loader
3. **ModuleLoader**: Main orchestrator of the loading process
- Dynamically loads module scripts
- Creates and manages the visual loading interface
- Initializes modules in the correct order
- Tracks and displays overall loading progress
4. **ModuleEvent**: Custom event system for inter-module communication
## Loading Sequence
1. HTML page loads and includes the loader script as a module
2. DOMContentLoaded triggers the loader initialization
3. Loader creates the loading UI and registers event listeners
4. Module scripts are loaded dynamically in parallel
5. Each module registers itself with the registry
6. Modules are initialized with dependency checking
7. Progress is reported and visualized throughout
8. When all modules reach FINISHED state, loading overlay is hidden
## Module Lifecycle
1. **PENDING**: Initial state before loading begins
2. **LOADING**: Module is loading dependencies
3. **WAITING**: Module is waiting for dependencies to be ready
4. **INITIALIZING**: Module's initialize() method is executing
5. **FINISHED**: Module is fully initialized and ready
6. **ERROR**: Module encountered an error during initialization
## Integration Pattern
Modules follow a consistent registration pattern:
```javascript
// Create the singleton instance
const ModuleName = new ModuleNameClass();
// Register with the module registry
moduleRegistry.register(ModuleName);
// Export the module
export { ModuleName };
// Keep a reference in window for loader system
window.ModuleName = ModuleName;
```
This design creates a flexible, maintainable system for loading complex applications with multiple interdependent components, prioritizing both user experience and performance.
# TTS System Structure & Kokoro Loading
After reviewing our chat history, here's a summary of the TTS system structure and how we decided to load the Kokoro TTS engine:
## Overall TTS System Architecture
1. **Modular Design**: The TTS system uses a modular architecture with multiple handler classes, each implementing a different TTS approach.
2. **Three TTS Providers**:
- `BrowserTTSHandler` - Uses the built-in Web Speech API
- `KokoroHandler` - Uses Kokoro.js neural TTS for high-quality voices
- `ApiTTSHandler` - Uses external TTS services like ElevenLabs
3. **Factory Pattern**: `TTSFactory` manages the handlers, provides a unified interface, and handles provider switching.
4. **Module System**: `TTSPlayer` module is registered with the `moduleRegistry` as part of the modular loading system.
## Loading Sequence
1. The module loader first loads `tts-player.js`, which in turn loads the `tts-factory.js`.
2. The factory initializes providers in order of preference:
- First loads the `BrowserTTSHandler` for immediate low-quality TTS
- Then loads the `ApiTTSHandler` if configured
- Finally attempts to load `KokoroHandler` in the background with low priority
3. The system uses the best available provider, with a preference for Kokoro when available.
## Kokoro TTS Loading Strategy
After consulting the documentation (https://www.npmjs.com/package/kokoro-js), we made these decisions:
1. **Low-Priority Loading**: Kokoro is loaded with `requestIdleCallback` to avoid impacting page performance.
2. **Kokoro npm package integration**: Load Kokoro directly from the local server:
'/js/kokoro-js.js' contains the minified complete code of the kokoro npm package copied from the node_modules folder to the public directory. Do not try to read or change it, it is too big!
3. **Pipeline Creation**: Per documentation, we use the pipeline pattern:
```javascript
this.kokoro = await window.kokoroTTS.pipeline('text-to-speech', {
quantized: true,
progress_callback: this.progressCallback
});
```
4. **Voice List**: We hardcoded the available voices rather than querying them dynamically.
5. **Audio Playback**: Synthesis returns an audio element which we play:
```javascript
const audio = await this.kokoro(processedText, {
voice: this.voiceOptions.voice,
speed: this.voiceOptions.speed
});
audio.play();
```
## User Experience Flow
1. User sees page immediately with browser TTS enabled (fast startup)
2. Kokoro loads in background without blocking the interface
3. Once Kokoro is ready, TTS switches to higher quality neural TTS
4. User can manually switch between providers via the UI if desired
This design prioritizes performance and user experience, making the TTS system both flexible and resource-efficient.
# Important practices
- Always import the following error, when debugging console output: onpage-dialog.preload.js:121 Uncaught ReferenceError: browser is not defined. This is producced by the installed adblocker and has nothing to do with our project.
# Text-to-Speech Synchronization Architecture
The TTS system needs to be synchronized with text animations to create a cohesive user experience. This section outlines the requirements and implementation approach.
## Transition to Game
The overlay fades away as the first scheduled animation.
- This fade animation is handled by the animation scheduler module
- Only after successful fade-out does the game loop start
- Socket connection is established and begins receiving text
## Text Buffering & Sentence Processing
1. **Text Buffer Collection**: Incoming text from sockets is collected in a buffer.
- System can receive fragments of any size (single letters, words, sentences, paragraphs)
- All text is accumulated in the buffer regardless of fragment size
- Buffer handles partial/incomplete text gracefully
2. **Sentence Detection**: The buffer identifies complete sentences.
- When full sentences are detected, they are extracted from the buffer
- If multiple sentences arrive simultaneously, they are split and processed individually
- Remaining partial sentences stay in the buffer until completion
## Synchronized Playback
1. **TTS Generation Queue**: Complete sentences enter the TTS generation queue.
- Generation begins immediately if no other sentence is being processed
- Results are cached for immediate playback when needed
2. **Animation Timing**: Animation speed is synchronized with audio duration.
- The system calculates animation duration to match TTS audio length exactly
- Both animation and audio start simultaneously
- Animation completes at the same time as audio playback
3. **Playback Pipeline**: Continuous processing of sentences.
- As soon as one sentence completes playback, the next begins
- Next sentence generation starts during current sentence playback
- This creates a seamless reading experience
## Fast-Forward & Control Flow
1. **Fast-Forward Behavior**: User can skip current sentence.
- Pressing the designated fast-forward key completes current animation immediately
- TTS audio is faded out and stopped
- System advances to next sentence
2. **Resource Management**: TTS generation is resource-conscious.
- Uses only CPU/GPU resources not needed for animation
- Generation process can be cancelled by fast-forward
- System prioritizes smooth animation over TTS preparation
3. **Loading States**: Animation waits for TTS when necessary.
- If next sentence TTS generation isn't ready when needed, animation pauses
- Fast-forward key can skip incomplete generation
- User is never blocked completely by TTS generation
## Persistent Configuration
1. **Options Storage**: The persistence-manager stores TTS settings.
- Speech on/off state is remembered
- Speed settings are preserved between sessions
- Voice preferences are stored
2. **Options UI**: Add an options button and modal dialog.
- Show additional options in a modal window
- Include volume sliders:
- Master volume control
- TTS volume control
- Music volume control
- Sound effects volume control
- Include manual TTS system selection
- All settings are persisted via the persistence-manager
This synchronized approach ensures that text animations and speech work together seamlessly, creating a more immersive storytelling experience while maintaining smooth performance.
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<!-- meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'blob'; style-src 'self' 'unsafe-inline'" -->
<title>ai-fiction Book Runtime (Modular Version)</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<p id="versions">We are using Node.js <span id="node-version"></span>,
Chromium <span id="chrome-version"></span>,
and Electron <span id="electron-version"></span>.</p>
<div id="book">
<div id="page_left">
<div class="header">
<h2 class="byline l10n-by">powered by Generative AI</h2>
<h1 class="title">AI Interactive Fiction</h1>
<h3 class="subtitle">An open-world text adventure</h3>
<div class="separator"><double></double></div>
</div>
<div id="controls" class="buttons">
<a class="l10n-speech" id="speech" title="Toggle text to speech" disabled="disabled">speech</a>
<span><a id="speed_reset"><span class="l10n-speed">speed<sup>*</sup></span></a><input type="range" min="0" max="100" value="50" id="speed" name="speed" /></span>
<a class="l10n-restart" id="rewind" title="Restart story from beginning" disabled="disabled">restart</a>
<a class="l10n-save" id="save" title="Save progress">save</a>
<a class="l10n-load" id="reload" title="Reload from save point" disabled="disabled">load</a>
</div>
<div id="choices" class="container">
<div id="command_history">
<!-- Previous commands and responses will be displayed here -->
</div>
<div id="command_input">
<div class="input-wrapper">
<textarea id="player_input" placeholder="Enter your command..." rows="1" autofocus></textarea>
<span id="cursor"></span>
</div>
</div>
</div>
<div class="l10n-remark" id="remark"><i><sup>*</sup>click on page or press spacebar to fast forward text animation</i></div>
</div>
<div id="page_right">
<div id="story" class="container">
</div>
</div>
</div>
<div id="ruler"></div>
<div class="l10n-prompt" id="indent">What do you want to do next?</div>
<div id="lighting" />
<!-- Socket.io library for client-server communication -->
<script src="/socket.io/socket.io.js"></script>
<!-- Core libraries -->
<script src="js/smartypants.js"></script>
<script src="js/linked-list.js"></script>
<script src="js/linebreak.js"></script>
<script src="js/knuth-and-plass.js"></script>
<script src="js/Hyphenopoly_Loader.js"></script>
<script>
var locale = "en";
// Create global variables needed by the modules
window.rstack = [];
</script>
<!-- Kokoro TTS library - load as module -->
<script type="module">
try {
// Import KokoroTTS class from the module
const kokoroModule = await import('./js/kokoro-js.js');
// Expose the KokoroTTS class globally
window.KokoroTTS = kokoroModule.KokoroTTS;
console.log('KokoroTTS class loaded and exposed to window');
// Dispatch an event to signal that the class is ready
const event = new CustomEvent('kokoro-class-loaded');
window.dispatchEvent(event);
} catch (error) {
console.error('Failed to load KokoroTTS module:', error);
// Dispatch an event even on failure so handlers don't wait forever
const event = new CustomEvent('kokoro-class-load-failed');
window.dispatchEvent(event);
}
</script>
<!-- Main application script - imports all needed modules -->
<script type="module" src="js/ai-fiction.js"></script>
</body>
</html>
+290
View File
@@ -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 = '&nbsp;'; // 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();
}
});
*/
}
}
+441
View File
@@ -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`);
}
}
}