Minor cleanup.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 MiB |
@@ -1,99 +1,106 @@
|
|||||||
# Project Implementation Plan
|
# Module System Refactoring TODO
|
||||||
|
|
||||||
## Phase 1: Project Setup and Basic Structure
|
## High Priority (Critical Architectural Issues)
|
||||||
- [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
|
|
||||||
|
|
||||||
## Phase 2: World Model Implementation
|
### 1. Asynchronous Flow Control Improvements
|
||||||
- [ ] Define YAML schema for world elements
|
- [ ] Remove all `setTimeout` calls used for synchronization in modules
|
||||||
- [ ] Room schema (description, exits, objects, characters)
|
- [X] Replace timeout in `browser-tts-handler.js` with proper Promise handling for voice loading
|
||||||
- [ ] Object schema (description, properties, allowed actions)
|
- [X] Eliminate race condition in `tts-player.js` that uses a hard-coded 1000ms timeout
|
||||||
- [ ] NPC schema (description, dialogue, behavior)
|
- [ ] Remove all `setTimeout` calls in `ui-controller.js` for UI updates
|
||||||
- [ ] Action schema (conditions, effects)
|
- [ ] Implement proper Promise-based flow control in all modules
|
||||||
- [x] Implement YAML parser and validator
|
- [ ] Update `kokoro-handler.js` to correctly handle loading events
|
||||||
- [ ] Create the world model core
|
- [ ] Ensure all `async/await` patterns follow best practices
|
||||||
- [ ] Game state management
|
- [ ] Fix race conditions in module loading sequences
|
||||||
- [ ] Room navigation
|
|
||||||
- [ ] Object interaction
|
|
||||||
- [ ] NPC interaction
|
|
||||||
- [ ] Action processing logic
|
|
||||||
- [x] Create a simple test world in YAML format
|
|
||||||
- [ ] Implement unit tests for world model
|
|
||||||
|
|
||||||
## Phase 3: LLM Integration
|
### 2. Module State Management
|
||||||
- [x] Research and select appropriate OpenRouter model
|
- [ ] Fix premature reporting of `FINISHED` state
|
||||||
- [x] Implement OpenRouter API client
|
- [ ] Ensure `tts-player.js` properly waits for Kokoro loading before reporting FINISHED
|
||||||
- [x] Configuration and authentication
|
- [ ] Add proper state checks in all modules before reporting FINISHED
|
||||||
- [x] API request/response handling
|
- [ ] Implement proper state transition reporting
|
||||||
- [ ] Rate limiting and error handling
|
- [ ] Update modules to use event system for reporting state transitions
|
||||||
- [ ] Design LLM prompting strategy
|
- [ ] Add better error handling during module initialization
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
## Phase 4: Game Engine Core
|
### 3. Module Dependencies & Loading
|
||||||
- [x] Implement the game loop
|
- [ ] Fix missing dependency declarations
|
||||||
- [x] Input handling
|
- [ ] Update `ui-controller.js` to properly declare its TTS dependency
|
||||||
- [ ] Action processing via LLM
|
- [ ] Ensure all modules correctly specify all dependencies
|
||||||
- [ ] World model updating
|
- [ ] Remove dependency availability checks within modules
|
||||||
- [ ] Response generation via LLM
|
- [ ] Remove conditional checks like `if (!this.ttsHandler)` in `ui-controller.js`
|
||||||
- [ ] Output formatting
|
- [ ] Rely on the module loader for dependency management
|
||||||
- [ ] Implement saving/loading game state
|
|
||||||
- [ ] Add game configuration options
|
|
||||||
- [ ] Implement logging for debugging
|
|
||||||
|
|
||||||
## Phase 5: User Interface
|
## Medium Priority (Functionality & Implementation Issues)
|
||||||
- [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
|
|
||||||
|
|
||||||
## Phase 6: Advanced Features
|
### 4. TTS Handler Implementation
|
||||||
- [ ] Implement integration layer for Z-machine
|
- [ ] Implement missing `tts-handler.js` file content
|
||||||
- [ ] Research Z-machine libraries
|
- [ ] Create proper implementation with consistent interface
|
||||||
- [ ] Create adapter for Z-machine to world model interface
|
- [ ] Ensure it uses proper event-based communication
|
||||||
- [ ] Test with classic Infocom games
|
- [ ] Fix inconsistent event usage across TTS handlers
|
||||||
- [ ] Add advanced LLM features
|
- [ ] Replace direct callbacks with event system
|
||||||
- [ ] Character styles and narrative tones
|
- [ ] Standardize event names and parameters
|
||||||
- [ ] Memory and reference to past events
|
|
||||||
- [ ] Player character personality modeling
|
|
||||||
- [ ] Create plugin system for extending world model capabilities
|
|
||||||
|
|
||||||
## Phase 7: Testing and Refinement
|
### 5. Animation Queue Enhancements
|
||||||
- [ ] Comprehensive testing
|
- [ ] Implement proper queue control mechanisms
|
||||||
- [ ] Unit tests for core components
|
- [ ] Add pause/resume functionality
|
||||||
- [ ] Integration tests for LLM integration
|
- [ ] Implement more robust animation timing
|
||||||
- [ ] End-to-end game flow tests
|
- [ ] Add priority management for animations
|
||||||
- [ ] User testing and feedback
|
|
||||||
- [ ] Performance optimization
|
|
||||||
- [ ] Minimize LLM token usage
|
|
||||||
- [ ] Optimize world model for larger games
|
|
||||||
- [ ] Refine prompting strategies based on testing
|
|
||||||
|
|
||||||
## Phase 8: Documentation and Release
|
### 6. UI Controller Cleanup
|
||||||
- [x] Complete user documentation
|
- [ ] Fix duplicate methods in UI Controller
|
||||||
- [x] Installation guide
|
- [ ] Deduplicate code for creating UI elements
|
||||||
- [ ] World creation guide
|
- [ ] Consolidate event handling functions
|
||||||
- [ ] Configuration reference
|
- [ ] Remove redundant `ModuleEvent` class implementation
|
||||||
- [ ] Complete developer documentation
|
- [ ] Use the shared implementation from `base-module.js`
|
||||||
- [ ] Architecture overview
|
|
||||||
- [ ] API reference
|
### 7. Kokoro Loading Implementation
|
||||||
- [ ] Extension guide
|
- [ ] Implement proper `requestIdleCallback` for Kokoro loading
|
||||||
- [ ] Create example worlds and games
|
- [ ] Follow the pattern described in the specification
|
||||||
- [ ] Prepare for initial release
|
- [ ] 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
|
||||||
Generated
+10
@@ -13,6 +13,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"hyphenopoly": "^6.0.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"kokoro-js": "^1.2.0",
|
"kokoro-js": "^1.2.0",
|
||||||
"openai": "^4.91.0",
|
"openai": "^4.91.0",
|
||||||
@@ -4321,6 +4322,15 @@
|
|||||||
"ms": "^2.0.0"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"hyphenopoly": "^6.0.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"kokoro-js": "^1.2.0",
|
"kokoro-js": "^1.2.0",
|
||||||
"openai": "^4.91.0",
|
"openai": "^4.91.0",
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user