Split everything up into dynamically loaded modules.
This commit is contained in:
@@ -1,756 +0,0 @@
|
||||
/**
|
||||
* Animated Fiction - Main Application Integration
|
||||
* Integrates all modules to create an interactive fiction experience.
|
||||
*/
|
||||
import { AnimationQueue } from './animation-queue.js';
|
||||
import { TextProcessor } from './text-processor.js';
|
||||
import { ParagraphLayout } from './paragraph-layout.js';
|
||||
import { LayoutRenderer } from './layout-renderer.js';
|
||||
import { AudioManager } from './audio-manager.js';
|
||||
import { TtsPlayer } from './tts-player.js';
|
||||
import { PersistenceManager } from './persistence-manager.js';
|
||||
import { InputHandler } from './input-handler.js';
|
||||
import { SocketClient } from './socket-client.js';
|
||||
import { UiController } from './ui-controller.js';
|
||||
import { ttsFactory } from './tts-factory.js';
|
||||
|
||||
export class AnimatedFiction {
|
||||
/**
|
||||
* Create a new AnimatedFiction application
|
||||
* @param {Object} config - Configuration options
|
||||
* @param {string} config.serverUrl - URL for the Socket.IO server (optional, defaults to window.location.origin)
|
||||
* @param {string} config.storyContainerId - ID of the story container element
|
||||
* @param {string} config.commandHistoryContainerId - ID of the command history container element
|
||||
* @param {string} config.ttsApiKey - API key for TTS service (if applicable)
|
||||
* @param {number} config.initialSpeed - Initial animation speed
|
||||
* @param {string} config.locale - Locale for translations
|
||||
* @param {Object} config.translations - Translations object
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
this.storyContainer = document.getElementById(config.storyContainerId || 'story');
|
||||
this.commandHistoryContainer = document.getElementById(config.commandHistoryContainerId || 'command_history'); // Added for user commands
|
||||
|
||||
// Game state
|
||||
this.gameState = {
|
||||
started: false,
|
||||
currentRoomId: '',
|
||||
isThinking: false,
|
||||
textSpeed: config.initialSpeed || 50 // Keep track of speed locally if needed
|
||||
};
|
||||
this.currentCommandTimeout = null; // To handle server timeouts
|
||||
|
||||
// Initialize core components
|
||||
this.initializeComponents();
|
||||
this.bindGlobalEvents(); // Add global event bindings like focus management
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all components
|
||||
*/
|
||||
initializeComponents() {
|
||||
// 1. Core Components
|
||||
this.animationQueue = new AnimationQueue();
|
||||
// Initialize TextProcessor without hyphenator initially
|
||||
this.textProcessor = new TextProcessor(window.SmartyPants);
|
||||
// Pass null for measure func initially, it will be provided after DOM measurement setup
|
||||
this.paragraphLayout = new ParagraphLayout(window.kap, null);
|
||||
this.layoutRenderer = new LayoutRenderer(this.animationQueue);
|
||||
this.audioManager = new AudioManager();
|
||||
this.ttsPlayer = new TtsPlayer({
|
||||
apiKey: this.config.ttsApiKey,
|
||||
animationQueue: this.animationQueue
|
||||
});
|
||||
this.persistenceManager = new PersistenceManager({
|
||||
storage: localStorage // Note: Persistence might need rework for socket state
|
||||
});
|
||||
|
||||
// Initialize the DOM-based text measurement system
|
||||
this.initializeTextMeasurement();
|
||||
|
||||
// 2. Input, Socket, and UI Controller
|
||||
this.inputHandler = new InputHandler('player_input', 'cursor');
|
||||
this.socketClient = new SocketClient(this.config.serverUrl); // Pass server URL if provided
|
||||
|
||||
this.uiController = new UiController({
|
||||
// storyPlayer: this.storyPlayer, // Remove storyPlayer dependency
|
||||
animationQueue: this.animationQueue, // Keep for speed control
|
||||
ttsPlayer: this.ttsPlayer, // Keep for TTS toggle
|
||||
speedSliderElement: document.getElementById('speed'),
|
||||
// choiceContainerElement: document.getElementById('choices'), // Choices are now suggestions/input
|
||||
commandHistoryContainerElement: this.commandHistoryContainer, // Pass command history
|
||||
storyContainerElement: this.storyContainer, // Pass story container
|
||||
rewindButtonElement: document.getElementById('rewind'),
|
||||
saveButtonElement: document.getElementById('save'),
|
||||
loadButtonElement: document.getElementById('reload'),
|
||||
speechButtonElement: document.getElementById('speech'),
|
||||
speedResetElement: document.getElementById('speed_reset'),
|
||||
inputHandler: this.inputHandler, // Pass input handler for suggestion clicks etc.
|
||||
socketClient: this.socketClient, // Pass socket client for actions
|
||||
translations: this.config.translations,
|
||||
locale: this.config.locale || 'en-us'
|
||||
});
|
||||
|
||||
// Configure TTS Player based on factory readiness
|
||||
this.listenForTTSReady();
|
||||
|
||||
// Link InputHandler submission to SocketClient
|
||||
this.inputHandler.onCommandSubmit((command) => {
|
||||
this.submitCommand(command);
|
||||
});
|
||||
|
||||
// Link UI Controller actions to SocketClient
|
||||
this.uiController.onRestartRequest = () => this.socketClient.requestStartGame();
|
||||
this.uiController.onSaveRequest = () => this.socketClient.requestSaveGame();
|
||||
this.uiController.onLoadRequest = () => this.socketClient.requestLoadGame();
|
||||
// TTS toggle is likely handled within UiController using TtsPlayer/ttsHandler
|
||||
|
||||
// Initialize Socket Listeners
|
||||
this.initializeSocketListeners();
|
||||
} // <<< Re-added missing closing brace for initializeComponents
|
||||
|
||||
/**
|
||||
* Initialize DOM-based text measurement system
|
||||
* Recreates the ruler stack system from the original game.js
|
||||
*/
|
||||
initializeTextMeasurement() {
|
||||
// Set up ruler DOM element & stack for text measurement
|
||||
this.rulerElement = document.getElementById('ruler');
|
||||
if (!this.rulerElement) {
|
||||
console.error("AnimatedFiction: Ruler element not found! Text measurement will fail.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset and initialize ruler stack
|
||||
this.rulerStack = [this.rulerElement];
|
||||
|
||||
// Create the DOM-based text measurement function
|
||||
this.measureText = (str) => {
|
||||
// Get current ruler from stack top
|
||||
let ruler = this.rulerStack[this.rulerStack.length - 1];
|
||||
|
||||
// Handle HTML tags specially
|
||||
if (str.substr(0, 2) == '</') {
|
||||
// End tag: pop the last child off the stack
|
||||
let child = this.rulerStack.pop();
|
||||
ruler = this.rulerStack[this.rulerStack.length - 1];
|
||||
ruler.removeChild(child);
|
||||
return 0;
|
||||
} else if (str.substr(0, 1) == '<') {
|
||||
// Start tag: create and push element onto stack
|
||||
let tmp = document.createElement('div');
|
||||
tmp.innerHTML = str;
|
||||
let word = tmp.firstChild;
|
||||
ruler = this.rulerStack[this.rulerStack.length - 1];
|
||||
this.rulerStack.push(word);
|
||||
ruler.appendChild(word);
|
||||
return 0;
|
||||
} else if (str === '|') {
|
||||
// Pipe character (hyphenation marker): zero width
|
||||
return 0;
|
||||
} else if (str === ' ') {
|
||||
// Non-breaking space for measurement
|
||||
str = '\u00A0';
|
||||
}
|
||||
|
||||
// For normal text, measure width with a text node
|
||||
ruler = this.rulerStack[this.rulerStack.length - 1];
|
||||
let textNode = document.createTextNode(str);
|
||||
ruler.appendChild(textNode);
|
||||
let width = ruler.getClientRects()[0].width;
|
||||
ruler.removeChild(textNode);
|
||||
|
||||
return width;
|
||||
};
|
||||
|
||||
// Provide the measurement function to ParagraphLayout
|
||||
if (this.paragraphLayout) {
|
||||
this.paragraphLayout.setMeasureFunction(this.measureText);
|
||||
}
|
||||
}
|
||||
|
||||
// Removed measureDomText method
|
||||
|
||||
/**
|
||||
* Listen for the tts-ready event from the factory
|
||||
*/
|
||||
listenForTTSReady() {
|
||||
window.addEventListener('tts-ready', (event) => {
|
||||
console.log('AnimatedFiction received tts-ready event:', event.detail);
|
||||
const { available, type, handler } = event.detail;
|
||||
|
||||
if (available && handler) {
|
||||
console.log(`AnimatedFiction: Using ${type} TTS system with handler:`, handler);
|
||||
|
||||
// Store the handler for direct access
|
||||
window.ttsHandler = handler;
|
||||
|
||||
// Pass the handler to the TtsPlayer if needed
|
||||
if (this.ttsPlayer) {
|
||||
this.ttsPlayer.setTtsHandler(handler);
|
||||
}
|
||||
|
||||
// Ensure UI controller knows about it
|
||||
if (this.uiController) {
|
||||
this.uiController.setTtsHandler(handler);
|
||||
this.uiController.updateSpeechButtonAvailability(available, type);
|
||||
}
|
||||
|
||||
// Set user activation flag once we have user interaction
|
||||
document.addEventListener('click', function setUserActivation() {
|
||||
if (window.ttsHandler) {
|
||||
window.ttsHandler.hasUserActivation = true;
|
||||
// If using Kokoro, try to resume the AudioContext
|
||||
if (window.ttsHandler.audioContext && window.ttsHandler.audioContext.state === 'suspended') {
|
||||
window.ttsHandler.audioContext.resume().catch(err =>
|
||||
console.error('Error resuming AudioContext on click:', err));
|
||||
}
|
||||
}
|
||||
// Only need this once
|
||||
document.removeEventListener('click', setUserActivation);
|
||||
}, { once: false });
|
||||
|
||||
} else {
|
||||
console.warn("AnimatedFiction: No TTS handler available after initialization.");
|
||||
if (this.uiController) {
|
||||
this.uiController.updateSpeechButtonAvailability(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize listeners for SocketClient events.
|
||||
*/
|
||||
initializeSocketListeners() {
|
||||
this.socketClient.on('connect', () => {
|
||||
console.log('AnimatedFiction: Socket connected.');
|
||||
// Automatically start the game once connected
|
||||
if (!this.gameState.started) {
|
||||
// Don't display any message before starting the game
|
||||
this.socketClient.requestStartGame();
|
||||
}
|
||||
});
|
||||
|
||||
this.socketClient.on('connect_error', (error) => {
|
||||
console.error('AnimatedFiction: Socket connection error:', error);
|
||||
this.displaySystemMessage('Connection error. Please check server and network.');
|
||||
});
|
||||
|
||||
this.socketClient.on('disconnect', (reason) => {
|
||||
console.warn('AnimatedFiction: Socket disconnected.', reason);
|
||||
this.displaySystemMessage('Disconnected from server.');
|
||||
this.gameState.started = false; // Reset started state on disconnect
|
||||
this.uiController.updateButtonStates(this.gameState); // Disable buttons
|
||||
});
|
||||
|
||||
this.socketClient.on('gameIntroduction', (data) => {
|
||||
// Clear any existing content
|
||||
this.clearStoryDisplay();
|
||||
|
||||
// Display the introduction text with proper animation
|
||||
this.displayNarrative(data.introduction);
|
||||
this.displayNarrative(data.initialRoomDescription);
|
||||
|
||||
this.gameState.started = true;
|
||||
this.gameState.currentRoomId = data.currentRoomId;
|
||||
this.gameState.isThinking = false;
|
||||
|
||||
this.uiController.updateButtonStates(this.gameState); // Enable buttons
|
||||
this.inputHandler.enableInput();
|
||||
this.inputHandler.focus();
|
||||
});
|
||||
|
||||
this.socketClient.on('narrativeResponse', (data) => {
|
||||
this.handleNarrativeResponse(data);
|
||||
});
|
||||
|
||||
this.socketClient.on('gameSaved', () => {
|
||||
this.displaySystemMessage('Game saved successfully.');
|
||||
this.gameState.canLoad = true; // Assuming save enables load
|
||||
this.uiController.updateButtonStates(this.gameState);
|
||||
});
|
||||
|
||||
this.socketClient.on('gameLoaded', (data) => {
|
||||
this.clearStoryDisplay();
|
||||
this.displaySystemMessage('Game loaded successfully.');
|
||||
this.displayNarrative(data.currentRoomDescription); // Display current room after load
|
||||
|
||||
this.gameState.started = true; // Ensure game is marked as started
|
||||
this.gameState.currentRoomId = data.currentRoomId;
|
||||
this.gameState.isThinking = false;
|
||||
this.gameState.canLoad = true; // Can still load after loading
|
||||
|
||||
this.uiController.updateButtonStates(this.gameState);
|
||||
this.inputHandler.enableInput();
|
||||
this.inputHandler.focus();
|
||||
});
|
||||
|
||||
this.socketClient.on('error', (data) => {
|
||||
this.handleNarrativeResponse({ text: '' }); // Clear thinking indicator on error
|
||||
this.displaySystemMessage(`Server Error: ${data.message}`);
|
||||
this.inputHandler.enableInput(); // Re-enable input on server error
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the narrative response from the server.
|
||||
* @param {object} data - The data received from the server.
|
||||
* @param {string} data.text - The narrative text.
|
||||
* @param {string[]} [data.suggestions] - Optional suggestions.
|
||||
* @param {object} [data.gameState] - Optional updated game state.
|
||||
*/
|
||||
handleNarrativeResponse(data) {
|
||||
// Clear thinking indicator and timeout
|
||||
if (this.currentCommandTimeout) {
|
||||
clearTimeout(this.currentCommandTimeout);
|
||||
this.currentCommandTimeout = null;
|
||||
}
|
||||
this.removeThinkingIndicator();
|
||||
this.gameState.isThinking = false;
|
||||
|
||||
// Display narrative using the proper text processing pipeline
|
||||
if (data.text) {
|
||||
this.displayNarrative(data.text);
|
||||
}
|
||||
|
||||
// Display suggestions
|
||||
if (data.suggestions && data.suggestions.length > 0) {
|
||||
this.displaySuggestions(data.suggestions);
|
||||
}
|
||||
|
||||
// Update game state if provided
|
||||
if (data.gameState) {
|
||||
this.gameState.currentRoomId = data.gameState.currentRoomId;
|
||||
// Update other relevant state parts if needed
|
||||
}
|
||||
|
||||
// Re-enable input and focus
|
||||
this.inputHandler.enableInput();
|
||||
this.scrollToBottom(); // Ensure view is scrolled down
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a command entered by the user.
|
||||
* @param {string} command - The command text.
|
||||
*/
|
||||
submitCommand(command) {
|
||||
if (!this.gameState.started || this.gameState.isThinking) return;
|
||||
|
||||
this.displayUserCommand(command); // Show command in history
|
||||
this.displayThinkingIndicator(); // Show thinking message
|
||||
this.gameState.isThinking = true;
|
||||
|
||||
this.socketClient.sendCommand(command);
|
||||
|
||||
// Failsafe timeout to re-enable input if server doesn't respond
|
||||
this.currentCommandTimeout = setTimeout(() => {
|
||||
if (this.gameState.isThinking) { // Only if still thinking
|
||||
console.warn("Server response timeout.");
|
||||
this.removeThinkingIndicator();
|
||||
this.displaySystemMessage('The server is taking too long to respond.');
|
||||
this.gameState.isThinking = false;
|
||||
this.inputHandler.enableInput();
|
||||
}
|
||||
}, 15000); // 15 seconds timeout
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a narrative paragraph using the rendering pipeline.
|
||||
* @param {string} text - The narrative text.
|
||||
*/
|
||||
displayNarrative(text) {
|
||||
if (!text) return;
|
||||
|
||||
console.log("AnimatedFiction: Displaying narrative text:", text);
|
||||
|
||||
try {
|
||||
// 1. Process the text with TextProcessor (SmartyPants, hyphenation)
|
||||
const processed = this.textProcessor.process(text);
|
||||
|
||||
// 2. Get container width for line measure calculation
|
||||
const containerWidth = this.storyContainer.clientWidth;
|
||||
|
||||
// 3. Setup measures array for the Knuth-Plass algorithm
|
||||
// Calculate indent width for the measures array
|
||||
const indentWidth = 2 * parseFloat(window.getComputedStyle(document.querySelector("#indent")).lineHeight);
|
||||
const measures = [
|
||||
containerWidth, // Full width
|
||||
containerWidth - indentWidth, // Indented width
|
||||
containerWidth - indentWidth * 0.9 // Slightly less indented width
|
||||
];
|
||||
|
||||
// 4. Calculate Layout using the Knuth-Plass algorithm with DOM-based measurement
|
||||
console.log("AnimatedFiction: Calculating paragraph layout with DOM ruler");
|
||||
// We'll use our DOM-based measureText function that we set up in initializeTextMeasurement
|
||||
// Will use reversed measures array as in the original game.js implementation
|
||||
const layout = this.paragraphLayout.calculateLayout(processed, measures.slice().reverse(), true);
|
||||
|
||||
// 5. Render paragraph using the LayoutRenderer
|
||||
console.log("AnimatedFiction: Rendering paragraph with layout data");
|
||||
// Also pass reversed measures array to renderer as in the original
|
||||
const renderResult = this.layoutRenderer.renderParagraph(layout, this.animationQueue.getDelay(), measures.slice().reverse());
|
||||
|
||||
if (!Array.isArray(renderResult) || renderResult.length < 2) {
|
||||
throw new Error("renderParagraph did not return the expected array [element, delay]");
|
||||
}
|
||||
|
||||
const [paragraphElement, finalDelay] = renderResult;
|
||||
|
||||
// Update the animation queue's delay
|
||||
this.animationQueue.setDelay(finalDelay);
|
||||
|
||||
// 6. Append the paragraph element to the story container
|
||||
this.storyContainer.appendChild(paragraphElement);
|
||||
|
||||
// 7. Speak text if TTS is enabled
|
||||
// First check our ttsPlayer
|
||||
if (this.ttsPlayer && this.ttsPlayer.isEnabled()) {
|
||||
console.log("AnimatedFiction: Speaking text with TTS via ttsPlayer");
|
||||
this.ttsPlayer.speak(text);
|
||||
}
|
||||
// Also try the global TTS handler in case ttsPlayer isn't properly configured
|
||||
else if (window.ttsHandler && typeof window.ttsHandler.isEnabled === 'function' && window.ttsHandler.isEnabled()) {
|
||||
console.log("AnimatedFiction: Speaking text with global TTS handler");
|
||||
window.ttsHandler.speak(text);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error during paragraph layout or rendering:", error);
|
||||
console.error(error.stack);
|
||||
// Display raw text as fallback with simple fade-in
|
||||
const fallbackPara = document.createElement('p');
|
||||
fallbackPara.textContent = text;
|
||||
fallbackPara.classList.add("fallback");
|
||||
fallbackPara.classList.add("fade-in");
|
||||
this.storyContainer.appendChild(fallbackPara);
|
||||
|
||||
// Still try to speak the text if TTS is enabled
|
||||
if (this.ttsPlayer && this.ttsPlayer.isEnabled()) {
|
||||
this.ttsPlayer.speak(text);
|
||||
} else if (window.ttsHandler && typeof window.ttsHandler.isEnabled === 'function' && window.ttsHandler.isEnabled()) {
|
||||
window.ttsHandler.speak(text);
|
||||
}
|
||||
}
|
||||
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the user's command in the history.
|
||||
* @param {string} command - The command text.
|
||||
*/
|
||||
displayUserCommand(command) {
|
||||
const element = document.createElement('p');
|
||||
element.className = 'user-input';
|
||||
element.textContent = `> ${command}`;
|
||||
this.commandHistoryContainer.appendChild(element);
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a system message (e.g., errors, confirmations).
|
||||
* @param {string} message - The message text.
|
||||
*/
|
||||
displaySystemMessage(message) {
|
||||
const element = document.createElement('p');
|
||||
element.className = 'system-message';
|
||||
element.textContent = message;
|
||||
this.storyContainer.appendChild(element); // Add to main story flow
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays clickable suggestions.
|
||||
* @param {string[]} suggestions - An array of suggestion strings.
|
||||
*/
|
||||
displaySuggestions(suggestions) {
|
||||
// Remove previous suggestions if any
|
||||
const existingSuggestions = this.storyContainer.querySelector('.suggestions');
|
||||
if (existingSuggestions) {
|
||||
existingSuggestions.remove();
|
||||
}
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.className = 'suggestions';
|
||||
|
||||
const heading = document.createElement('p');
|
||||
heading.textContent = 'Suggestions:'; // TODO: Localize
|
||||
heading.style.fontStyle = 'italic';
|
||||
heading.style.marginTop = '1em';
|
||||
element.appendChild(heading);
|
||||
|
||||
const list = document.createElement('ul');
|
||||
suggestions.forEach(suggestion => {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = suggestion;
|
||||
item.style.cursor = 'pointer';
|
||||
item.addEventListener('click', () => {
|
||||
this.inputHandler.setValue(suggestion); // Set input value
|
||||
this.submitCommand(suggestion); // Submit immediately
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
element.appendChild(list);
|
||||
|
||||
this.storyContainer.appendChild(element);
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
/** Displays a "Thinking..." indicator. */
|
||||
displayThinkingIndicator() {
|
||||
this.removeThinkingIndicator(); // Ensure only one exists
|
||||
const id = 'thinking-' + Date.now();
|
||||
const element = document.createElement('div');
|
||||
element.id = id;
|
||||
element.className = 'thinking';
|
||||
element.innerHTML = '<p>Thinking<span class="loading-indicator"><span>.</span><span>.</span><span>.</span></span></p>'; // TODO: Localize
|
||||
this.storyContainer.appendChild(element);
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
/** Removes the "Thinking..." indicator. */
|
||||
removeThinkingIndicator() {
|
||||
const thinkingElement = this.storyContainer.querySelector('.thinking');
|
||||
if (thinkingElement) {
|
||||
thinkingElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the main story display area. */
|
||||
clearStoryDisplay() {
|
||||
// Clear story container
|
||||
while (this.storyContainer.firstChild) {
|
||||
this.storyContainer.removeChild(this.storyContainer.firstChild);
|
||||
}
|
||||
// Optionally clear command history as well, or keep it
|
||||
// while (this.commandHistoryContainer.firstChild) {
|
||||
// this.commandHistoryContainer.removeChild(this.commandHistoryContainer.firstChild);
|
||||
// }
|
||||
}
|
||||
|
||||
/** Scrolls the main content area to the bottom. */
|
||||
scrollToBottom() {
|
||||
// Scroll the right page (story container's parent)
|
||||
const rightPage = document.getElementById('page_right');
|
||||
if (rightPage) {
|
||||
// Use setTimeout to ensure rendering is complete before scrolling
|
||||
setTimeout(() => {
|
||||
rightPage.scrollTop = rightPage.scrollHeight;
|
||||
}, 50); // Small delay
|
||||
}
|
||||
}
|
||||
|
||||
/** Binds global event listeners like focus management. */
|
||||
bindGlobalEvents() {
|
||||
// Basic focus management: click anywhere focuses input unless on interactive element
|
||||
document.addEventListener('click', (e) => {
|
||||
if (
|
||||
e.target.tagName !== 'BUTTON' &&
|
||||
e.target.tagName !== 'A' &&
|
||||
e.target.tagName !== 'INPUT' && // Allow range slider interaction
|
||||
!e.target.closest('.suggestions li') && // Allow clicking suggestions
|
||||
e.target !== this.inputHandler.playerInput // Don't refocus if already clicking input
|
||||
) {
|
||||
this.inputHandler.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Refocus on window visibility change
|
||||
window.addEventListener('focus', () => this.inputHandler.focus());
|
||||
window.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
setTimeout(() => this.inputHandler.focus(), 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Start the application: Connect socket and set up UI.
|
||||
*/
|
||||
start() {
|
||||
// Set up UI event listeners (speed, TTS, etc.)
|
||||
this.uiController.setupEventListeners();
|
||||
|
||||
// Force initial UI layout calculation immediately to ensure visibility
|
||||
this.uiController.updateBookDimensions();
|
||||
this.uiController.updateParagraphHeight();
|
||||
|
||||
// Set initial speed in AnimationQueue
|
||||
const initialQueueSpeed = Math.pow(100.0 - (this.config.initialSpeed || 50), 3) / 10000 * 10 + 0.01;
|
||||
this.animationQueue.setSpeed(initialQueueSpeed);
|
||||
this.uiController.updateSpeedDisplay(this.config.initialSpeed || 50); // Update slider visually
|
||||
|
||||
// Connect the socket to the server without showing any loading messages
|
||||
console.log("AnimatedFiction: Connecting to server...");
|
||||
this.socketClient.connect();
|
||||
|
||||
this.uiController.handleWindowResize();
|
||||
|
||||
// Initial focus on input
|
||||
this.inputHandler.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the application when the window loads
|
||||
* Using addEventListener instead of window.onload to prevent overriding other handlers
|
||||
*/
|
||||
window.addEventListener('load', async () => {
|
||||
// Define translations first
|
||||
const translations = {
|
||||
'en-us': {
|
||||
by: "powered by Generative AI", // Updated
|
||||
speed: "speed<sup>*</sup>", // Simpler superscript
|
||||
title_speed: "Set speed of text animation",
|
||||
restart: "restart",
|
||||
title_restart: "Restart game from beginning", // Clarified
|
||||
save: "save",
|
||||
title_save: "Save progress",
|
||||
load: "load",
|
||||
title_load: "Reload from save point",
|
||||
prompt: "What do you want to do next?", // Changed from italic
|
||||
remark: "<i><sup>*</sup>click on page or press spacebar to fast forward text animation</i>", // Simplified
|
||||
end: "The End", // Keep for potential future use
|
||||
// Action prompts might not be needed for socket version, keep for now
|
||||
action_examine: "Examine",
|
||||
action_comment: "Comment",
|
||||
action_ask: "Ask",
|
||||
action_interact: "Interact",
|
||||
action_reflect: "Reflect",
|
||||
action_inventory: "Inventory",
|
||||
speech: "speech", // Lowercase to match button style
|
||||
title_speech: "Toggle text to speech",
|
||||
system_error: "Error", // Added
|
||||
system_connecting: "Connecting...", // Added
|
||||
system_thinking: "Thinking", // Added
|
||||
system_suggestions: "Suggestions:", // Added
|
||||
system_save_ok: "Game saved.", // Added
|
||||
system_load_ok: "Game loaded.", // Added
|
||||
system_connection_lost: "Connection lost.", // Added
|
||||
system_restarting: "Restarting game..." // Added
|
||||
},
|
||||
'de': { // Keep German translations, update/add as needed
|
||||
by: "powered by Generative AI",
|
||||
speed: "Geschwindigkeit<sup>*</sup>",
|
||||
title_speed: "Geschwindigkeit der Textanimation einstellen",
|
||||
restart: "Neustart",
|
||||
title_restart: "Spiel von vorne beginnen",
|
||||
save: "Speichern",
|
||||
title_save: "Fortschritt speichern",
|
||||
load: "Laden",
|
||||
title_load: "Gespeicherten Spielstand laden",
|
||||
prompt: "Was möchtest du als Nächstes tun?",
|
||||
remark: "<i><sup>*</sup>Klicke auf die Seite oder drücke die Leertaste, um die Textanimation zu beschleunigen</i>",
|
||||
end: "Ende",
|
||||
action_examine: "Untersuchen",
|
||||
action_comment: "Kommentieren",
|
||||
action_ask: "Fragen",
|
||||
action_interact: "Interagieren",
|
||||
action_reflect: "Nachdenken",
|
||||
action_inventory: "Inventar",
|
||||
speech: "Sprache",
|
||||
title_speech: "Sprachausgabe umschalten",
|
||||
system_error: "Fehler",
|
||||
system_connecting: "Verbinde...",
|
||||
system_thinking: "Denke nach",
|
||||
system_suggestions: "Vorschläge:",
|
||||
system_save_ok: "Spiel gespeichert.",
|
||||
system_load_ok: "Spiel geladen.",
|
||||
system_connection_lost: "Verbindung verloren.",
|
||||
system_restarting: "Starte Spiel neu..."
|
||||
}
|
||||
};
|
||||
|
||||
// Configure Hyphenopoly before creating the application
|
||||
window.Hyphenopoly = window.Hyphenopoly || {};
|
||||
window.Hyphenopoly.config({
|
||||
require: {
|
||||
"en-us": "FORCEHYPHENOPOLY"
|
||||
},
|
||||
paths: {
|
||||
maindir: "./js/",
|
||||
patterndir: "./js/patterns/"
|
||||
},
|
||||
setup: {
|
||||
selectors: {
|
||||
".hyphenate": { // Default selector with soft hyphen
|
||||
hyphen: "\u00AD"
|
||||
},
|
||||
".hyphenatePipe": { // Selector for Knuth-Plass with pipe hyphen
|
||||
hyphen: "|",
|
||||
// Explicitly add minWordLength here as a potential fix
|
||||
// Although it should derive from language, this might help
|
||||
minWordLength: 4
|
||||
}
|
||||
}
|
||||
},
|
||||
handleEvent: {
|
||||
error: function(e) {
|
||||
console.error("Hyphenopoly error:", e);
|
||||
},
|
||||
hyphenopolyEnd: function(e) {
|
||||
console.log("Hyphenopoly fully initialized (hyphenopolyEnd event).");
|
||||
// --- Move hyphenator setup logic inside this event handler ---
|
||||
try {
|
||||
if (window.Hyphenopoly && window.Hyphenopoly.hyphenators) {
|
||||
const hyphenatorPromise = window.Hyphenopoly.hyphenators["en-us"];
|
||||
|
||||
hyphenatorPromise.then(hyphenatorFunction => {
|
||||
console.log("Hyphenator function obtained after hyphenopolyEnd:", hyphenatorFunction);
|
||||
|
||||
if (window.app && window.app.textProcessor) {
|
||||
const hyphenationSelector = ".hyphenatePipe";
|
||||
console.log("Using hyphenation selector:", hyphenationSelector);
|
||||
|
||||
const wrappedHyphenator = (text) => {
|
||||
try {
|
||||
// Don't strip the dot - Hyphenopoly expects the full selector with dot
|
||||
const result = hyphenatorFunction(text, hyphenationSelector);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error during hyphenation call:", error);
|
||||
console.error("Text being hyphenated:", text);
|
||||
console.error("Selector used:", hyphenationSelector);
|
||||
return text; // Fallback
|
||||
}
|
||||
};
|
||||
|
||||
window.app.textProcessor.setHyphenator(wrappedHyphenator);
|
||||
console.log("Hyphenator successfully configured on TextProcessor using selector after hyphenopolyEnd.");
|
||||
|
||||
} else {
|
||||
console.error("Failed to set hyphenator post-hyphenopolyEnd: window.app or window.app.textProcessor not found.");
|
||||
}
|
||||
|
||||
}).catch(err => {
|
||||
console.error("Failed to get hyphenator function from promise post-hyphenopolyEnd:", err);
|
||||
});
|
||||
} else {
|
||||
console.error("Hyphenopoly.hyphenators not found post-hyphenopolyEnd.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("General error setting up hyphenator post-hyphenopolyEnd:", error);
|
||||
}
|
||||
// --- End of moved logic ---
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create and initialize the application
|
||||
window.app = new AnimatedFiction({
|
||||
// storyUrl: 'Herrenhaus.ink.json', // No longer needed for socket version
|
||||
storyContainerId: 'story',
|
||||
commandHistoryContainerId: 'command_history', // Specify history container
|
||||
initialSpeed: 50,
|
||||
locale: window.locale || 'en-us',
|
||||
translations: translations
|
||||
// ttsApiKey: 'YOUR_API_KEY' // Add if needed for specific TTS service via TtsPlayer
|
||||
});
|
||||
|
||||
// Start the application
|
||||
window.app.start();
|
||||
|
||||
// Force another UI update once DOM is completely rendered
|
||||
});
|
||||
+171
-77
@@ -1,117 +1,211 @@
|
||||
/**
|
||||
* AnimationQueue Module
|
||||
* Manages the timing and execution queue for all scheduled animations (primarily text reveal).
|
||||
* Animation Queue Module
|
||||
* Handles scheduling and executing animations with proper resource management
|
||||
* and synchronization with TTS
|
||||
*/
|
||||
export class AnimationQueue {
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.delay = 0;
|
||||
this.speed = 0.05; // Default speed
|
||||
}
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
import { ModuleEvent } from './base-module.js'; // Add this import
|
||||
|
||||
class AnimationQueueModule extends BaseModule {
|
||||
constructor() {
|
||||
super('animation-queue', 'Animation Queue');
|
||||
|
||||
// Queue of scheduled animations/functions
|
||||
this.queue = [];
|
||||
|
||||
// Animation timing properties
|
||||
this.speed = 0.05; // Base animation speed (seconds per character)
|
||||
this.delay = 0; // Current accumulated delay
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['tts'];
|
||||
this.tts = null; // TTS module reference
|
||||
|
||||
// Fast-forwarding state
|
||||
this.isFastForwarding = false;
|
||||
|
||||
// Bind methods
|
||||
this.schedule = this.schedule.bind(this);
|
||||
this.fastForward = this.fastForward.bind(this);
|
||||
this.clearAll = this.clearAll.bind(this);
|
||||
this.setSpeed = this.setSpeed.bind(this);
|
||||
}
|
||||
|
||||
async waitForDependencies() {
|
||||
try {
|
||||
// Wait for TTS module to be available
|
||||
this.tts = moduleRegistry.getModule('tts');
|
||||
|
||||
if (!this.tts) {
|
||||
console.warn("TTS module not ready, Animation Queue will have limited functionality");
|
||||
return true; // Continue anyway
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error waiting for Animation Queue dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
// Nothing special to initialize here
|
||||
this.reportProgress(100, "Animation Queue ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing Animation Queue:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a function to be executed after a delay
|
||||
* @param {Function} func - The function to execute
|
||||
* @param {number} delay - The delay in milliseconds
|
||||
* Schedule a function to execute after a delay
|
||||
* @param {Function} func - Function to execute
|
||||
* @param {number} delay - Delay in milliseconds
|
||||
* @param {...any} args - Arguments to pass to the function
|
||||
* @returns {number} The timeout ID
|
||||
* @returns {Object} - Timeout object that can be used to cancel
|
||||
*/
|
||||
schedule(func, delay, ...args) {
|
||||
if (typeof func !== 'function') {
|
||||
console.error('Animation Queue: Not a function passed to schedule');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create timeout object with execute method
|
||||
const timeoutObject = {
|
||||
execute: () => func(...args),
|
||||
timeoutId: null
|
||||
execute: () => {
|
||||
try {
|
||||
func(...args);
|
||||
} catch (error) {
|
||||
console.error('Error executing scheduled function:', error);
|
||||
}
|
||||
},
|
||||
timeoutId: null,
|
||||
createdAt: Date.now(),
|
||||
delay: delay
|
||||
};
|
||||
|
||||
|
||||
// Apply speed factor to the delay
|
||||
const adjustedDelay = delay * this.speed;
|
||||
|
||||
// Schedule execution
|
||||
timeoutObject.timeoutId = setTimeout(() => {
|
||||
// Execute the function
|
||||
timeoutObject.execute();
|
||||
this.queue = this.queue.filter(t => t !== timeoutObject);
|
||||
if (this.queue.length <= 0) {
|
||||
let event = new CustomEvent("allWordsSetEvent", {
|
||||
detail: { messages: "All scheduled word fade in animations were played." },
|
||||
bubbles: true,
|
||||
cancelable: false
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
// Remove from queue
|
||||
const index = this.queue.indexOf(timeoutObject);
|
||||
if (index !== -1) {
|
||||
this.queue.splice(index, 1);
|
||||
}
|
||||
}, delay);
|
||||
|
||||
}, adjustedDelay);
|
||||
|
||||
// Add to queue
|
||||
this.queue.push(timeoutObject);
|
||||
return timeoutObject.timeoutId;
|
||||
|
||||
// Update current total delay
|
||||
this.delay = adjustedDelay + delay;
|
||||
|
||||
return timeoutObject;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fast forward all scheduled animations
|
||||
* Fast-forward all pending animations
|
||||
*/
|
||||
fastForward() {
|
||||
this.delay = 0.0;
|
||||
// Sort the queue based on timeoutId (assuming that smaller ids are scheduled earlier)
|
||||
this.queue.sort((a, b) => a.timeoutId - b.timeoutId);
|
||||
// Clear and execute all timeouts
|
||||
this.queue.forEach(timeoutObject => {
|
||||
clearTimeout(timeoutObject.timeoutId);
|
||||
console.log(`Animation Queue: Fast-forwarding ${this.queue.length} pending items`);
|
||||
|
||||
// Stop TTS if playing
|
||||
if (this.tts) {
|
||||
this.tts.stop();
|
||||
}
|
||||
|
||||
// Execute and clear all timeouts
|
||||
const queueCopy = [...this.queue]; // Make a copy to avoid modification during iteration
|
||||
|
||||
queueCopy.forEach(timeoutObject => {
|
||||
// Clear timeout
|
||||
if (timeoutObject.timeoutId !== null) {
|
||||
clearTimeout(timeoutObject.timeoutId);
|
||||
}
|
||||
|
||||
// Execute immediately
|
||||
timeoutObject.execute();
|
||||
});
|
||||
|
||||
// Clear queue
|
||||
this.queue = [];
|
||||
let event = new CustomEvent("allWordsSetEvent", {
|
||||
detail: { messages: "All scheduled word fade in animations were played." },
|
||||
bubbles: true,
|
||||
cancelable: false
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
document.getElementById("page_right").scrollTo({
|
||||
top: document.getElementById("page_right").scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
// Reset delay
|
||||
this.delay = 0;
|
||||
|
||||
// Use direct DOM event dispatch instead of this.dispatchEvent
|
||||
document.dispatchEvent(new CustomEvent('animations:fastForwarded', {
|
||||
detail: { moduleId: this.id }
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Stop all scheduled animations
|
||||
* Clear all scheduled animations without executing them
|
||||
*/
|
||||
stop() {
|
||||
clearAll() {
|
||||
console.log(`Animation Queue: Clearing ${this.queue.length} pending items`);
|
||||
|
||||
// Clear all timeouts
|
||||
this.queue.forEach(timeoutObject => {
|
||||
clearTimeout(timeoutObject.timeoutId);
|
||||
if (timeoutObject.timeoutId !== null) {
|
||||
clearTimeout(timeoutObject.timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear queue
|
||||
this.queue = [];
|
||||
|
||||
// Reset delay
|
||||
this.delay = 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the animation speed
|
||||
* @param {number} value - The speed value
|
||||
* @param {number} speed - Animation speed factor (lower is faster)
|
||||
*/
|
||||
setSpeed(value) {
|
||||
this.speed = value;
|
||||
setSpeed(speed) {
|
||||
if (typeof speed !== 'number' || speed <= 0) {
|
||||
console.error('Animation Queue: Invalid speed value');
|
||||
return;
|
||||
}
|
||||
|
||||
this.speed = speed;
|
||||
console.log(`Animation Queue: Speed set to ${speed}`);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the current animation speed
|
||||
* @returns {number} The current speed
|
||||
* Get current queue length
|
||||
* @returns {number} - Number of items in the queue
|
||||
*/
|
||||
getSpeed() {
|
||||
return this.speed;
|
||||
getQueueLength() {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the current accumulated delay
|
||||
* @returns {number} The current delay
|
||||
* Get current accumulated delay
|
||||
* @returns {number} - Current delay in milliseconds
|
||||
*/
|
||||
getDelay() {
|
||||
getCurrentDelay() {
|
||||
return this.delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the accumulated delay
|
||||
* @param {number} value - The delay value
|
||||
*/
|
||||
setDelay(value) {
|
||||
this.delay = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the accumulated delay
|
||||
* @param {number} value - The amount to increment
|
||||
*/
|
||||
incrementDelay(value) {
|
||||
this.delay += value;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const AnimationQueue = new AnimationQueueModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(AnimationQueue);
|
||||
|
||||
// Export the module
|
||||
export { AnimationQueue };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.AnimationQueue = AnimationQueue;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* ApiTTSHandler for AI Interactive Fiction
|
||||
* Implementation using external TTS APIs like ElevenLabs
|
||||
*/
|
||||
import { TTSHandler } from './tts-handler.js';
|
||||
|
||||
export class ApiTTSHandler extends TTSHandler {
|
||||
constructor() {
|
||||
super(); // Initialize the base TTSHandler
|
||||
this.isReady = false;
|
||||
this.enabled = false; // Disabled by default until options panel is implemented
|
||||
this.audioElement = null;
|
||||
// Set voice options through base class
|
||||
this.voiceOptions = {
|
||||
voice: '8JNqTOY3RaSYcHTVJZ0G', // Default ElevenLabs voice ID
|
||||
model: 'eleven_multilingual_v1',
|
||||
stability: 0,
|
||||
similarityBoost: 0,
|
||||
style: 0.5,
|
||||
useSpeakerBoost: true
|
||||
};
|
||||
this.apiKey = 'd191e27c2e5b07573b39fe70f0783f48'; // From speech.js
|
||||
this.apiUrl = 'https://api.elevenlabs.io/v1/text-to-speech';
|
||||
this.voicesApiUrl = 'https://api.elevenlabs.io/v1/voices'; // Separate URL for voices endpoint
|
||||
this.cache = new Map();
|
||||
this.currentCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of this provider
|
||||
* @returns {string} - Provider ID
|
||||
*/
|
||||
getId() {
|
||||
return 'api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the API TTS system
|
||||
* @param {Function} progressCallback - Optional callback for progress updates
|
||||
* @returns {Promise<boolean>} - Resolves to true if initialization was successful
|
||||
*/
|
||||
async initialize(progressCallback = null) {
|
||||
try {
|
||||
if (progressCallback) progressCallback(20, 'Setting up API TTS');
|
||||
|
||||
// Create audio element for playback
|
||||
this.audioElement = new Audio();
|
||||
|
||||
// Set up audio event listeners
|
||||
this.audioElement.onended = () => {
|
||||
if (this.currentCallback) {
|
||||
const callback = this.currentCallback;
|
||||
this.currentCallback = null;
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
this.audioElement.onerror = (error) => {
|
||||
console.error('Audio playback error:', error);
|
||||
if (this.currentCallback) {
|
||||
const callback = this.currentCallback;
|
||||
this.currentCallback = null;
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
if (progressCallback) progressCallback(80, 'API TTS ready');
|
||||
|
||||
// Only check API if enabled
|
||||
if (this.enabled) {
|
||||
// Check if the API is reachable with a simple request
|
||||
try {
|
||||
const testResponse = await fetch(this.voicesApiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'xi-api-key': this.apiKey
|
||||
}
|
||||
});
|
||||
|
||||
if (testResponse.ok) {
|
||||
this.isReady = true;
|
||||
console.log('API TTS initialized successfully');
|
||||
} else {
|
||||
console.warn('API TTS initialized but API may not be accessible');
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.warn('Could not verify API access, but continuing:', apiError);
|
||||
// We'll still mark as ready and try when speak is called
|
||||
this.isReady = true;
|
||||
}
|
||||
} else {
|
||||
console.log('API TTS is disabled by default. Enable via options panel when implemented.');
|
||||
}
|
||||
|
||||
if (progressCallback) progressCallback(100, 'API TTS initialization complete');
|
||||
|
||||
return this.isReady;
|
||||
} catch (error) {
|
||||
console.error('Error initializing API TTS:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if API TTS is available
|
||||
* @returns {boolean} - True if API TTS is ready to use
|
||||
*/
|
||||
isAvailable() {
|
||||
return this.isReady && this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an MD5 hash for text caching
|
||||
* @param {string} text - Text to hash
|
||||
* @returns {string} - MD5 hash
|
||||
*/
|
||||
generateHash(text) {
|
||||
// Simple hash function for client-side use
|
||||
// For production, consider using a proper hashing library
|
||||
let hash = 0;
|
||||
if (text.length === 0) return hash.toString();
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert text to speech via API and play it
|
||||
* @param {string} text - Text to speak
|
||||
* @param {Function} callback - Called when speech completes
|
||||
*/
|
||||
async speak(text, callback = null) {
|
||||
if (!this.isAvailable() || !text) {
|
||||
if (callback) callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any current speech
|
||||
this.stop();
|
||||
|
||||
// Set new callback
|
||||
this.currentCallback = callback;
|
||||
|
||||
try {
|
||||
// Check cache first
|
||||
const cacheKey = this.generateHash(text + JSON.stringify(this.voiceOptions));
|
||||
let audioUrl = this.cache.get(cacheKey);
|
||||
|
||||
if (!audioUrl) {
|
||||
// Make API request to get audio
|
||||
const response = await fetch(`${this.apiUrl}/${this.voiceOptions.voice}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'xi-api-key': this.apiKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: text,
|
||||
model_id: this.voiceOptions.model,
|
||||
voice_settings: {
|
||||
stability: this.voiceOptions.stability,
|
||||
similarity_boost: this.voiceOptions.similarityBoost,
|
||||
style: this.voiceOptions.style,
|
||||
use_speaker_boost: this.voiceOptions.useSpeakerBoost
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Get the audio data as blob
|
||||
const audioBlob = await response.blob();
|
||||
audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
// Store in cache
|
||||
this.cache.set(cacheKey, audioUrl);
|
||||
}
|
||||
|
||||
// Play the audio
|
||||
this.audioElement.src = audioUrl;
|
||||
await this.audioElement.play();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error speaking with API TTS:', error);
|
||||
if (this.currentCallback) {
|
||||
const callback = this.currentCallback;
|
||||
this.currentCallback = null;
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any ongoing speech
|
||||
*/
|
||||
stop() {
|
||||
if (this.audioElement) {
|
||||
this.audioElement.pause();
|
||||
this.audioElement.currentTime = 0;
|
||||
}
|
||||
|
||||
if (this.currentCallback) {
|
||||
const callback = this.currentCallback;
|
||||
this.currentCallback = null;
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options = {}) {
|
||||
if (options.voice !== undefined) this.voiceOptions.voice = options.voice;
|
||||
if (options.model !== undefined) this.voiceOptions.model = options.model;
|
||||
if (options.stability !== undefined) this.voiceOptions.stability = options.stability;
|
||||
if (options.similarityBoost !== undefined) this.voiceOptions.similarityBoost = options.similarityBoost;
|
||||
if (options.style !== undefined) this.voiceOptions.style = options.style;
|
||||
if (options.useSpeakerBoost !== undefined) this.voiceOptions.useSpeakerBoost = options.useSpeakerBoost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices from the API
|
||||
* @returns {Promise<Array>} - Array of available voices
|
||||
*/
|
||||
async getVoices() {
|
||||
if (!this.enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(this.voicesApiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'xi-api-key': this.apiKey
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.voices || [];
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error getting voices from API:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the API TTS
|
||||
* @param {boolean} enabled - Whether the API TTS should be enabled
|
||||
*/
|
||||
setEnabled(enabled) {
|
||||
this.enabled = enabled;
|
||||
if (enabled && !this.isReady) {
|
||||
// Re-initialize if enabled
|
||||
this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if speech is currently playing
|
||||
* @returns {boolean} - True if speaking
|
||||
*/
|
||||
isSpeaking() {
|
||||
return this.audioElement !== null &&
|
||||
!this.audioElement.paused &&
|
||||
!this.audioElement.ended;
|
||||
}
|
||||
}
|
||||
+103
-6
@@ -2,11 +2,63 @@
|
||||
* AudioManager Module
|
||||
* Manages loading and playback of non-TTS audio effects triggered by tags.
|
||||
*/
|
||||
export class AudioManager {
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class AudioManagerModule extends BaseModule {
|
||||
constructor() {
|
||||
super('audio-manager', 'Audio Manager');
|
||||
this.sounds = new Map();
|
||||
this.currentAudio = null;
|
||||
this.currentLoop = null;
|
||||
this.masterVolume = 1.0;
|
||||
this.musicVolume = 1.0;
|
||||
this.sfxVolume = 1.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load module dependencies
|
||||
* @returns {Promise} - Resolves when dependencies are loaded
|
||||
*/
|
||||
async loadDependencies() {
|
||||
try {
|
||||
this.reportProgress(40, "Initializing audio system");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error loading AudioManager dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Set up audio context if needed
|
||||
this.setupAudioContext();
|
||||
|
||||
// Load some basic sound effects
|
||||
this.reportProgress(80, "Loading sound effects");
|
||||
|
||||
this.reportProgress(100, "Audio system ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing AudioManager:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Web Audio API context if needed
|
||||
*/
|
||||
setupAudioContext() {
|
||||
// Only create if needed for advanced audio features
|
||||
if (typeof AudioContext !== 'undefined' || typeof webkitAudioContext !== 'undefined') {
|
||||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||
this.audioContext = new AudioContextClass();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,20 +184,53 @@ export class AudioManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the volume for all sounds
|
||||
* Set the master volume for all sounds
|
||||
* @param {number} volume - The volume level (0.0 to 1.0)
|
||||
*/
|
||||
setVolume(volume) {
|
||||
setMasterVolume(volume) {
|
||||
this.masterVolume = Math.max(0, Math.min(1, volume));
|
||||
this.updateVolumes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the music volume
|
||||
* @param {number} volume - The volume level (0.0 to 1.0)
|
||||
*/
|
||||
setMusicVolume(volume) {
|
||||
this.musicVolume = Math.max(0, Math.min(1, volume));
|
||||
// Apply to current loop if it exists
|
||||
if (this.currentLoop) {
|
||||
this.currentLoop.volume = this.masterVolume * this.musicVolume;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the sound effects volume
|
||||
* @param {number} volume - The volume level (0.0 to 1.0)
|
||||
*/
|
||||
setSfxVolume(volume) {
|
||||
this.sfxVolume = Math.max(0, Math.min(1, volume));
|
||||
// Apply to current non-loop audio if it exists
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.volume = this.masterVolume * this.sfxVolume;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all volume levels based on current settings
|
||||
*/
|
||||
updateVolumes() {
|
||||
this.sounds.forEach(audio => {
|
||||
audio.volume = volume;
|
||||
const isMusic = audio.loop;
|
||||
audio.volume = this.masterVolume * (isMusic ? this.musicVolume : this.sfxVolume);
|
||||
});
|
||||
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.volume = volume;
|
||||
this.currentAudio.volume = this.masterVolume * this.sfxVolume;
|
||||
}
|
||||
|
||||
if (this.currentLoop) {
|
||||
this.currentLoop.volume = volume;
|
||||
this.currentLoop.volume = this.masterVolume * this.musicVolume;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,3 +276,15 @@ export class AudioManager {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const AudioManager = new AudioManagerModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(AudioManager);
|
||||
|
||||
// Export the module
|
||||
export { AudioManager };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.AudioManager = AudioManager;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Base Module Class
|
||||
* Provides common functionality and enforces a consistent interface for all modules
|
||||
*/
|
||||
export class BaseModule {
|
||||
constructor(id, name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.state = 'PENDING';
|
||||
this.progress = 0;
|
||||
this.progressCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module interface
|
||||
* @param {Function} progressCallback - Function to report progress
|
||||
* @returns {Promise} - Resolves when initialization is complete
|
||||
*/
|
||||
async initializeInterface(progressCallback) {
|
||||
this.progressCallback = progressCallback;
|
||||
|
||||
try {
|
||||
this.changeState('LOADING');
|
||||
this.reportProgress(10, "Starting initialization");
|
||||
|
||||
// Load dependencies
|
||||
const depsLoaded = await this.loadDependencies();
|
||||
if (!depsLoaded) {
|
||||
this.changeState('ERROR');
|
||||
this.reportProgress(100, "Failed to load dependencies");
|
||||
return false;
|
||||
}
|
||||
|
||||
const depStatus = await this.waitForDependencies();
|
||||
if (!depStatus) {
|
||||
// If dependencies aren't available, report waiting
|
||||
this.changeState('WAITING');
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
this.changeState('INITIALIZING');
|
||||
const initResult = await this.initialize();
|
||||
|
||||
if (initResult) {
|
||||
this.changeState('FINISHED');
|
||||
this.reportProgress(100, "Initialization complete");
|
||||
} else {
|
||||
this.changeState('ERROR');
|
||||
this.reportProgress(100, "Initialization failed");
|
||||
}
|
||||
|
||||
return initResult;
|
||||
} catch (error) {
|
||||
console.error(`Error in module ${this.id}:`, error);
|
||||
this.changeState('ERROR');
|
||||
this.reportProgress(100, "Error during initialization");
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load module dependencies - Override this in child classes
|
||||
* @returns {Promise} - Resolves when dependencies are loaded
|
||||
*/
|
||||
async loadDependencies() {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for dependencies to be ready - Override this in child classes
|
||||
* @returns {Promise} - Resolves when dependencies are ready
|
||||
*/
|
||||
async waitForDependencies() {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module - Override this in child classes
|
||||
* @returns {Promise} - Resolves when initialization is complete
|
||||
*/
|
||||
async initialize() {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the module state and dispatch an event
|
||||
* @param {string} state - The new state
|
||||
*/
|
||||
changeState(state) {
|
||||
this.state = state;
|
||||
document.dispatchEvent(new ModuleEvent('stateChange', this.id, { state }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Report progress to the module loader
|
||||
* @param {number} percent - Progress percentage (0-100)
|
||||
* @param {string} message - Status message
|
||||
*/
|
||||
reportProgress(percent, message) {
|
||||
this.progress = percent;
|
||||
|
||||
if (this.progressCallback && typeof this.progressCallback === 'function') {
|
||||
this.progressCallback(percent, message);
|
||||
} else {
|
||||
document.dispatchEvent(new ModuleEvent('progress', this.id, { progress: percent }));
|
||||
if (message) {
|
||||
document.dispatchEvent(new ModuleEvent('message', this.id, { message }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current module state
|
||||
* @returns {string} - Current state
|
||||
*/
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Module Events - Used for communication between modules and the loader
|
||||
*/
|
||||
export class ModuleEvent extends CustomEvent {
|
||||
constructor(type, moduleId, data = {}) {
|
||||
super(`module:${type}`, {
|
||||
detail: {
|
||||
moduleId,
|
||||
...data
|
||||
},
|
||||
bubbles: true
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* BrowserTTSHandler for AI Interactive Fiction
|
||||
* Implementation using the browser's Web Speech API
|
||||
*/
|
||||
import { TTSHandler } from './tts-handler.js';
|
||||
|
||||
export class BrowserTTSHandler extends TTSHandler {
|
||||
constructor() {
|
||||
super(); // Initialize the base TTSHandler
|
||||
this.synth = window.speechSynthesis;
|
||||
this.utterance = null;
|
||||
this.voices = [];
|
||||
this.isReady = false;
|
||||
// Initialize voice options through base class
|
||||
this.voiceOptions = {
|
||||
voice: '',
|
||||
rate: 1.0,
|
||||
pitch: 1.0,
|
||||
volume: 1.0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if speech is currently playing
|
||||
* @returns {boolean} - True if speaking
|
||||
*/
|
||||
isSpeaking() {
|
||||
return this.synth && this.synth.speaking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of this provider
|
||||
* @returns {string} - Provider ID
|
||||
*/
|
||||
getId() {
|
||||
return 'browser';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the browser's speech synthesis
|
||||
* @param {Function} progressCallback - Optional callback for progress updates
|
||||
* @returns {Promise<boolean>} - Resolves to true if initialization was successful
|
||||
*/
|
||||
async initialize(progressCallback = null) {
|
||||
if (!this.synth) {
|
||||
console.warn('Web Speech API not supported in this browser');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (progressCallback) progressCallback(20, 'Loading speech synthesis');
|
||||
|
||||
// Get available voices
|
||||
this.voices = await this.getVoices();
|
||||
|
||||
if (progressCallback) progressCallback(80, 'Speech synthesis loaded');
|
||||
|
||||
// If we have voices, we're ready
|
||||
this.isReady = this.voices && this.voices.length > 0;
|
||||
|
||||
if (this.isReady) {
|
||||
console.log('Browser TTS initialized with', this.voices.length, 'voices');
|
||||
} else {
|
||||
console.warn('Browser TTS initialized but no voices available');
|
||||
}
|
||||
|
||||
if (progressCallback) progressCallback(100, 'Browser TTS ready');
|
||||
|
||||
return this.isReady;
|
||||
} catch (error) {
|
||||
console.error('Error initializing browser TTS:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
* @returns {Promise<Array>} - Array of available voices
|
||||
*/
|
||||
async getVoices() {
|
||||
return new Promise((resolve) => {
|
||||
// Some browsers get voices immediately, others need an event
|
||||
const voices = this.synth.getVoices();
|
||||
|
||||
if (voices && voices.length > 0) {
|
||||
resolve(voices);
|
||||
} else {
|
||||
// Wait for voiceschanged event
|
||||
const voicesChangedHandler = () => {
|
||||
this.synth.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(this.synth.getVoices());
|
||||
};
|
||||
|
||||
this.synth.addEventListener('voiceschanged', voicesChangedHandler);
|
||||
|
||||
// Safety mechanism: if after 3 seconds we still have no voices and no event,
|
||||
// resolve with whatever we have (or empty array)
|
||||
// This is not a setTimeout for synchronization, but a safety fallback
|
||||
const safetyCheckVoices = () => {
|
||||
const currentVoices = this.synth.getVoices() || [];
|
||||
console.log(`Safety check: Found ${currentVoices.length} voices`);
|
||||
resolve(currentVoices);
|
||||
};
|
||||
|
||||
// Use requestIdleCallback if available, otherwise requestAnimationFrame
|
||||
if (window.requestIdleCallback) {
|
||||
window.requestIdleCallback(safetyCheckVoices, { timeout: 3000 });
|
||||
} else {
|
||||
// Schedule for next frame, but with longer delay
|
||||
setTimeout(safetyCheckVoices, 3000);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if browser TTS is available
|
||||
* @returns {boolean} - True if browser TTS is ready to use
|
||||
*/
|
||||
isAvailable() {
|
||||
return this.isReady && this.synth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text using browser TTS
|
||||
* @param {string} text - The text to speak
|
||||
* @param {Function} callback - Called when speech completes
|
||||
*/
|
||||
speak(text, callback = null) {
|
||||
if (!this.isAvailable() || !text) {
|
||||
if (callback) callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any current speech
|
||||
this.stop();
|
||||
|
||||
try {
|
||||
// Create a new utterance
|
||||
this.utterance = new SpeechSynthesisUtterance(text);
|
||||
|
||||
// Apply voice options
|
||||
if (this.voiceOptions.voice) {
|
||||
// Find the voice by name or URI
|
||||
const selectedVoice = this.voices.find(v =>
|
||||
v.name === this.voiceOptions.voice ||
|
||||
v.voiceURI === this.voiceOptions.voice
|
||||
);
|
||||
if (selectedVoice) {
|
||||
this.utterance.voice = selectedVoice;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply other options
|
||||
this.utterance.rate = this.voiceOptions.rate;
|
||||
this.utterance.pitch = this.voiceOptions.pitch;
|
||||
this.utterance.volume = this.voiceOptions.volume;
|
||||
|
||||
// Handle end of speech
|
||||
this.utterance.onend = () => {
|
||||
if (callback) callback();
|
||||
};
|
||||
|
||||
// Handle errors
|
||||
this.utterance.onerror = (e) => {
|
||||
console.error('Speech synthesis error:', e);
|
||||
if (callback) callback();
|
||||
};
|
||||
|
||||
// Start speaking
|
||||
this.synth.speak(this.utterance);
|
||||
} catch (error) {
|
||||
console.error('Error speaking with browser TTS:', error);
|
||||
if (callback) callback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any ongoing speech
|
||||
*/
|
||||
stop() {
|
||||
if (this.synth) {
|
||||
this.synth.cancel();
|
||||
this.utterance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options = {}) {
|
||||
if (options.voice !== undefined) this.voiceOptions.voice = options.voice;
|
||||
if (options.rate !== undefined) this.voiceOptions.rate = options.rate;
|
||||
if (options.pitch !== undefined) this.voiceOptions.pitch = options.pitch;
|
||||
if (options.volume !== undefined) this.voiceOptions.volume = options.volume;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Debug Utilities for AI Interactive Fiction
|
||||
* Provides debugging and testing tools
|
||||
*/
|
||||
|
||||
class DebugUtils {
|
||||
/**
|
||||
* Test the text processing pipeline with sample text
|
||||
* @param {string} text - Test text to process
|
||||
*/
|
||||
static testTextPipeline(text = "This is a test sentence. Let's see if it displays correctly!") {
|
||||
console.log("Debug: Testing text pipeline with:", text);
|
||||
|
||||
// Find the text buffer
|
||||
const textBuffer = window.TextBuffer || window.moduleRegistry?.getModule('text-buffer');
|
||||
if (textBuffer) {
|
||||
textBuffer.addText(text);
|
||||
console.log("Debug: Text added to buffer");
|
||||
return true;
|
||||
} else {
|
||||
console.error("Debug: TextBuffer not found");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the socket connection
|
||||
*/
|
||||
static testSocketConnection() {
|
||||
console.log("Debug: Testing socket connection");
|
||||
|
||||
// Find the socket client
|
||||
const socketClient = window.SocketClient || window.moduleRegistry?.getModule('socket-client');
|
||||
if (socketClient) {
|
||||
if (socketClient.isConnected) {
|
||||
console.log("Debug: Socket is connected");
|
||||
return true;
|
||||
} else {
|
||||
console.log("Debug: Socket is not connected, attempting connection");
|
||||
socketClient.connect();
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
console.error("Debug: SocketClient not found");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the TTS system
|
||||
* @param {string} text - Test text to speak
|
||||
*/
|
||||
static testTTS(text = "This is a test of the text to speech system.") {
|
||||
console.log("Debug: Testing TTS with:", text);
|
||||
|
||||
// Find the TTS player
|
||||
const ttsPlayer = window.TTSPlayer || window.moduleRegistry?.getModule('tts');
|
||||
if (ttsPlayer) {
|
||||
const wasEnabled = ttsPlayer.isEnabled();
|
||||
|
||||
// Enable TTS temporarily if it was disabled
|
||||
if (!wasEnabled && ttsPlayer.toggle) {
|
||||
ttsPlayer.toggle();
|
||||
}
|
||||
|
||||
// Speak the text
|
||||
ttsPlayer.speak(text, (result) => {
|
||||
console.log("Debug: TTS completed with result:", result);
|
||||
|
||||
// Restore previous enabled state
|
||||
if (!wasEnabled && ttsPlayer.toggle) {
|
||||
ttsPlayer.toggle();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.error("Debug: TTSPlayer not found");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force all modules to reconnect
|
||||
*/
|
||||
static forceReconnect() {
|
||||
console.log("Debug: Forcing module reconnection");
|
||||
|
||||
// Get all modules
|
||||
const registry = window.moduleRegistry;
|
||||
if (!registry) {
|
||||
console.error("Debug: Module registry not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
const modules = registry.getAllModules();
|
||||
|
||||
// UI Controller
|
||||
const uiController = modules['ui-controller'];
|
||||
if (uiController) {
|
||||
if (uiController.textBuffer === null) {
|
||||
uiController.textBuffer = modules['text-buffer'];
|
||||
console.log("Debug: Reconnected UI Controller to Text Buffer");
|
||||
|
||||
// Reinitialize text buffer
|
||||
if (uiController.initializeTextBuffer) {
|
||||
uiController.initializeTextBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
if (uiController.ttsHandler === null) {
|
||||
uiController.ttsHandler = modules['tts'];
|
||||
console.log("Debug: Reconnected UI Controller to TTS Player");
|
||||
}
|
||||
}
|
||||
|
||||
// Socket Client
|
||||
const socketClient = modules['socket-client'];
|
||||
if (socketClient) {
|
||||
if (socketClient.textBuffer === null) {
|
||||
socketClient.textBuffer = modules['text-buffer'];
|
||||
console.log("Debug: Reconnected Socket Client to Text Buffer");
|
||||
}
|
||||
}
|
||||
|
||||
// Game Loop
|
||||
const gameLoop = modules['game-loop'];
|
||||
if (gameLoop) {
|
||||
if (gameLoop.uiController === null) {
|
||||
gameLoop.uiController = modules['ui-controller'];
|
||||
console.log("Debug: Reconnected Game Loop to UI Controller");
|
||||
}
|
||||
|
||||
if (gameLoop.socketClient === null) {
|
||||
gameLoop.socketClient = modules['socket-client'];
|
||||
console.log("Debug: Reconnected Game Loop to Socket Client");
|
||||
}
|
||||
|
||||
if (gameLoop.textBuffer === null) {
|
||||
gameLoop.textBuffer = modules['text-buffer'];
|
||||
console.log("Debug: Reconnected Game Loop to Text Buffer");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Export as global for easy console access
|
||||
window.DebugUtils = DebugUtils;
|
||||
|
||||
export { DebugUtils };
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Game Loop Module for AI Interactive Fiction
|
||||
* Manages the main game logic and connects various modules
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class GameLoopModule extends BaseModule {
|
||||
constructor() {
|
||||
super('game-loop', 'Game Loop');
|
||||
this.uiController = null;
|
||||
this.socketClient = null;
|
||||
this.ttsPlayer = null;
|
||||
this.textBuffer = null;
|
||||
this.isRunning = false;
|
||||
this.gameState = {
|
||||
started: false,
|
||||
canLoad: false,
|
||||
currentRoom: null,
|
||||
inventory: [],
|
||||
commandHistory: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load module dependencies
|
||||
* @returns {Promise} - Resolves when dependencies are loaded
|
||||
*/
|
||||
async loadDependencies() {
|
||||
// Basic dependency declaration - details handled in waitForDependencies
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for dependencies to be ready
|
||||
*/
|
||||
async waitForDependencies() {
|
||||
try {
|
||||
// Wait for TTS module with a timeout
|
||||
const ttsReady = await moduleRegistry.waitForModule('tts', 15000);
|
||||
|
||||
if (ttsReady) {
|
||||
this.ttsPlayer = moduleRegistry.getModule('tts');
|
||||
this.reportProgress(30, "TTS module ready");
|
||||
} else {
|
||||
console.warn("TTS module not ready, game will have limited functionality");
|
||||
}
|
||||
|
||||
// Wait for UI Controller with a timeout
|
||||
const uiReady = await moduleRegistry.waitForModule('ui-controller', 15000);
|
||||
|
||||
if (uiReady) {
|
||||
this.uiController = moduleRegistry.getModule('ui-controller');
|
||||
this.reportProgress(50, "UI Controller ready");
|
||||
} else {
|
||||
console.warn("UI Controller not ready, game will have limited functionality");
|
||||
}
|
||||
|
||||
// Get text buffer reference
|
||||
this.textBuffer = moduleRegistry.getModule('text-buffer');
|
||||
if (this.textBuffer) {
|
||||
this.reportProgress(60, "Text buffer ready");
|
||||
}
|
||||
|
||||
// Continue even with limited functionality
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error waiting for dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
this.reportProgress(100, "Game loop initialized");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the game loop
|
||||
*/
|
||||
start() {
|
||||
console.log("GameLoop: Starting game sequence...");
|
||||
|
||||
try {
|
||||
// Update UI with initial game state
|
||||
if (this.uiController && this.ttsPlayer) {
|
||||
this.updateUIState();
|
||||
} else {
|
||||
console.warn("GameLoop: UI Controller or TTS Player not ready for status update.");
|
||||
}
|
||||
|
||||
console.log("GameLoop: Setting up socket listeners and connecting...");
|
||||
|
||||
// Set up socket event listeners and connect
|
||||
this.setupSocketEventListeners();
|
||||
|
||||
// Set the game loop as running
|
||||
this.isRunning = true;
|
||||
} catch (error) {
|
||||
console.error("Error starting game loop:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up socket event listeners and connect to server
|
||||
*/
|
||||
setupSocketEventListeners() {
|
||||
// Get the socket client module
|
||||
this.socketClient = moduleRegistry.getModule('socket-client');
|
||||
|
||||
if (!this.socketClient) {
|
||||
console.error("Socket client module not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect UI controller to socket client for command handling
|
||||
if (this.uiController) {
|
||||
this.uiController.socketClient = this.socketClient;
|
||||
} else {
|
||||
console.warn("GameLoop: UI Controller not ready for Socket Client assignment.");
|
||||
}
|
||||
|
||||
// Listen for socket connection event
|
||||
this.socketClient.on('connect', () => {
|
||||
console.log("GameLoop: Socket connected event received.");
|
||||
|
||||
// Request a new game start when we (re)connect
|
||||
console.log("GameLoop: Requesting start game on (re)connect.");
|
||||
this.requestStartGame();
|
||||
});
|
||||
|
||||
// Listen for game state updates
|
||||
this.socketClient.on('gameStateUpdate', (data) => {
|
||||
console.log("GameLoop: Game state update received", data);
|
||||
this.updateGameState(data);
|
||||
});
|
||||
|
||||
// Listen for narrative responses
|
||||
this.socketClient.on('narrativeResponse', (data) => {
|
||||
console.log("GameLoop: Narrative response received", data);
|
||||
// Text processing is handled by socket-client -> text-buffer -> ui-controller pipeline
|
||||
});
|
||||
|
||||
// Listen for game introduction
|
||||
this.socketClient.on('gameIntroduction', (data) => {
|
||||
console.log("GameLoop: Received gameIntroduction");
|
||||
this.gameState.started = true;
|
||||
this.updateUIState();
|
||||
// Text processing is handled by socket-client -> text-buffer -> ui-controller pipeline
|
||||
});
|
||||
|
||||
// Connect to the socket server
|
||||
this.socketClient.connect().then(success => {
|
||||
if (success) {
|
||||
console.log("GameLoop: Socket connection established successfully.");
|
||||
console.log("GameLoop: Requesting to start a new game");
|
||||
this.requestStartGame();
|
||||
} else {
|
||||
console.error("GameLoop: Failed to connect to socket server");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the game state
|
||||
* @param {Object} data - New game state data
|
||||
*/
|
||||
updateGameState(data) {
|
||||
if (!data) return;
|
||||
|
||||
// Update game state
|
||||
if (data.currentRoom) {
|
||||
this.gameState.currentRoom = data.currentRoom;
|
||||
}
|
||||
|
||||
if (data.inventory) {
|
||||
this.gameState.inventory = data.inventory;
|
||||
}
|
||||
|
||||
// Update UI with new game state
|
||||
this.updateUIState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI with current game state
|
||||
*/
|
||||
updateUIState() {
|
||||
if (!this.uiController) return;
|
||||
|
||||
// Update UI components based on game state
|
||||
this.uiController.updateButtonStates(this.gameState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to start a new game
|
||||
*/
|
||||
requestStartGame() {
|
||||
if (!this.socketClient) return;
|
||||
|
||||
this.socketClient.requestStartGame();
|
||||
this.gameState.started = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to save the current game
|
||||
*/
|
||||
requestSaveGame() {
|
||||
if (!this.socketClient) return;
|
||||
|
||||
this.socketClient.requestSaveGame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to load a saved game
|
||||
*/
|
||||
requestLoadGame() {
|
||||
if (!this.socketClient) return;
|
||||
|
||||
this.socketClient.requestLoadGame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually add text to the buffer
|
||||
* Useful for testing or adding local messages
|
||||
* @param {string} text - Text to add
|
||||
*/
|
||||
addText(text) {
|
||||
if (!this.textBuffer) {
|
||||
console.warn("Text buffer not available");
|
||||
return;
|
||||
}
|
||||
|
||||
this.textBuffer.addText(text);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const GameLoop = new GameLoopModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(GameLoop);
|
||||
|
||||
// Export the module
|
||||
export { GameLoop };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.GameLoop = GameLoop;
|
||||
@@ -0,0 +1,677 @@
|
||||
/**
|
||||
* @license Hyphenopoly.module.js 6.0.0 - hyphenation for node
|
||||
* ©2024 Mathias Nater, Güttingen (mathiasnater at gmail dot com)
|
||||
* https://github.com/mnater/Hyphenopoly
|
||||
*
|
||||
* Released under the MIT license
|
||||
* http://mnater.github.io/Hyphenopoly/LICENSE
|
||||
*/
|
||||
|
||||
/* eslint-env node */
|
||||
|
||||
const decode = (() => {
|
||||
const utf16ledecoder = new TextDecoder("utf-16le");
|
||||
return (ui16) => {
|
||||
return utf16ledecoder.decode(ui16);
|
||||
};
|
||||
})();
|
||||
|
||||
/**
|
||||
* Create Object without standard Object-prototype
|
||||
* @returns {Object} empty object
|
||||
*/
|
||||
const empty = () => {
|
||||
return Object.create(null);
|
||||
};
|
||||
|
||||
const H = empty();
|
||||
|
||||
H.supportedLanguages = [
|
||||
"af",
|
||||
"as",
|
||||
"be",
|
||||
"bg",
|
||||
"bn",
|
||||
"ca",
|
||||
"cs",
|
||||
"cy",
|
||||
"da",
|
||||
"de",
|
||||
"de-x-syllable",
|
||||
"el-monoton",
|
||||
"el-polyton",
|
||||
"en-gb",
|
||||
"en-us",
|
||||
"eo",
|
||||
"es",
|
||||
"et",
|
||||
"eu",
|
||||
"fi",
|
||||
"fo",
|
||||
"fr",
|
||||
"fur",
|
||||
"ga",
|
||||
"gl",
|
||||
"gu",
|
||||
"hi",
|
||||
"hr",
|
||||
"hsb",
|
||||
"hu",
|
||||
"hy",
|
||||
"ia",
|
||||
"id",
|
||||
"is",
|
||||
"it",
|
||||
"ka",
|
||||
"kmr",
|
||||
"kn",
|
||||
"la",
|
||||
"lt",
|
||||
"lv",
|
||||
"mk",
|
||||
"ml",
|
||||
"mn-cyrl",
|
||||
"mr",
|
||||
"nb",
|
||||
"nl",
|
||||
"nn",
|
||||
"no",
|
||||
"oc",
|
||||
"or",
|
||||
"pa",
|
||||
"pi",
|
||||
"pl",
|
||||
"pms",
|
||||
"pt",
|
||||
"rm",
|
||||
"ro",
|
||||
"ru",
|
||||
"sh-cyrl",
|
||||
"sh-latn",
|
||||
"sk",
|
||||
"sl",
|
||||
"sq",
|
||||
"sr-cyrl",
|
||||
"sv",
|
||||
"ta",
|
||||
"te",
|
||||
"th",
|
||||
"tk",
|
||||
"tr",
|
||||
"uk",
|
||||
"zh-latn-pinyin"
|
||||
];
|
||||
|
||||
H.languages = new Map();
|
||||
|
||||
/**
|
||||
* Create lang Object
|
||||
* @param {string} lang The language
|
||||
* @returns {Object} The newly created lang object
|
||||
*/
|
||||
function createLangObj(lang) {
|
||||
if (!H.languages.has(lang)) {
|
||||
H.languages.set(lang, empty());
|
||||
}
|
||||
return H.languages.get(lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup a language object (lo) and dispatch "engineReady"
|
||||
* @param {string} lang The language
|
||||
* @param {function} hyphenateFunction The hyphenateFunction
|
||||
* @param {string} alphabet List of used characters
|
||||
* @param {number} patternLeftmin leftmin as defined in patterns
|
||||
* @param {number} patternRightmin rightmin as defined in patterns
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function prepareLanguagesObj(
|
||||
lang,
|
||||
hyphenateFunction,
|
||||
alphabet,
|
||||
patternLeftmin,
|
||||
patternRightmin
|
||||
) {
|
||||
alphabet = alphabet.replace(/\\*-/g, "\\-");
|
||||
const lo = createLangObj(lang);
|
||||
if (!lo.engineReady) {
|
||||
lo.cache = new Map();
|
||||
const exc = [];
|
||||
if (H.c.exceptions.has(lang)) {
|
||||
exc.push(...H.c.exceptions.get(lang).split(", "));
|
||||
}
|
||||
if (H.c.exceptions.has("global")) {
|
||||
exc.push(...H.c.exceptions.get("global").split(", "));
|
||||
}
|
||||
lo.exceptions = new Map(exc.map((e) => {
|
||||
return [e.replace(/-/g, ""), e];
|
||||
}));
|
||||
|
||||
lo.alphabet = alphabet;
|
||||
lo.reNotAlphabet = RegExp(`[^${alphabet}]`, "i");
|
||||
lo.lm = Math.max(
|
||||
patternLeftmin,
|
||||
H.c.leftmin,
|
||||
H.c.leftminPerLang.get(lang) || 0
|
||||
);
|
||||
lo.rm = Math.max(
|
||||
patternRightmin,
|
||||
H.c.rightmin,
|
||||
H.c.rightminPerLang.get(lang) || 0
|
||||
);
|
||||
lo.hyphenate = hyphenateFunction;
|
||||
lo.engineReady = true;
|
||||
}
|
||||
H.events.dispatch("engineReady", {"msg": lang});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup env for hyphenateFunction
|
||||
* @param {Object} baseData baseData
|
||||
* @param {function} hyphenateFunc hyphenateFunction
|
||||
* @returns {function} hyphenateFunction with closured environment
|
||||
*/
|
||||
function encloseHyphenateFunction(buf, hyphenateFunc) {
|
||||
const wordStore = new Uint16Array(buf, 0, 64);
|
||||
|
||||
/**
|
||||
* The hyphenateFunction that encloses the env above
|
||||
* Copies the word to wasm-Memory, calls wasm.hyphenateFunc and reads
|
||||
* the hyphenated word from wasm-Memory (eventually replacing hyphenchar)
|
||||
* @param {String} word - the word that has to be hyphenated
|
||||
* @param {String} hyphenchar - the hyphenate character
|
||||
* @param {Number} leftmin - min number of chars to remain on line
|
||||
* @param {Number} rightmin - min number of chars to go to new line
|
||||
* @returns {String} the hyphenated word
|
||||
*/
|
||||
return ((word, hyphencc, leftmin, rightmin) => {
|
||||
wordStore.set([
|
||||
...[...word].map((c) => {
|
||||
return c.charCodeAt(0);
|
||||
}),
|
||||
0
|
||||
]);
|
||||
const len = hyphenateFunc(leftmin, rightmin, hyphencc);
|
||||
if (len > 0) {
|
||||
word = decode(new Uint16Array(buf, 0, len));
|
||||
}
|
||||
return word;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate Wasm Engine
|
||||
* @param {string} lang The language
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function instantiateWasmEngine(lang, wasmdata) {
|
||||
/**
|
||||
* Register character substitutions in the .wasm-hyphenEngine
|
||||
* @param {number} alphalen - The length of the alphabet
|
||||
* @param {object} exp - Export-object of the hyphenEngine
|
||||
*/
|
||||
function registerSubstitutions(alphalen, exp) {
|
||||
if (H.c.substitute.has(lang)) {
|
||||
const subst = H.c.substitute.get(lang);
|
||||
subst.forEach((substituer, substituted) => {
|
||||
const substitutedU = substituted.toUpperCase();
|
||||
const substitutedUcc = (substitutedU === substituted)
|
||||
? 0
|
||||
: substitutedU.charCodeAt(0);
|
||||
alphalen = exp.subst(
|
||||
substituted.charCodeAt(0),
|
||||
substitutedUcc,
|
||||
substituer.charCodeAt(0)
|
||||
);
|
||||
});
|
||||
}
|
||||
return alphalen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate the hyphenEngine
|
||||
* @param {object} res - The fetched ressource
|
||||
*/
|
||||
function handleWasm(inst) {
|
||||
const exp = inst.exports;
|
||||
let alphalen = exp.lct.value;
|
||||
alphalen = registerSubstitutions(alphalen, exp);
|
||||
prepareLanguagesObj(
|
||||
lang,
|
||||
encloseHyphenateFunction(
|
||||
exp.mem.buffer,
|
||||
exp.hyphenate
|
||||
),
|
||||
decode(new Uint16Array(exp.mem.buffer, 1664, alphalen)),
|
||||
exp.lmi.value,
|
||||
exp.rmi.value
|
||||
);
|
||||
}
|
||||
if (H.c.sync) {
|
||||
const heInstance = new WebAssembly.Instance(
|
||||
new WebAssembly.Module(wasmdata)
|
||||
);
|
||||
handleWasm(heInstance);
|
||||
} else {
|
||||
WebAssembly.instantiate(wasmdata).then((res) => {
|
||||
handleWasm(res.instance);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a .wasm file and call instantiateWasmEngine on success
|
||||
* @param {string} lang - The language
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function loadHyphenEngine(lang) {
|
||||
const file = `${lang}.wasm`;
|
||||
// eslint-disable-next-line require-jsdoc
|
||||
const cb = (err, data) => {
|
||||
if (err) {
|
||||
H.events.dispatch("error", {
|
||||
"key": lang,
|
||||
"msg": `${lang}.wasm not found.`
|
||||
});
|
||||
} else {
|
||||
instantiateWasmEngine(lang, new Uint8Array(data).buffer);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof H.c.loader !== "function") {
|
||||
H.events.dispatch("error", {
|
||||
"msg": "Loader must be a function."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (H.c.sync) {
|
||||
cb(null, H.c.loaderSync(file, new URL('./patterns/', import.meta.url)));
|
||||
} else {
|
||||
H.c.loader(file, new URL('./patterns/', import.meta.url)).then(
|
||||
(res) => {
|
||||
cb(null, res);
|
||||
},
|
||||
(err) => {
|
||||
cb(err, null);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const wordHyphenatorPool = new Map();
|
||||
|
||||
/**
|
||||
* Factory for hyphenatorFunctions for a specific language and class
|
||||
* @param {Object} lo Language-Object
|
||||
* @param {string} lang The language
|
||||
* @returns {function} The hyphenate function
|
||||
*/
|
||||
function createWordHyphenator(lo, lang) {
|
||||
if (wordHyphenatorPool.has(lang)) {
|
||||
return wordHyphenatorPool.get(lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* HyphenateFunction for non-compound words
|
||||
* @param {string} word The word
|
||||
* @returns {string} The hyphenated word
|
||||
*/
|
||||
function hyphenateNormal(word) {
|
||||
if (word.length > 61) {
|
||||
H.events.dispatch("error", {"msg": "found word longer than 61 characters"});
|
||||
} else if (!lo.reNotAlphabet.test(word)) {
|
||||
return lo.hyphenate(
|
||||
word,
|
||||
H.c.hyphen.charCodeAt(0),
|
||||
lo.lm,
|
||||
lo.rm
|
||||
);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
/**
|
||||
* HyphenateFunction for compound words
|
||||
* @param {string} word The word
|
||||
* @returns {string} The hyphenated compound word
|
||||
*/
|
||||
function hyphenateCompound(word) {
|
||||
let joiner = "-";
|
||||
const parts = word.split(joiner).map((p) => {
|
||||
if (H.c.compound !== "hyphen" &&
|
||||
p.length >= H.c.minWordLength) {
|
||||
return createWordHyphenator(lo, lang)(p);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
if (H.c.compound !== "auto") {
|
||||
// Add Zero Width Space
|
||||
joiner += "\u200B";
|
||||
}
|
||||
return parts.join(joiner);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is mixed case
|
||||
* @param {string} s The string
|
||||
* @returns {boolean} true if s is mixed case
|
||||
*/
|
||||
function isMixedCase(s) {
|
||||
return Array.prototype.map.call(s, (c) => {
|
||||
return (c === c.toLowerCase());
|
||||
}).some((v, i, a) => {
|
||||
return (v !== a[0]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HyphenateFunction for words (compound or not)
|
||||
* @param {string} word The word
|
||||
* @returns {string} The hyphenated word
|
||||
*/
|
||||
function hyphenator(word) {
|
||||
let hw = lo.cache.get(word);
|
||||
if (!hw) {
|
||||
if (lo.exceptions.has(word)) {
|
||||
hw = lo.exceptions.get(word).replace(
|
||||
/-/g,
|
||||
H.c.hyphen
|
||||
);
|
||||
} else if (!H.c.mixedCase && isMixedCase(word)) {
|
||||
hw = word;
|
||||
} else if (word.includes("-")) {
|
||||
hw = hyphenateCompound(word);
|
||||
} else {
|
||||
hw = hyphenateNormal(word);
|
||||
}
|
||||
lo.cache.set(word, hw);
|
||||
}
|
||||
return hw;
|
||||
}
|
||||
wordHyphenatorPool.set(lang, hyphenator);
|
||||
return hyphenator;
|
||||
}
|
||||
|
||||
const orphanController = (() => {
|
||||
/**
|
||||
* Function template
|
||||
* @param {string} ignore unused result of replace
|
||||
* @param {string} leadingWhiteSpace The leading whiteSpace
|
||||
* @param {string} lastWord The last word
|
||||
* @param {string} trailingWhiteSpace The trailing whiteSpace
|
||||
* @returns {string} Treated end of text
|
||||
*/
|
||||
function controlOrphans(
|
||||
ignore,
|
||||
leadingWhiteSpace,
|
||||
lastWord,
|
||||
trailingWhiteSpace
|
||||
) {
|
||||
let h = H.c.hyphen;
|
||||
if (".\\+*?[^]$(){}=!<>|:-".indexOf(H.c.hyphen) !== -1) {
|
||||
h = `\\${H.c.hyphen}`;
|
||||
}
|
||||
if (H.c.orphanControl === 3 && leadingWhiteSpace === " ") {
|
||||
// \u00A0 = no-break space (nbsp)
|
||||
leadingWhiteSpace = "\u00A0";
|
||||
}
|
||||
/* eslint-disable security/detect-non-literal-regexp */
|
||||
return leadingWhiteSpace + lastWord.replace(new RegExp(h, "g"), "") + trailingWhiteSpace;
|
||||
/* eslint-enable security/detect-non-literal-regexp */
|
||||
}
|
||||
return controlOrphans;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Encloses hyphenateTextFunction
|
||||
* @param {string} lang - The language
|
||||
* @return {function} The hyphenateText-function
|
||||
*/
|
||||
function createTextHyphenator(lang) {
|
||||
const lo = H.languages.get(lang);
|
||||
const wordHyphenator = (wordHyphenatorPool.has(lang))
|
||||
? wordHyphenatorPool.get(lang)
|
||||
: createWordHyphenator(lo, lang);
|
||||
|
||||
/*
|
||||
* Transpiled RegExp of
|
||||
* /[${alphabet}\p{Letter}-]{${minwordlength},}/gui
|
||||
*/
|
||||
const reWord = RegExp(
|
||||
`[${lo.alphabet}a-z\u0300-\u036F\u0483-\u0487\u00DF-\u00F6\u00F8-\u00FE\u0101\u0103\u0105\u0107\u0109\u010D\u010F\u0111\u0113\u0117\u0119\u011B\u011D\u011F\u0123\u0125\u012B\u012F\u0131\u0135\u0137\u013C\u013E\u0142\u0144\u0146\u0148\u014D\u0151\u0153\u0155\u0159\u015B\u015D\u015F\u0161\u0165\u016B\u016D\u016F\u0171\u0173\u017A\u017C\u017E\u017F\u01CE\u01D0\u01D2\u01D4\u01D6\u01D8\u01DA\u01DC\u0219\u021B\u02BC\u0390\u03AC-\u03CE\u03D0\u03E3\u03E5\u03E7\u03E9\u03EB\u03ED\u03EF\u03F2\u0430-\u044F\u0451-\u045C\u045E\u045F\u0491\u04AF\u04E9\u0561-\u0585\u0587\u0905-\u090C\u090F\u0910\u0913-\u0928\u092A-\u0930\u0932\u0933\u0935-\u0939\u093D\u0960\u0961\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A85-\u0A8B\u0A8F\u0A90\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AE0\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B60\u0B61\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB5\u0BB7-\u0BB9\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39\u0D60\u0D61\u0D7A-\u0D7F\u0E01-\u0E2E\u0E30\u0E32\u0E33\u0E40-\u0E45\u10D0-\u10F0\u1200-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u1E0D\u1E37\u1E41\u1E43\u1E45\u1E47\u1E6D\u1F00-\u1F07\u1F10-\u1F15\u1F20-\u1F27\u1F30-\u1F37\u1F40-\u1F45\u1F50-\u1F57\u1F60-\u1F67\u1F70-\u1F7D\u1F80-\u1F87\u1F90-\u1F97\u1FA0-\u1FA7\u1FB2-\u1FB4\u1FB6\u1FB7\u1FC2-\u1FC4\u1FC6\u1FC7\u1FD2\u1FD3\u1FD6\u1FD7\u1FE2-\u1FE7\u1FF2-\u1FF4\u1FF6\u1FF7\u2C81\u2C83\u2C85\u2C87\u2C89\u2C8D\u2C8F\u2C91\u2C93\u2C95\u2C97\u2C99\u2C9B\u2C9D\u2C9F\u2CA1\u2CA3\u2CA5\u2CA7\u2CA9\u2CAB\u2CAD\u2CAF\u2CB1\u2CC9\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E-]{${H.c.minWordLength},}`, "gui"
|
||||
);
|
||||
|
||||
/**
|
||||
* Hyphenate text
|
||||
* @param {string} text The text
|
||||
* @param {string} lang The language of the text
|
||||
* @returns {string} Hyphenated text
|
||||
*/
|
||||
return ((text) => {
|
||||
if (H.c.normalize) {
|
||||
text = text.normalize("NFC");
|
||||
}
|
||||
let tn = text.replace(reWord, wordHyphenator);
|
||||
if (H.c.orphanControl !== 1) {
|
||||
tn = tn.replace(
|
||||
/(\u0020*)(\S+)(\s*)$/,
|
||||
orphanController
|
||||
);
|
||||
}
|
||||
return tn;
|
||||
});
|
||||
}
|
||||
|
||||
(() => {
|
||||
// Events known to the system
|
||||
const definedEvents = new Map();
|
||||
|
||||
/**
|
||||
* Create Event Object
|
||||
* @param {string} name The Name of the event
|
||||
* @param {function|null} defFunc The default method of the event
|
||||
* @param {boolean} cancellable Is the default cancellable
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function define(name, defFunc, cancellable) {
|
||||
definedEvents.set(name, {
|
||||
cancellable,
|
||||
"default": defFunc,
|
||||
"register": []
|
||||
});
|
||||
}
|
||||
|
||||
define(
|
||||
"error",
|
||||
(e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e.msg);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
define(
|
||||
"engineReady",
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
/**
|
||||
* Dispatch event <name> with arguments <data>
|
||||
* @param {string} name The name of the event
|
||||
* @param {Object|undefined} data Data of the event
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function dispatch(name, data) {
|
||||
data.defaultPrevented = false;
|
||||
data.preventDefault = (() => {
|
||||
data.defaultPrevented = true;
|
||||
});
|
||||
definedEvents.get(name).register.forEach((currentHandler) => {
|
||||
currentHandler(data);
|
||||
});
|
||||
if (!data.defaultPrevented && definedEvents.get(name).default) {
|
||||
definedEvents.get(name).default(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add EventListender <handler> to event <name>
|
||||
* @param {string} name The name of the event
|
||||
* @param {function} handler Function to register
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function addListener(name, handler) {
|
||||
if (definedEvents.has(name)) {
|
||||
definedEvents.get(name).register.push(handler);
|
||||
} else {
|
||||
H.events.dispatch(
|
||||
"error",
|
||||
{"msg": `unknown Event "${name}" discarded`}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
H.events = empty();
|
||||
H.events.dispatch = dispatch;
|
||||
H.events.addListener = addListener;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Create a Map with a default Map behind the scenes. This mimics
|
||||
* kind of a prototype chain of an object, but without the object-
|
||||
* injection security risk.
|
||||
*
|
||||
* @param {Map} defaultsMap - A Map with default values
|
||||
* @returns {Proxy} - A Proxy for the Map (dot-notation or get/set)
|
||||
*/
|
||||
function createMapWithDefaults(defaultsMap) {
|
||||
const userMap = new Map();
|
||||
|
||||
/**
|
||||
* The get-trap: get the value from userMap or else from defaults
|
||||
* @param {Sring} key - The key to retrieve the value for
|
||||
* @returns {*}
|
||||
*/
|
||||
function get(key) {
|
||||
return (userMap.has(key))
|
||||
? userMap.get(key)
|
||||
: defaultsMap.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The set-trap: set the value to userMap and don't touch defaults
|
||||
* @param {Sring} key - The key for the value
|
||||
* @param {*} value - The value
|
||||
* @returns {*}
|
||||
*/
|
||||
function set(key, value) {
|
||||
userMap.set(key, value);
|
||||
}
|
||||
return new Proxy(defaultsMap, {
|
||||
"get": (_target, prop) => {
|
||||
if (prop === "set") {
|
||||
return set;
|
||||
}
|
||||
if (prop === "get") {
|
||||
return get;
|
||||
}
|
||||
return get(prop);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Default loader emits error
|
||||
* @returns null
|
||||
*/
|
||||
function defaultLoader() {
|
||||
H.events.dispatch("error", {
|
||||
"msg": "loader/loaderSync has not been configured."
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
H.config = ((userConfig) => {
|
||||
const settings = createMapWithDefaults(new Map([
|
||||
["compound", "hyphen"],
|
||||
["exceptions", new Map()],
|
||||
["hyphen", "\u00AD"],
|
||||
["leftmin", 0],
|
||||
["leftminPerLang", new Map()],
|
||||
["loader", defaultLoader],
|
||||
["loaderSync", defaultLoader],
|
||||
["minWordLength", 6],
|
||||
["mixedCase", true],
|
||||
["normalize", false],
|
||||
["orphanControl", 1],
|
||||
["require", []],
|
||||
["rightmin", 0],
|
||||
["rightminPerLang", new Map()],
|
||||
["substitute", new Map()],
|
||||
["sync", false]
|
||||
]));
|
||||
Object.entries(userConfig).forEach(([key, value]) => {
|
||||
switch (key) {
|
||||
case "exceptions":
|
||||
case "leftminPerLang":
|
||||
case "paths":
|
||||
case "rightminPerLang":
|
||||
Object.entries(value).forEach(([k, v]) => {
|
||||
settings.get(key).set(k, v);
|
||||
});
|
||||
break;
|
||||
case "substitute":
|
||||
Object.entries(value).forEach(([lang, subst]) => {
|
||||
settings.substitute.set(
|
||||
lang,
|
||||
new Map(Object.entries(subst))
|
||||
);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
settings.set(key, value);
|
||||
}
|
||||
});
|
||||
H.c = settings;
|
||||
if (H.c.handleEvent) {
|
||||
Object.entries(H.c.handleEvent).forEach(([name, fn]) => {
|
||||
H.events.addListener(name, fn);
|
||||
});
|
||||
}
|
||||
const result = new Map();
|
||||
if (H.c.require.length === 0) {
|
||||
H.events.dispatch(
|
||||
"error",
|
||||
{"msg": "No language has been required. Setup config according to documenation."}
|
||||
);
|
||||
}
|
||||
H.c.require.forEach((lang) => {
|
||||
if (H.c.sync) {
|
||||
H.events.addListener("engineReady", (e) => {
|
||||
if (e.msg === lang) {
|
||||
result.set(lang, createTextHyphenator(lang));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const prom = new Promise((resolve, reject) => {
|
||||
H.events.addListener("engineReady", (e) => {
|
||||
if (e.msg === lang) {
|
||||
resolve(createTextHyphenator(lang));
|
||||
}
|
||||
});
|
||||
H.events.addListener("error", (e) => {
|
||||
if (e.key === lang) {
|
||||
reject(e.msg);
|
||||
}
|
||||
});
|
||||
});
|
||||
result.set(lang, prom);
|
||||
}
|
||||
loadHyphenEngine(lang);
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
export default H;
|
||||
@@ -1,719 +0,0 @@
|
||||
/**
|
||||
* InkStoryPlayer Module
|
||||
* Orchestrates the narrative flow specific to the Ink story.
|
||||
*/
|
||||
export class InkStoryPlayer {
|
||||
/**
|
||||
* Create a new InkStoryPlayer
|
||||
* @param {Object} config - Configuration options
|
||||
* @param {Function} config.InkStory - The inkjs.Story constructor
|
||||
* @param {Object} config.textProcessor - The TextProcessor instance
|
||||
* @param {Object} config.paragraphLayout - The ParagraphLayout instance
|
||||
* @param {Object} config.layoutRenderer - The LayoutRenderer instance
|
||||
* @param {Object} config.audioManager - The AudioManager instance
|
||||
* @param {Object} config.ttsPlayer - The TtsPlayer instance
|
||||
* @param {Object} config.persistenceManager - The PersistenceManager instance
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
this.InkStory = config.InkStory;
|
||||
this.textProcessor = config.textProcessor;
|
||||
this.paragraphLayout = config.paragraphLayout;
|
||||
this.layoutRenderer = config.layoutRenderer;
|
||||
this.audioManager = config.audioManager;
|
||||
this.ttsPlayer = config.ttsPlayer;
|
||||
this.persistenceManager = config.persistenceManager;
|
||||
|
||||
this.story = null;
|
||||
this.storyContainer = document.getElementById('story');
|
||||
this.choiceContainer = document.getElementById('choices');
|
||||
this.savePoint = "";
|
||||
this.running = false;
|
||||
this.keyEventListener = null;
|
||||
this.indentedParagraphs = 0;
|
||||
this.chapterBegin = false;
|
||||
this.measure = [];
|
||||
this.locale = 'en-us';
|
||||
this.translations = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a story from JSON content
|
||||
* @param {Object} storyContent - The compiled Ink story JSON
|
||||
* @param {string} initialState - Optional initial state to load
|
||||
*/
|
||||
loadStory(storyContent, initialState = null) {
|
||||
this.story = new this.InkStory(storyContent);
|
||||
|
||||
if (initialState) {
|
||||
this.story.state.LoadJson(initialState);
|
||||
}
|
||||
|
||||
this.savePoint = this.story.state.toJson();
|
||||
|
||||
// Process global tags
|
||||
this.processGlobalTags();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process global tags from the story
|
||||
*/
|
||||
processGlobalTags() {
|
||||
if (!this.story || !this.story.globalTags) return;
|
||||
|
||||
for (let i = 0; i < this.story.globalTags.length; i++) {
|
||||
const globalTag = this.story.globalTags[i];
|
||||
const splitTag = this.splitPropertyTag(globalTag);
|
||||
|
||||
if (splitTag && splitTag.property == "title") {
|
||||
const title = document.querySelector('.title');
|
||||
if (title) title.innerHTML = splitTag.val;
|
||||
} else if (splitTag && splitTag.property == "author") {
|
||||
const byline = document.querySelector('.byline');
|
||||
if (byline) byline.textContent += splitTag.val;
|
||||
} else if (splitTag && splitTag.property == "subtitle") {
|
||||
const subtitle = document.querySelector('.subtitle');
|
||||
if (subtitle) subtitle.textContent += splitTag.val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue the story
|
||||
* @param {boolean} firstTime - Whether this is the first time continuing
|
||||
*/
|
||||
async continueStory(firstTime = true) {
|
||||
if (!this.story) {
|
||||
console.error('No story loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
this.layoutRenderer.setFastForwardingAll(false);
|
||||
let chapterBegin = false;
|
||||
|
||||
if (this.keyEventListener) {
|
||||
window.removeEventListener('keypress', this.keyEventListener);
|
||||
this.keyEventListener = null;
|
||||
}
|
||||
|
||||
document.querySelectorAll('#story p').forEach((p) => {
|
||||
p.classList.remove("latest-paragraph");
|
||||
});
|
||||
|
||||
// Generate story text - loop through available content
|
||||
while (this.story.canContinue) {
|
||||
if (this.layoutRenderer.getFastForwardingAll()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.layoutRenderer.animationQueue) {
|
||||
this.layoutRenderer.animationQueue.setDelay(0.0);
|
||||
}
|
||||
|
||||
// Get ink to generate the next paragraph
|
||||
const paragraphText = this.story.Continue();
|
||||
const tags = this.story.currentTags;
|
||||
|
||||
// Process tags and get custom classes
|
||||
const customClasses = this.processTags(tags);
|
||||
|
||||
// Skip empty paragraphs
|
||||
if (paragraphText.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process paragraph text and layout
|
||||
await this.processParagraph(paragraphText, customClasses, chapterBegin);
|
||||
|
||||
chapterBegin = false;
|
||||
}
|
||||
|
||||
if (this.layoutRenderer.animationQueue) {
|
||||
this.layoutRenderer.animationQueue.setDelay(0.0);
|
||||
}
|
||||
|
||||
// Process choices
|
||||
await this.processChoices();
|
||||
|
||||
// Dispatch turn complete event
|
||||
const tce = new CustomEvent("turnCompleteEvent", {
|
||||
detail: { messages: "All text and choices have been set up." },
|
||||
bubbles: true,
|
||||
cancelable: false
|
||||
});
|
||||
document.dispatchEvent(tce);
|
||||
|
||||
// Check for end of story
|
||||
if (this.story.canContinue === false && this.story.currentChoices.length === 0) {
|
||||
const end = document.createElement("p");
|
||||
end.style.textTransform = "uppercase";
|
||||
end.style.textAlign = "center";
|
||||
end.classList.add("fade-in");
|
||||
end.classList.add("choice");
|
||||
|
||||
const endText = this.translations[this.locale] && this.translations[this.locale]['end']
|
||||
? this.translations[this.locale]['end']
|
||||
: "The End";
|
||||
|
||||
end.appendChild(document.createTextNode(endText));
|
||||
this.choiceContainer.appendChild(end);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tags for a paragraph
|
||||
* @param {Array<string>} tags - The tags for the paragraph
|
||||
* @returns {Array<string>} Custom CSS classes to apply
|
||||
*/
|
||||
processTags(tags) {
|
||||
if (!tags || tags.length === 0) return [];
|
||||
|
||||
const customClasses = [];
|
||||
let tagDebug = "";
|
||||
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
const tag = tags[i];
|
||||
tagDebug += tag + ";";
|
||||
|
||||
// Detect tags of the form "X: Y"
|
||||
const splitTag = this.splitPropertyTag(tag);
|
||||
|
||||
// AUDIO: src
|
||||
if (splitTag && splitTag.property == "AUDIO") {
|
||||
if (this.audioManager) {
|
||||
this.audioManager.playSoundFromUrl(splitTag.val);
|
||||
}
|
||||
}
|
||||
|
||||
// AUDIOLOOP: src
|
||||
else if (splitTag && splitTag.property == "AUDIOLOOP") {
|
||||
if (this.audioManager) {
|
||||
this.audioManager.playSoundFromUrl(splitTag.val, true);
|
||||
}
|
||||
}
|
||||
|
||||
// IMAGE: src
|
||||
else if (splitTag && splitTag.property == "IMAGE") {
|
||||
if (this.layoutRenderer) {
|
||||
const imageElement = this.layoutRenderer.renderVisualTag("IMAGE", splitTag.val, this.storyContainer);
|
||||
if (imageElement && this.layoutRenderer.animationQueue) {
|
||||
this.layoutRenderer.showAfter(this.layoutRenderer.animationQueue.getDelay(), imageElement);
|
||||
this.layoutRenderer.animationQueue.incrementDelay(this.layoutRenderer.animationQueue.getSpeed());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LINK: url
|
||||
else if (splitTag && splitTag.property == "LINK") {
|
||||
window.location.href = splitTag.val;
|
||||
}
|
||||
|
||||
// LINKOPEN: url
|
||||
else if (splitTag && splitTag.property == "LINKOPEN") {
|
||||
window.open(splitTag.val);
|
||||
}
|
||||
|
||||
// BACKGROUND: src
|
||||
else if (splitTag && splitTag.property == "BACKGROUND") {
|
||||
if (this.layoutRenderer) {
|
||||
this.layoutRenderer.renderVisualTag("BACKGROUND", splitTag.val);
|
||||
}
|
||||
}
|
||||
|
||||
// CLASS: className
|
||||
else if (splitTag && splitTag.property == "CLASS") {
|
||||
customClasses.push(splitTag.val);
|
||||
}
|
||||
|
||||
// CLEAR - removes all existing content
|
||||
else if (tag == "CLEAR") {
|
||||
this.removeAll("p");
|
||||
this.removeAll("img");
|
||||
}
|
||||
|
||||
// RESTART - clears everything and restarts the story from the beginning
|
||||
else if (tag == "RESTART") {
|
||||
this.removeAll("p");
|
||||
this.removeAll("img");
|
||||
this.restart();
|
||||
return [];
|
||||
}
|
||||
|
||||
// CHAPTER: Chapter Heading
|
||||
else if (splitTag && splitTag.property == "CHAPTER") {
|
||||
if (this.layoutRenderer) {
|
||||
this.layoutRenderer.renderVisualTag("CHAPTER", splitTag.val, this.storyContainer);
|
||||
}
|
||||
this.chapterBegin = true;
|
||||
this.indentedParagraphs = 2;
|
||||
}
|
||||
|
||||
// SEPARATOR
|
||||
else if (tag == "SEPARATOR") {
|
||||
if (this.layoutRenderer) {
|
||||
this.layoutRenderer.renderVisualTag("SEPARATOR", splitTag.val, this.storyContainer);
|
||||
}
|
||||
this.chapterBegin = true;
|
||||
}
|
||||
}
|
||||
|
||||
return customClasses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a paragraph of text
|
||||
* @param {string} paragraphText - The paragraph text
|
||||
* @param {Array<string>} customClasses - Custom CSS classes to apply
|
||||
* @param {boolean} chapterBegin - Whether this is the beginning of a chapter
|
||||
*/
|
||||
async processParagraph(paragraphText, customClasses, chapterBegin) {
|
||||
const indentWidth = 2 * parseFloat(window.getComputedStyle(document.querySelector("#indent")).lineHeight);
|
||||
let text = paragraphText;
|
||||
let dropCap = null;
|
||||
let dropQuote = null;
|
||||
|
||||
if (this.chapterBegin) {
|
||||
this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width));
|
||||
this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width) - indentWidth);
|
||||
this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width) - indentWidth * 0.9);
|
||||
|
||||
const words = paragraphText.split(" ");
|
||||
let firstWord = words[0].substr(1, words[0].length);
|
||||
let openingQuote = "";
|
||||
let firstLetter = words[0].substr(0, 1);
|
||||
|
||||
if (firstLetter == "\"" || firstLetter == "'") {
|
||||
openingQuote = window.SmartyPants ? window.SmartyPants.smartypantsu(firstLetter, 1) : firstLetter;
|
||||
firstLetter = words[0].substr(1, 1);
|
||||
firstWord = words[0].substr(2, words[0].length);
|
||||
}
|
||||
|
||||
if (firstWord.length < 5 && words.length > 1) {
|
||||
firstWord += ' ' + words[1];
|
||||
}
|
||||
|
||||
text = '<cap>' + firstWord + '</cap> ' + paragraphText.substr(firstWord.length + 2 + openingQuote.length, paragraphText.length);
|
||||
|
||||
dropCap = document.createElement("span");
|
||||
dropCap.classList.add("drop-cap");
|
||||
dropCap.appendChild(document.createTextNode(firstLetter));
|
||||
dropCap.style.left = '0%';
|
||||
dropCap.style.top = '0%';
|
||||
dropCap.style.position = 'absolute';
|
||||
|
||||
if (openingQuote) {
|
||||
dropQuote = document.createElement("span");
|
||||
dropQuote.classList.add("drop-quote");
|
||||
dropQuote.appendChild(document.createTextNode(openingQuote));
|
||||
dropQuote.style.left = '-4.45%';
|
||||
dropQuote.style.top = '-16%';
|
||||
dropQuote.style.position = 'absolute';
|
||||
}
|
||||
} else {
|
||||
if (this.measure.length < 1) {
|
||||
this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width));
|
||||
this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width) - indentWidth * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Process text and calculate layout
|
||||
const processedText = this.textProcessor.process(text);
|
||||
const paragraphData = this.paragraphLayout.calculateLayout(processedText, [...this.measure].reverse(), true);
|
||||
|
||||
// Render paragraph
|
||||
const [p, d] = this.layoutRenderer.renderParagraph(paragraphData, this.layoutRenderer.animationQueue.getDelay(), [...this.measure].reverse());
|
||||
|
||||
// Update measures and indented paragraphs
|
||||
for (let k = 0; k < parseInt(p.dataset.numberOfLines); k++) {
|
||||
this.measure.pop();
|
||||
}
|
||||
|
||||
this.indentedParagraphs -= p.dataset.numberOfLines;
|
||||
|
||||
// Add drop cap and quote if needed
|
||||
if (dropQuote) {
|
||||
this.layoutRenderer.insertAfter(0, p, dropQuote, true);
|
||||
}
|
||||
|
||||
if (dropCap) {
|
||||
this.layoutRenderer.insertAfter(0, p, dropCap, true);
|
||||
}
|
||||
|
||||
// Add custom classes
|
||||
for (let i = 0; i < customClasses.length; i++) {
|
||||
p.classList.add(customClasses[i]);
|
||||
}
|
||||
|
||||
p.lang = this.locale;
|
||||
this.storyContainer.appendChild(p);
|
||||
|
||||
// Smooth scroll to the paragraph
|
||||
this.layoutRenderer.smoothScroll(p, this.layoutRenderer.animationQueue.getSpeed() * 10 * p.dataset.numberOfLines);
|
||||
|
||||
// Wait for animations and speech
|
||||
await Promise.all([
|
||||
new Promise(resolve => {
|
||||
document.addEventListener('allWordsSetEvent', resolve, { once: true });
|
||||
}),
|
||||
new Promise(async resolve => {
|
||||
if (!this.ttsPlayer || !this.ttsPlayer.isEnabled()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ttsPlayer.speak(paragraphText);
|
||||
resolve();
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process choices from the story
|
||||
*/
|
||||
async processChoices() {
|
||||
const categoryContainers = { default: null };
|
||||
const categoryNumbers = { default: 0, categorized: 0 };
|
||||
|
||||
this.story.currentChoices.forEach(choice => {
|
||||
if (this.layoutRenderer.getFastForwardingAll()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tagDebug = "";
|
||||
let action = "default";
|
||||
|
||||
choice.tags.forEach(tag => {
|
||||
tagDebug += tag + ";";
|
||||
const splitTag = this.splitPropertyTag(tag);
|
||||
|
||||
if (splitTag && splitTag.property === "ACTION") {
|
||||
action = splitTag.val;
|
||||
}
|
||||
});
|
||||
|
||||
const prompt = this.translations[this.locale] && this.translations[this.locale][`action_${action}`]
|
||||
? this.translations[this.locale][`action_${action}`]
|
||||
: (action === "default" ? "What do you want to do?" : action);
|
||||
|
||||
if (action != "default") {
|
||||
this.createChoiceContainer(categoryContainers, categoryNumbers, action, prompt, choice, tagDebug);
|
||||
} else {
|
||||
const defaultPrompt = this.translations[this.locale] && this.translations[this.locale]['prompt']
|
||||
? this.translations[this.locale]['prompt']
|
||||
: "What do you want to do?";
|
||||
|
||||
this.createChoiceContainer(categoryContainers, categoryNumbers, "default", defaultPrompt, choice, tagDebug, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Set up key event listener
|
||||
this.keyEventListener = this.setupKeyEventListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a choice container
|
||||
* @param {Object} categoryContainers - Map of category containers
|
||||
* @param {Object} categoryNumbers - Map of category numbers
|
||||
* @param {string} action - The action category
|
||||
* @param {string} prompt - The prompt text
|
||||
* @param {Object} choice - The choice object
|
||||
* @param {string} tagDebug - Debug information for tags
|
||||
* @param {boolean} registerKeys - Whether to register keyboard shortcuts
|
||||
* @returns {HTMLElement} The choice container element
|
||||
*/
|
||||
createChoiceContainer(categoryContainers, categoryNumbers, action, prompt, choice, tagDebug, registerKeys = false) {
|
||||
let choiceCategoryContainer = categoryContainers[action];
|
||||
|
||||
if (!choiceCategoryContainer) {
|
||||
choiceCategoryContainer = document.createElement('ol');
|
||||
const p = document.createElement('p');
|
||||
p.innerHTML = prompt;
|
||||
choiceCategoryContainer.appendChild(p);
|
||||
choiceCategoryContainer.classList.add("choice");
|
||||
choiceCategoryContainer.classList.add("fade-in");
|
||||
|
||||
if (!registerKeys) {
|
||||
choiceCategoryContainer.classList.add("categorized");
|
||||
}
|
||||
|
||||
if (this.story.currentChoices.length && !this.layoutRenderer.getFastForwardingAll()) {
|
||||
this.choiceContainer.appendChild(choiceCategoryContainer);
|
||||
}
|
||||
}
|
||||
|
||||
categoryContainers[action] = choiceCategoryContainer;
|
||||
|
||||
let choiceNumber = categoryNumbers[action];
|
||||
if (choiceNumber === undefined) {
|
||||
choiceNumber = 0;
|
||||
}
|
||||
|
||||
choiceNumber++;
|
||||
|
||||
const choiceParagraphElement = document.createElement('li');
|
||||
choiceParagraphElement.classList.add("choice");
|
||||
choiceParagraphElement.lang = this.locale;
|
||||
choiceParagraphElement.title = tagDebug;
|
||||
|
||||
// Use SmartyPants if available
|
||||
const choiceText = window.SmartyPants && typeof window.SmartyPants.smartypantsu === 'function'
|
||||
? window.SmartyPants.smartypantsu(choice.text, 1)
|
||||
: choice.text;
|
||||
|
||||
choiceParagraphElement.innerHTML = `<a href='#'>${choiceText}</a>`;
|
||||
|
||||
if (!this.layoutRenderer.getFastForwardingAll()) {
|
||||
this.layoutRenderer.insertAfter(this.layoutRenderer.animationQueue.getDelay(), choiceCategoryContainer, choiceParagraphElement, true);
|
||||
}
|
||||
|
||||
this.layoutRenderer.animationQueue.incrementDelay(this.layoutRenderer.animationQueue.getSpeed());
|
||||
|
||||
// Register key shortcuts
|
||||
if (registerKeys) {
|
||||
choiceParagraphElement.value = choiceNumber;
|
||||
this.registerKey('Digit' + choiceNumber, choice.index);
|
||||
} else {
|
||||
let categorizedNumber = categoryNumbers['categorized'];
|
||||
categorizedNumber++;
|
||||
const keyLetter = String.fromCharCode(64 + categorizedNumber);
|
||||
choiceParagraphElement.value = categorizedNumber;
|
||||
this.registerKey('Key' + keyLetter, choice.index);
|
||||
categoryNumbers['categorized'] = categorizedNumber;
|
||||
}
|
||||
|
||||
// Click on choice
|
||||
const choiceAnchorEl = choiceParagraphElement.querySelectorAll("a")[0];
|
||||
choiceAnchorEl.addEventListener("click", (event) => {
|
||||
// Don't follow <a> link
|
||||
event.preventDefault();
|
||||
this.chooseChoice(choice.index);
|
||||
});
|
||||
|
||||
categoryNumbers[action] = choiceNumber;
|
||||
|
||||
return choiceCategoryContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose a choice by index
|
||||
* @param {number} index - The choice index
|
||||
*/
|
||||
chooseChoice(index) {
|
||||
// Remove all existing choices
|
||||
this.removeAll(".choice", true);
|
||||
this.clearKeyRegistry();
|
||||
|
||||
// Tell the story where to go next
|
||||
this.story.ChooseChoiceIndex(index);
|
||||
|
||||
// This is where the save button will save from
|
||||
this.savePoint = this.story.state.toJson();
|
||||
|
||||
// Continue the story
|
||||
this.continueStory(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a key for keyboard shortcuts
|
||||
* @param {string} key - The key code
|
||||
* @param {number} choice - The choice index
|
||||
*/
|
||||
registerKey(key, choice) {
|
||||
this.keyRegistry[key] = choice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the key registry
|
||||
*/
|
||||
clearKeyRegistry() {
|
||||
this.keyRegistry = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the key event listener for choices
|
||||
* @returns {Function} The key event listener
|
||||
*/
|
||||
setupKeyEventListener() {
|
||||
const keyEventListener = (event) => {
|
||||
for (const key in this.keyRegistry) {
|
||||
if (event.code === key) {
|
||||
window.removeEventListener('keypress', keyEventListener);
|
||||
this.chooseChoice(this.keyRegistry[key]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keypress', keyEventListener);
|
||||
|
||||
return keyEventListener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the story
|
||||
*/
|
||||
restart() {
|
||||
if (!this.story) return;
|
||||
|
||||
if (this.layoutRenderer.animationQueue) {
|
||||
this.layoutRenderer.animationQueue.setDelay(0.0);
|
||||
}
|
||||
|
||||
this.story.ResetState();
|
||||
this.layoutRenderer.setFastForwardingAll(true);
|
||||
this.layoutRenderer.animationQueue.fastForward();
|
||||
|
||||
this.removeAll("p");
|
||||
this.removeAll("img");
|
||||
this.removeAll("h2");
|
||||
this.removeAll("double");
|
||||
this.removeAll(".choice", true);
|
||||
|
||||
if (this.keyEventListener) {
|
||||
window.removeEventListener('keypress', this.keyEventListener);
|
||||
this.keyEventListener = null;
|
||||
}
|
||||
|
||||
// Set save point to here
|
||||
this.savePoint = this.story.state.toJson();
|
||||
|
||||
// Continue the story
|
||||
this.continueStory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current state
|
||||
* @returns {boolean} Whether the save was successful
|
||||
*/
|
||||
saveState() {
|
||||
if (!this.persistenceManager || !this.story) return false;
|
||||
|
||||
try {
|
||||
const history = Array.from(document.querySelectorAll("#story p:not(.latest-paragraph)")).map(p => p.outerHTML);
|
||||
|
||||
return this.persistenceManager.saveState({
|
||||
inkJson: this.savePoint,
|
||||
history: history
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Couldn't save state:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a saved state
|
||||
* @returns {boolean} Whether the load was successful
|
||||
*/
|
||||
loadState() {
|
||||
if (!this.persistenceManager || !this.story) return false;
|
||||
|
||||
try {
|
||||
const savedState = this.persistenceManager.loadState();
|
||||
|
||||
if (!savedState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeAll("p");
|
||||
this.removeAll("img");
|
||||
this.removeAll("h2");
|
||||
this.removeAll("double");
|
||||
this.removeAll(".choice", true);
|
||||
|
||||
if (savedState.history) {
|
||||
savedState.history.forEach(p => {
|
||||
const d = document.createElement('div');
|
||||
d.innerHTML = p;
|
||||
this.storyContainer.appendChild(d.firstChild);
|
||||
});
|
||||
}
|
||||
|
||||
if (savedState.inkJson) {
|
||||
this.story.state.LoadJson(savedState.inkJson);
|
||||
this.savePoint = savedState.inkJson;
|
||||
}
|
||||
|
||||
// Update paragraph heights
|
||||
this.updateParagraphHeight();
|
||||
|
||||
if (this.keyEventListener) {
|
||||
window.removeEventListener('keypress', this.keyEventListener);
|
||||
this.keyEventListener = null;
|
||||
}
|
||||
|
||||
// Continue the story
|
||||
this.continueStory();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn("Couldn't load state:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update paragraph heights based on viewport
|
||||
*/
|
||||
updateParagraphHeight() {
|
||||
document.querySelectorAll("#story p").forEach((element) => {
|
||||
if (element.dataset.vpc) {
|
||||
const pHeight = parseFloat(window.getComputedStyle(document.getElementById('page_right')).height);
|
||||
const newHeight = pHeight * element.dataset.vpc / 100 + 'px';
|
||||
element.style.height = newHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all elements that match the given selector
|
||||
* @param {string} selector - The CSS selector
|
||||
* @param {boolean} choices - Whether to remove from the choice container
|
||||
*/
|
||||
removeAll(selector, choices = false) {
|
||||
const container = choices ? this.choiceContainer : this.storyContainer;
|
||||
const allElements = container.querySelectorAll(selector);
|
||||
|
||||
for (let i = 0; i < allElements.length; i++) {
|
||||
const el = allElements[i];
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for parsing out tags of the form: # PROPERTY: value
|
||||
* @param {string} tag - The tag to parse
|
||||
* @returns {Object|null} The parsed property and value
|
||||
*/
|
||||
splitPropertyTag(tag) {
|
||||
const propertySplitIdx = tag.indexOf(":");
|
||||
|
||||
if (propertySplitIdx !== -1) {
|
||||
const property = tag.substr(0, propertySplitIdx).trim();
|
||||
const val = tag.substr(propertySplitIdx + 1).trim();
|
||||
|
||||
return {
|
||||
property: property,
|
||||
val: val
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the locale for translations
|
||||
* @param {string} locale - The locale code
|
||||
*/
|
||||
setLocale(locale) {
|
||||
this.locale = locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set translations
|
||||
* @param {Object} translations - The translations object
|
||||
*/
|
||||
setTranslations(translations) {
|
||||
this.translations = translations;
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
/**
|
||||
* Input Handler Module
|
||||
* Manages the multi-line text input field with a custom cursor.
|
||||
*/
|
||||
export class InputHandler {
|
||||
constructor(inputId = 'player_input', cursorId = 'cursor') {
|
||||
this.playerInput = document.getElementById(inputId);
|
||||
this.cursor = document.getElementById(cursorId);
|
||||
this.commandInputContainer = document.getElementById('command_input'); // Assuming this container exists
|
||||
|
||||
if (!this.playerInput || !this.cursor || !this.commandInputContainer) {
|
||||
console.error('InputHandler: Required DOM elements not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.commandSubmitCallback = null; // Callback for when a command is submitted
|
||||
|
||||
this.bindEvents();
|
||||
this.adjustTextareaHeight(); // Initial adjustment
|
||||
this.updateCursorPosition(); // Initial position
|
||||
|
||||
// Setup handler for window load event to ensure proper initialization
|
||||
window.addEventListener('load', () => {
|
||||
console.log('InputHandler: Window loaded, adjusting text area height and cursor position');
|
||||
this.adjustTextareaHeight();
|
||||
this.updateCursorPosition();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback function to be called when a command is submitted.
|
||||
* @param {function(string)} callback - The function to call with the command text.
|
||||
*/
|
||||
onCommandSubmit(callback) {
|
||||
this.commandSubmitCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event handlers to the input element.
|
||||
*/
|
||||
bindEvents() {
|
||||
// Submit command on Enter key without Shift
|
||||
this.playerInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault(); // Prevent default to avoid newline
|
||||
this.submitCommand();
|
||||
}
|
||||
// Allow Shift+Enter for new lines (default behavior)
|
||||
});
|
||||
|
||||
// Auto-resize textarea and update cursor on input
|
||||
this.playerInput.addEventListener('input', () => {
|
||||
this.adjustTextareaHeight();
|
||||
this.updateCursorPosition();
|
||||
});
|
||||
|
||||
// Update cursor on various events
|
||||
this.playerInput.addEventListener('click', this.updateCursorPosition.bind(this));
|
||||
this.playerInput.addEventListener('keyup', this.updateCursorPosition.bind(this));
|
||||
|
||||
// Show/hide cursor on focus/blur
|
||||
this.playerInput.addEventListener('focus', () => {
|
||||
if (this.cursor) this.cursor.style.opacity = '1';
|
||||
this.updateCursorPosition();
|
||||
});
|
||||
this.playerInput.addEventListener('blur', () => {
|
||||
if (this.cursor) this.cursor.style.opacity = '0';
|
||||
});
|
||||
|
||||
// Handle paste events
|
||||
this.playerInput.addEventListener('paste', () => {
|
||||
// Use setTimeout to let the paste complete before adjusting
|
||||
setTimeout(() => {
|
||||
this.adjustTextareaHeight();
|
||||
this.updateCursorPosition();
|
||||
}, 10);
|
||||
});
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
this.adjustTextareaHeight();
|
||||
this.updateCursorPosition();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the current command.
|
||||
*/
|
||||
submitCommand() {
|
||||
const command = this.playerInput.value.trim();
|
||||
if (command === '' || !this.commandSubmitCallback) return;
|
||||
|
||||
// Fade out the input field container
|
||||
if (this.commandInputContainer) {
|
||||
this.commandInputContainer.classList.add('fading');
|
||||
}
|
||||
|
||||
// Disable input temporarily
|
||||
this.playerInput.disabled = true;
|
||||
|
||||
// Call the registered callback
|
||||
this.commandSubmitCallback(command);
|
||||
|
||||
// Clear input
|
||||
this.clearInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the input field and resets its state.
|
||||
*/
|
||||
clearInput() {
|
||||
this.playerInput.value = '';
|
||||
this.resetCursorPosition();
|
||||
this.adjustTextareaHeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enables the input field after a command submission or response.
|
||||
*/
|
||||
enableInput() {
|
||||
if (this.commandInputContainer) {
|
||||
// Remove fading class and add fade-in animation
|
||||
this.commandInputContainer.classList.remove('fading');
|
||||
this.commandInputContainer.classList.add('fade-in-input');
|
||||
|
||||
// Remove animation class after it completes
|
||||
setTimeout(() => {
|
||||
if (this.commandInputContainer) {
|
||||
this.commandInputContainer.classList.remove('fade-in-input');
|
||||
}
|
||||
}, 500); // Match CSS animation duration
|
||||
}
|
||||
|
||||
this.playerInput.disabled = false;
|
||||
this.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the input field.
|
||||
*/
|
||||
focus() {
|
||||
this.playerInput.focus();
|
||||
// Ensure cursor is visible and positioned correctly after focus
|
||||
setTimeout(() => {
|
||||
if (this.cursor) this.cursor.style.opacity = '1';
|
||||
this.updateCursorPosition();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current value of the input field.
|
||||
* @returns {string} The input text.
|
||||
*/
|
||||
getValue() {
|
||||
return this.playerInput.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of the input field.
|
||||
* @param {string} value - The text to set.
|
||||
*/
|
||||
setValue(value) {
|
||||
this.playerInput.value = value;
|
||||
this.adjustTextareaHeight();
|
||||
this.updateCursorPosition();
|
||||
this.focus(); // Focus after setting value
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the cursor position to the start.
|
||||
*/
|
||||
resetCursorPosition() {
|
||||
if (this.cursor) {
|
||||
this.cursor.style.left = '0px';
|
||||
// Adjust top based on computed style padding or a default
|
||||
const computedStyle = window.getComputedStyle(this.playerInput);
|
||||
const paddingTop = parseFloat(computedStyle.paddingTop) || 6;
|
||||
this.cursor.style.top = `${paddingTop}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the custom cursor position based on input text and caret position.
|
||||
* Uses a temporary div for accurate measurement.
|
||||
*/
|
||||
updateCursorPosition() {
|
||||
if (!this.cursor || !this.playerInput) return;
|
||||
|
||||
const input = this.playerInput;
|
||||
const cursor = this.cursor;
|
||||
const caretPosition = input.selectionStart || 0;
|
||||
const inputText = input.value;
|
||||
|
||||
// If no text, position cursor at the beginning based on padding
|
||||
if (inputText.length === 0 && caretPosition === 0) {
|
||||
this.resetCursorPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a temporary measurement div
|
||||
const div = document.createElement('div');
|
||||
const style = getComputedStyle(input);
|
||||
|
||||
// Apply relevant styles from the textarea to the div
|
||||
div.style.position = 'absolute';
|
||||
div.style.top = '-9999px';
|
||||
div.style.left = '-9999px';
|
||||
div.style.width = style.width;
|
||||
div.style.height = 'auto';
|
||||
div.style.padding = style.padding;
|
||||
div.style.border = style.border;
|
||||
div.style.fontFamily = style.fontFamily;
|
||||
div.style.fontSize = style.fontSize;
|
||||
div.style.fontWeight = style.fontWeight;
|
||||
div.style.lineHeight = style.lineHeight;
|
||||
div.style.whiteSpace = 'pre-wrap';
|
||||
div.style.wordWrap = 'break-word';
|
||||
div.style.boxSizing = style.boxSizing;
|
||||
|
||||
// Create spans for text before and after the caret, and a marker span
|
||||
const preCaretText = document.createTextNode(inputText.substring(0, caretPosition));
|
||||
const caretMarker = document.createElement('span');
|
||||
caretMarker.innerHTML = ' '; // Use non-breaking space for measurement
|
||||
const postCaretText = document.createTextNode(inputText.substring(caretPosition));
|
||||
|
||||
// Append spans to the div
|
||||
div.appendChild(preCaretText);
|
||||
div.appendChild(caretMarker);
|
||||
div.appendChild(postCaretText);
|
||||
|
||||
// Append div to body for measurement
|
||||
document.body.appendChild(div);
|
||||
|
||||
// Get position relative to the div's content box
|
||||
const markerRect = caretMarker.getBoundingClientRect();
|
||||
const divRect = div.getBoundingClientRect();
|
||||
|
||||
// Calculate position relative to the input's top-left, considering scroll
|
||||
const cursorLeft = markerRect.left - divRect.left;
|
||||
const cursorTop = markerRect.top - divRect.top - input.scrollTop;
|
||||
|
||||
// Set cursor position
|
||||
cursor.style.left = `${cursorLeft}px`;
|
||||
cursor.style.top = `${cursorTop}px`;
|
||||
|
||||
// Clean up the temporary div
|
||||
document.body.removeChild(div);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust textarea height based on its content.
|
||||
*/
|
||||
adjustTextareaHeight() {
|
||||
if (!this.playerInput) return;
|
||||
const textarea = this.playerInput;
|
||||
// Temporarily reset height to accurately measure scrollHeight
|
||||
textarea.style.height = 'auto';
|
||||
// Set height to scrollHeight to fit content, adding a small buffer if needed
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up focus management to keep the input field focused.
|
||||
* Note: Some parts might be better handled by the main application logic
|
||||
* depending on overall focus requirements (e.g., clicking outside input).
|
||||
*/
|
||||
setupFocusManagement() {
|
||||
// Focus input field when the handler is initialized
|
||||
this.focus();
|
||||
|
||||
// Re-focus input when user returns to this browser tab/window
|
||||
window.addEventListener('focus', () => this.focus());
|
||||
window.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
setTimeout(() => this.focus(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Optional: Add a listener to the document to refocus if needed,
|
||||
// but be careful not to interfere with other interactive elements.
|
||||
/*
|
||||
document.addEventListener('click', (e) => {
|
||||
// Example: Refocus if click is not on specific elements
|
||||
if (!e.target.closest('button, a, .interactive-ui-element')) {
|
||||
this.focus();
|
||||
}
|
||||
});
|
||||
*/
|
||||
}
|
||||
}
|
||||
+670
-559
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Kokoro Web Worker
|
||||
* Handles TTS processing in a separate thread to keep UI responsive
|
||||
*/
|
||||
|
||||
// Global variables
|
||||
let kokoroLoaded = false;
|
||||
let isProcessing = false;
|
||||
let voiceOptions = {
|
||||
voice: 'bf_alice',
|
||||
speed: 1.0
|
||||
};
|
||||
|
||||
// Initialize when receiving init message
|
||||
self.onmessage = function(e) {
|
||||
const message = e.data;
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'init':
|
||||
// Just acknowledge initialization - actual model loading happens on first generate call
|
||||
self.postMessage({ type: 'ready' });
|
||||
break;
|
||||
|
||||
case 'generate':
|
||||
if (!message.data || !message.data.text) {
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: 'No text provided for generation'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Store voice options
|
||||
if (message.data.voice) voiceOptions.voice = message.data.voice;
|
||||
if (message.data.speed) voiceOptions.speed = message.data.speed;
|
||||
|
||||
// Generate speech
|
||||
generateSpeech(message.data.text)
|
||||
.catch(error => {
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: `Generation error: ${error.message || error}`
|
||||
});
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: `Unknown message type: ${message.type}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: `Worker error: ${error.message || error}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate speech from text
|
||||
* @param {string} text - Text to convert to speech
|
||||
*/
|
||||
async function generateSpeech(text) {
|
||||
if (isProcessing) {
|
||||
throw new Error('Already processing another request');
|
||||
}
|
||||
|
||||
isProcessing = true;
|
||||
|
||||
try {
|
||||
// Load Kokoro if not already loaded
|
||||
if (!kokoroLoaded) {
|
||||
// Load the Kokoro script
|
||||
self.importScripts('/js/kokoro-js.js');
|
||||
|
||||
if (!self.kokoro || !self.kokoro.KokoroTTS) {
|
||||
throw new Error('Kokoro failed to load correctly');
|
||||
}
|
||||
|
||||
kokoroLoaded = true;
|
||||
}
|
||||
|
||||
// Create a new Kokoro instance for this generation
|
||||
// We can't easily transfer the instance from the main thread, so we create it here
|
||||
const kokoroTTS = self.kokoro.KokoroTTS;
|
||||
|
||||
// Create instance using from_pretrained
|
||||
const tts = await kokoroTTS.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", {
|
||||
dtype: "fp32",
|
||||
device: "wasm",
|
||||
cache: true // Use cache to speed up subsequent loads
|
||||
});
|
||||
|
||||
// Generate speech
|
||||
const result = await tts.generate(text, {
|
||||
voice: voiceOptions.voice,
|
||||
speed: voiceOptions.speed
|
||||
});
|
||||
|
||||
// Send the result back to the main thread
|
||||
// We can't transfer the Float32Array directly, so let's transfer the buffer
|
||||
const audioBuffer = result.audio.buffer;
|
||||
|
||||
self.postMessage({
|
||||
type: 'generated',
|
||||
result: {
|
||||
audio: audioBuffer,
|
||||
sampling_rate: result.sampling_rate
|
||||
}
|
||||
}, [audioBuffer]); // Transfer the buffer for better performance
|
||||
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
/**
|
||||
* Module Loader System
|
||||
*
|
||||
* Handles loading and initializing modules in the correct order,
|
||||
* with dependency management and progress reporting.
|
||||
*/
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
import { ModuleEvent } from './base-module.js';
|
||||
|
||||
/**
|
||||
* Module States
|
||||
*/
|
||||
const ModuleState = {
|
||||
PENDING: 'PENDING',
|
||||
LOADING: 'LOADING',
|
||||
WAITING: 'WAITING',
|
||||
INITIALIZING: 'INITIALIZING',
|
||||
FINISHED: 'FINISHED',
|
||||
ERROR: 'ERROR'
|
||||
};
|
||||
|
||||
/**
|
||||
* Module Loader - Manages the loading of all modules
|
||||
*/
|
||||
const ModuleLoader = (function() {
|
||||
// Private variables
|
||||
let loadingOverlay = null;
|
||||
let modulesList = null;
|
||||
let progressIndicator = null;
|
||||
let progressText = null;
|
||||
let statusText = null;
|
||||
let isLoadingComplete = false;
|
||||
let moduleWeights = {};
|
||||
let createdModules = new Set(); // Track which modules we've created UI elements for
|
||||
let gameLoopModule = null; // Add variable to hold game loop instance
|
||||
|
||||
/**
|
||||
* Initialize the loader
|
||||
*/
|
||||
function init() {
|
||||
// Prevent duplicate initialization
|
||||
if (createdModules.size > 0) {
|
||||
console.warn('Module Loader already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Module Loader: Initialization started');
|
||||
// Create the loading overlay
|
||||
createLoadingOverlay();
|
||||
|
||||
// Setup event listeners
|
||||
setupEventListeners();
|
||||
|
||||
// Load available module scripts
|
||||
loadModuleScripts().then(() => {
|
||||
// Once scripts are loaded, initialize modules
|
||||
initializeModules();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for module communication
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
// Listen for module progress events
|
||||
document.addEventListener('module:progress', handleModuleProgress);
|
||||
|
||||
// Listen for module state change events
|
||||
document.addEventListener('module:stateChange', handleModuleStateChange);
|
||||
|
||||
// Listen for module status message events
|
||||
document.addEventListener('module:message', handleModuleMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all module scripts
|
||||
* @returns {Promise} - Resolves when all module scripts are loaded
|
||||
*/
|
||||
async function loadModuleScripts() {
|
||||
// Define modules with their weights
|
||||
const modulesToLoad = [
|
||||
// Core functionality modules
|
||||
{ id: 'persistence-manager', script: '/js/persistence-manager.js', weight: 40 },
|
||||
{ id: 'localization', script: '/js/localization.js', weight: 40 },
|
||||
{ id: 'text-processor', script: '/js/text-processor.js', weight: 40 },
|
||||
{ id: 'paragraph-layout', script: '/js/paragraph-layout.js', weight: 40 },
|
||||
{ id: 'animation-queue', script: '/js/animation-queue.js', weight: 50 },
|
||||
|
||||
// Audio and TTS modules
|
||||
{ id: 'audio-manager', script: '/js/audio-manager.js', weight: 60 },
|
||||
{ id: 'tts', script: '/js/tts-player.js', weight: 75 },
|
||||
|
||||
// UI and interaction modules
|
||||
{ id: 'text-buffer', script: '/js/text-buffer.js', weight: 50 },
|
||||
{ id: 'ui-effects', script: '/js/ui-effects.js', weight: 50 }, // Add UI Effects module
|
||||
{ id: 'ui-input-handler', script: '/js/ui-input-handler.js', weight: 50 }, // Add UI Input Handler module
|
||||
{ id: 'ui-display-handler', script: '/js/ui-display-handler.js', weight: 60 }, // Add UI Display Handler module
|
||||
{ id: 'ui-controller', script: '/js/ui-controller.js', weight: 100 },
|
||||
{ id: 'options-ui', script: '/js/options-ui.js', weight: 40 },
|
||||
{ id: 'socket-client', script: '/js/socket-client.js', weight: 60 },
|
||||
|
||||
// Main game module - should be last to load
|
||||
{ id: 'game-loop', script: '/js/game-loop.js', weight: 25 }
|
||||
];
|
||||
|
||||
// Store module weights for progress calculation
|
||||
modulesToLoad.forEach(module => {
|
||||
moduleWeights[module.id] = module.weight;
|
||||
});
|
||||
|
||||
// Create a module list entry for each module
|
||||
modulesToLoad.forEach(module => {
|
||||
createModuleListItem(module.id, getModuleNameFromId(module.id));
|
||||
});
|
||||
|
||||
// Load each module script
|
||||
const loadPromises = modulesToLoad.map(module => loadScript(module.script));
|
||||
return Promise.all(loadPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a script dynamically
|
||||
* @param {string} src - Script source URL
|
||||
* @returns {Promise} - Resolves when script is loaded
|
||||
*/
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.type = 'module';
|
||||
script.src = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all registered modules
|
||||
*/
|
||||
function initializeModules() {
|
||||
const modules = moduleRegistry.getAllModules();
|
||||
|
||||
// Find the game loop module instance
|
||||
gameLoopModule = moduleRegistry.getModule('game-loop');
|
||||
|
||||
// For each registered module, start initialization
|
||||
Object.values(modules).forEach(async (module) => {
|
||||
try {
|
||||
// Create a progress callback for this module
|
||||
const progressCallback = (percent, message) => {
|
||||
handleModuleProgress({
|
||||
detail: {
|
||||
moduleId: module.id,
|
||||
progress: percent
|
||||
}
|
||||
});
|
||||
|
||||
if (message) {
|
||||
handleModuleMessage({
|
||||
detail: {
|
||||
moduleId: module.id,
|
||||
message
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize the module with progress callback
|
||||
await module.initializeInterface(progressCallback);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error initializing module ${module.id}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable module name from its ID
|
||||
* @param {string} id - Module ID
|
||||
* @returns {string} - User-friendly module name
|
||||
*/
|
||||
function getModuleNameFromId(id) {
|
||||
// Convert kebab-case to Title Case
|
||||
return id
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the loading overlay
|
||||
*/
|
||||
function createLoadingOverlay() {
|
||||
// Find existing elements or create minimal ones for progress reporting
|
||||
loadingOverlay = document.querySelector('.loading-overlay');
|
||||
|
||||
if (!loadingOverlay) {
|
||||
// If no overlay exists in the HTML, create a minimal one
|
||||
loadingOverlay = document.createElement('div');
|
||||
loadingOverlay.className = 'loading-overlay';
|
||||
loadingOverlay.style.transition = 'opacity 0.5s ease-out';
|
||||
document.body.appendChild(loadingOverlay);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'loading-content';
|
||||
|
||||
const title = document.createElement('h2');
|
||||
title.textContent = 'Loading Interface';
|
||||
content.appendChild(title);
|
||||
|
||||
const progressBar = document.createElement('div');
|
||||
progressBar.className = 'loading-bar';
|
||||
|
||||
progressIndicator = document.createElement('div');
|
||||
progressIndicator.className = 'loading-progress';
|
||||
progressBar.appendChild(progressIndicator);
|
||||
|
||||
progressText = document.createElement('div');
|
||||
progressText.className = 'loading-text';
|
||||
progressText.textContent = '0%';
|
||||
progressBar.appendChild(progressText);
|
||||
|
||||
statusText = document.createElement('div');
|
||||
statusText.className = 'loading-status';
|
||||
statusText.textContent = 'Loading modules...';
|
||||
|
||||
modulesList = document.createElement('ul');
|
||||
modulesList.id = 'modules-list';
|
||||
|
||||
content.appendChild(progressBar);
|
||||
content.appendChild(statusText);
|
||||
content.appendChild(modulesList);
|
||||
loadingOverlay.appendChild(content);
|
||||
} else {
|
||||
// If overlay exists, find the progress elements
|
||||
progressIndicator = loadingOverlay.querySelector('.loading-progress');
|
||||
progressText = loadingOverlay.querySelector('.loading-text');
|
||||
statusText = loadingOverlay.querySelector('.loading-status');
|
||||
modulesList = loadingOverlay.querySelector('#modules-list');
|
||||
|
||||
// Ensure transition is set
|
||||
loadingOverlay.style.transition = 'opacity 0.5s ease-out';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a module list item in the UI
|
||||
* @param {string} id - Module ID
|
||||
* @param {string} name - Module display name
|
||||
*/
|
||||
function createModuleListItem(id, name) {
|
||||
if (!modulesList) return;
|
||||
|
||||
// Check if we've already created this module item
|
||||
if (createdModules.has(id)) return;
|
||||
|
||||
// Mark this module as created
|
||||
createdModules.add(id);
|
||||
|
||||
const moduleItem = document.createElement('li');
|
||||
moduleItem.className = 'module-item';
|
||||
moduleItem.id = `module-${id}`;
|
||||
moduleItem.innerHTML = `
|
||||
<span class="module-name">${name}</span>
|
||||
<span class="module-status status-pending">Pending</span>
|
||||
`;
|
||||
modulesList.appendChild(moduleItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle module progress events
|
||||
*/
|
||||
function handleModuleProgress(event) {
|
||||
const { moduleId, progress } = event.detail;
|
||||
updateModuleProgress(moduleId, progress);
|
||||
updateOverallProgress();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle module state change events
|
||||
*/
|
||||
function handleModuleStateChange(event) {
|
||||
const { moduleId, state } = event.detail;
|
||||
updateModuleState(moduleId, state);
|
||||
updateOverallProgress();
|
||||
|
||||
// Check if all modules are finished after each state change
|
||||
checkAllFinished();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle module status message events
|
||||
*/
|
||||
function handleModuleMessage(event) {
|
||||
const { moduleId, message } = event.detail;
|
||||
updateModuleStatusText(moduleId, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all modules are finished loading
|
||||
*/
|
||||
function checkAllFinished() {
|
||||
const modules = moduleRegistry.getAllModules();
|
||||
const allFinished = Object.values(modules).every(module => {
|
||||
const state = module.getState();
|
||||
return state === ModuleState.FINISHED || state === ModuleState.ERROR;
|
||||
});
|
||||
|
||||
if (allFinished && !isLoadingComplete) {
|
||||
finalizeLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize the loading process
|
||||
*/
|
||||
function finalizeLoading() {
|
||||
console.log('Loading completed. Finalizing...');
|
||||
completeFinalization();
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the finalization process
|
||||
*/
|
||||
function completeFinalization() {
|
||||
isLoadingComplete = true;
|
||||
|
||||
// Call the start method on the game loop module directly
|
||||
// Ensure the game loop module was found during initialization
|
||||
if (gameLoopModule && typeof gameLoopModule.start === 'function') {
|
||||
// Hide the overlay first, then start the game loop
|
||||
hideOverlay(() => {
|
||||
console.log("Loader: Overlay hidden, starting Game Loop.");
|
||||
gameLoopModule.start();
|
||||
});
|
||||
} else {
|
||||
console.error("Loader: Game Loop module not found or start method missing.");
|
||||
// Hide overlay anyway, but log error
|
||||
hideOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the loading overlay with a fade out animation
|
||||
* Then completely remove it from the DOM
|
||||
* @param {Function} [callback] - Optional callback to execute after fade completes
|
||||
*/
|
||||
function hideOverlay(callback) { // Added callback parameter
|
||||
if (!loadingOverlay) {
|
||||
if (callback) callback(); // Call callback immediately if no overlay
|
||||
return;
|
||||
}
|
||||
|
||||
// Set opacity to 0 to trigger the fade-out transition
|
||||
loadingOverlay.style.opacity = '0';
|
||||
|
||||
// Use transition event listener to remove from DOM after fade completes
|
||||
loadingOverlay.addEventListener('transitionend', function handler(e) {
|
||||
// Only handle the opacity transition
|
||||
if (e.propertyName === 'opacity') {
|
||||
console.log('Module Loader: Removing overlay from DOM');
|
||||
|
||||
// Remove from DOM completely
|
||||
if (loadingOverlay.parentNode) {
|
||||
loadingOverlay.parentNode.removeChild(loadingOverlay);
|
||||
}
|
||||
|
||||
// Remove the event listener to prevent memory leaks
|
||||
loadingOverlay.removeEventListener('transitionend', handler);
|
||||
|
||||
// Set to null to allow garbage collection
|
||||
loadingOverlay = null;
|
||||
|
||||
// Execute the callback if provided
|
||||
if (callback) callback();
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback in case the transition event doesn't fire
|
||||
setTimeout(() => {
|
||||
if (loadingOverlay && loadingOverlay.parentNode) {
|
||||
console.log('Module Loader: Removing overlay from DOM (fallback)');
|
||||
loadingOverlay.parentNode.removeChild(loadingOverlay);
|
||||
loadingOverlay = null;
|
||||
}
|
||||
// Execute callback in fallback as well
|
||||
if (callback) callback();
|
||||
}, 1000); // Wait longer than the transition duration
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the state of a module
|
||||
* @param {string} id - Module ID
|
||||
* @param {string} state - New state
|
||||
*/
|
||||
function updateModuleState(id, state) {
|
||||
// Update UI
|
||||
const moduleItem = document.getElementById(`module-${id}`);
|
||||
if (!moduleItem) return;
|
||||
|
||||
const statusElement = moduleItem.querySelector('.module-status');
|
||||
if (!statusElement) return;
|
||||
|
||||
// Remove all status classes
|
||||
statusElement.classList.remove(
|
||||
'status-pending',
|
||||
'status-loading',
|
||||
'status-waiting',
|
||||
'status-initializing',
|
||||
'status-finished',
|
||||
'status-error'
|
||||
);
|
||||
|
||||
// Add appropriate class and text
|
||||
let statusText = '';
|
||||
switch (state) {
|
||||
case ModuleState.PENDING:
|
||||
statusElement.classList.add('status-pending');
|
||||
statusText = 'Pending';
|
||||
break;
|
||||
case ModuleState.LOADING:
|
||||
statusElement.classList.add('status-loading');
|
||||
statusText = 'Loading';
|
||||
break;
|
||||
case ModuleState.WAITING:
|
||||
statusElement.classList.add('status-waiting');
|
||||
statusText = 'Waiting';
|
||||
break;
|
||||
case ModuleState.INITIALIZING:
|
||||
statusElement.classList.add('status-initializing');
|
||||
statusText = 'Initializing';
|
||||
break;
|
||||
case ModuleState.FINISHED:
|
||||
statusElement.classList.add('status-finished');
|
||||
statusText = 'Finished';
|
||||
break;
|
||||
case ModuleState.ERROR:
|
||||
statusElement.classList.add('status-error');
|
||||
statusText = 'Error';
|
||||
break;
|
||||
}
|
||||
|
||||
statusElement.textContent = statusText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the progress of a module
|
||||
* @param {string} id - Module ID
|
||||
* @param {number} progress - Progress percentage (0-100)
|
||||
*/
|
||||
function updateModuleProgress(id, progress) {
|
||||
// Module states are now managed by the module itself
|
||||
|
||||
// Update any additional UI elements for module progress if needed
|
||||
const moduleItem = document.getElementById(`module-${id}`);
|
||||
if (moduleItem) {
|
||||
// Update progress display if needed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status text of a module in the UI
|
||||
* @param {string} id - Module ID
|
||||
* @param {string} text - Status text to display
|
||||
*/
|
||||
function updateModuleStatusText(id, text) {
|
||||
const moduleItem = document.getElementById(`module-${id}`);
|
||||
if (!moduleItem) return;
|
||||
|
||||
let statusDetailElement = moduleItem.querySelector('.module-status-detail');
|
||||
|
||||
if (!statusDetailElement) {
|
||||
statusDetailElement = document.createElement('span');
|
||||
statusDetailElement.className = 'module-status-detail';
|
||||
moduleItem.appendChild(statusDetailElement);
|
||||
}
|
||||
|
||||
statusDetailElement.textContent = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update overall progress based on module weights and progress
|
||||
*/
|
||||
function updateOverallProgress() {
|
||||
const modules = moduleRegistry.getAllModules();
|
||||
const moduleIds = Object.keys(modules);
|
||||
|
||||
// Calculate total weight
|
||||
const totalWeight = moduleIds.reduce((sum, id) => {
|
||||
return sum + (moduleWeights[id] || 1);
|
||||
}, 0);
|
||||
|
||||
// Calculate weighted progress
|
||||
let overallProgress = moduleIds.reduce((sum, id) => {
|
||||
const module = modules[id];
|
||||
const weight = moduleWeights[id] || 1;
|
||||
return sum + (module.progress * weight / totalWeight);
|
||||
}, 0);
|
||||
|
||||
overallProgress = Math.min(Math.round(overallProgress), 100);
|
||||
|
||||
// Update progress bar
|
||||
if (progressIndicator) {
|
||||
progressIndicator.style.width = `${overallProgress}%`;
|
||||
}
|
||||
|
||||
if (progressText) {
|
||||
progressText.textContent = `${overallProgress}%`;
|
||||
}
|
||||
|
||||
// Update status text based on progress
|
||||
if (statusText) {
|
||||
if (overallProgress >= 100 && !isLoadingComplete) {
|
||||
statusText.textContent = 'Finalizing...';
|
||||
} else if (isLoadingComplete) {
|
||||
statusText.textContent = 'Complete!';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
ModuleState
|
||||
};
|
||||
})();
|
||||
|
||||
// Now that ModuleLoader is defined, add the event listener
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Start the loading process when the DOM is loaded
|
||||
ModuleLoader.init();
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Localization Module
|
||||
* Manages translations and locale settings for the application
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class LocalizationModule extends BaseModule {
|
||||
constructor() {
|
||||
super('localization', 'Localization');
|
||||
this.currentLocale = 'en-us'; // Default locale
|
||||
this.translations = {};
|
||||
this.observers = new Set(); // Modules that need to be notified of locale changes
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Load translations
|
||||
this.loadTranslations();
|
||||
|
||||
// Set global locale for SmartyPants
|
||||
window.locale = this.currentLocale;
|
||||
|
||||
this.reportProgress(100, "Localization module ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing localization module:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all translations
|
||||
*/
|
||||
loadTranslations() {
|
||||
// Add English translations (default)
|
||||
this.addTranslations('en-us', {
|
||||
// UI elements
|
||||
'by': 'powered by Generative AI',
|
||||
'title': 'AI Interactive Fiction',
|
||||
'subtitle': 'An open-world text adventure',
|
||||
'speech': 'speech',
|
||||
'speed': 'speed',
|
||||
'restart': 'restart',
|
||||
'save': 'save',
|
||||
'load': 'load',
|
||||
'prompt': 'What do you want to do next?',
|
||||
'remark': '<i><sup>*</sup>click on page or press spacebar to fast forward text animation</i>',
|
||||
|
||||
// Tooltips
|
||||
'title_speech': 'Toggle text to speech',
|
||||
'title_speech_unavailable': 'Text-to-Speech not available',
|
||||
'title_restart': 'Restart story from beginning',
|
||||
'title_save': 'Save progress',
|
||||
'title_load': 'Reload from save point',
|
||||
|
||||
// Confirm dialogs
|
||||
'confirm_restart': 'Are you sure you want to restart the game? All progress will be lost.'
|
||||
});
|
||||
|
||||
// Add German translations
|
||||
this.addTranslations('de', {
|
||||
'by': 'unterstützt durch KI',
|
||||
'title': 'KI Interaktive Fiktion',
|
||||
'subtitle': 'Ein Textabenteuer in offener Welt',
|
||||
'speech': 'Sprache',
|
||||
'speed': 'Tempo',
|
||||
'restart': 'Neustart',
|
||||
'save': 'Speichern',
|
||||
'load': 'Laden',
|
||||
'prompt': 'Was möchtest du als nächstes tun?',
|
||||
'remark': '<i><sup>*</sup>Klicke auf die Seite oder drücke die Leertaste, um die Textanimation zu beschleunigen</i>',
|
||||
|
||||
'title_speech': 'Text-zu-Sprache umschalten',
|
||||
'title_speech_unavailable': 'Text-zu-Sprache nicht verfügbar',
|
||||
'title_restart': 'Geschichte von Anfang an neu starten',
|
||||
'title_save': 'Fortschritt speichern',
|
||||
'title_load': 'Von Speicherpunkt neu laden',
|
||||
|
||||
'confirm_restart': 'Bist du sicher, dass du das Spiel neu starten möchtest? Der gesamte Fortschritt geht verloren.'
|
||||
});
|
||||
|
||||
// Add French translations
|
||||
this.addTranslations('fr', {
|
||||
'by': 'propulsé par l\'IA',
|
||||
'title': 'Fiction Interactive IA',
|
||||
'subtitle': 'Une aventure textuelle en monde ouvert',
|
||||
'speech': 'parole',
|
||||
'speed': 'vitesse',
|
||||
'restart': 'recommencer',
|
||||
'save': 'sauver',
|
||||
'load': 'charger',
|
||||
'prompt': 'Que voulez-vous faire ensuite?',
|
||||
'remark': '<i><sup>*</sup>cliquez sur la page ou appuyez sur la barre d\'espace pour accélérer l\'animation du texte</i>',
|
||||
|
||||
'title_speech': 'Activer/désactiver la synthèse vocale',
|
||||
'title_speech_unavailable': 'Synthèse vocale non disponible',
|
||||
'title_restart': 'Redémarrer l\'histoire depuis le début',
|
||||
'title_save': 'Sauvegarder la progression',
|
||||
'title_load': 'Recharger depuis le point de sauvegarde',
|
||||
|
||||
'confirm_restart': 'Êtes-vous sûr de vouloir redémarrer le jeu? Tous les progrès seront perdus.'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add translations for a specific locale
|
||||
* @param {string} locale - Locale code
|
||||
* @param {Object} translations - Translation key-value pairs
|
||||
*/
|
||||
addTranslations(locale, translations) {
|
||||
if (!this.translations[locale]) {
|
||||
this.translations[locale] = {};
|
||||
}
|
||||
|
||||
Object.assign(this.translations[locale], translations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation for a key in current locale
|
||||
* @param {string} key - Translation key
|
||||
* @param {string} [defaultValue] - Default value if translation not found
|
||||
* @returns {string} - Translated text or default value
|
||||
*/
|
||||
translate(key, defaultValue = null) {
|
||||
const localeTranslations = this.translations[this.currentLocale];
|
||||
|
||||
if (localeTranslations && localeTranslations[key] !== undefined) {
|
||||
return localeTranslations[key];
|
||||
}
|
||||
|
||||
// Fall back to English if translation not found
|
||||
if (this.currentLocale !== 'en-us' && this.translations['en-us'] && this.translations['en-us'][key]) {
|
||||
return this.translations['en-us'][key];
|
||||
}
|
||||
|
||||
// Return default value or key if no translation found
|
||||
return defaultValue || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current locale
|
||||
* @param {string} locale - Locale code
|
||||
*/
|
||||
setLocale(locale) {
|
||||
if (this.translations[locale]) {
|
||||
this.currentLocale = locale;
|
||||
|
||||
// Update global locale for SmartyPants
|
||||
window.locale = locale;
|
||||
|
||||
// Notify observers of locale change
|
||||
this.notifyObservers();
|
||||
|
||||
console.log(`Localization: Locale set to ${locale}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.warn(`Localization: Locale ${locale} not available`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current locale
|
||||
* @returns {string} - Current locale code
|
||||
*/
|
||||
getLocale() {
|
||||
return this.currentLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a module to be notified of locale changes
|
||||
* @param {Object} module - Module to register
|
||||
* @param {Function} updateMethod - Method to call on locale change
|
||||
*/
|
||||
registerObserver(module, updateMethod) {
|
||||
if (typeof updateMethod !== 'function') {
|
||||
console.error('Localization: Update method must be a function');
|
||||
return;
|
||||
}
|
||||
|
||||
this.observers.add({ module, updateMethod });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an observer module
|
||||
* @param {Object} module - Module to unregister
|
||||
*/
|
||||
unregisterObserver(module) {
|
||||
this.observers.forEach(observer => {
|
||||
if (observer.module === module) {
|
||||
this.observers.delete(observer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all observer modules of locale change
|
||||
*/
|
||||
notifyObservers() {
|
||||
this.observers.forEach(observer => {
|
||||
try {
|
||||
observer.updateMethod(this.currentLocale);
|
||||
} catch (error) {
|
||||
console.error(`Error notifying observer for locale change:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available locales
|
||||
* @returns {Array<string>} - Array of locale codes
|
||||
*/
|
||||
getAvailableLocales() {
|
||||
return Object.keys(this.translations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all translations for a specific locale
|
||||
* @param {string} locale - Locale code
|
||||
* @returns {Object} - Translations for the locale
|
||||
*/
|
||||
getTranslationsForLocale(locale) {
|
||||
return this.translations[locale] || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current locale's direction (ltr or rtl)
|
||||
* @returns {string} - Text direction ('ltr' or 'rtl')
|
||||
*/
|
||||
getTextDirection() {
|
||||
// List of RTL languages
|
||||
const rtlLocales = ['ar', 'he', 'fa', 'ur'];
|
||||
|
||||
// Check if current locale starts with any RTL language code
|
||||
for (const rtl of rtlLocales) {
|
||||
if (this.currentLocale.startsWith(rtl)) {
|
||||
return 'rtl';
|
||||
}
|
||||
}
|
||||
|
||||
return 'ltr';
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const Localization = new LocalizationModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(Localization);
|
||||
|
||||
// Export the module
|
||||
export { Localization };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.Localization = Localization;
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Module Registry
|
||||
* Manages module registration and dependency tracking
|
||||
*/
|
||||
export class ModuleRegistry {
|
||||
constructor() {
|
||||
this.modules = {};
|
||||
this.readyPromises = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a module
|
||||
* @param {BaseModule} module - Module to register
|
||||
*/
|
||||
register(module) {
|
||||
if (!module || !module.id) {
|
||||
console.error('Invalid module - must have an id property');
|
||||
return;
|
||||
}
|
||||
|
||||
this.modules[module.id] = module;
|
||||
|
||||
// Create a promise that will resolve when this module is ready
|
||||
this.readyPromises[module.id] = new Promise((resolve) => {
|
||||
// Set up a state change listener for this module
|
||||
document.addEventListener('module:stateChange', (event) => {
|
||||
if (event.detail.moduleId === module.id &&
|
||||
(event.detail.state === 'FINISHED' || event.detail.state === 'ERROR')) {
|
||||
resolve(event.detail.state === 'FINISHED');
|
||||
}
|
||||
});
|
||||
|
||||
// Check if already in finished state
|
||||
if (module.state === 'FINISHED') {
|
||||
resolve(true);
|
||||
} else if (module.state === 'ERROR') {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a module by id
|
||||
* @param {string} id - Module id
|
||||
* @returns {BaseModule} - The module, or null if not found
|
||||
*/
|
||||
getModule(id) {
|
||||
return this.modules[id] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered modules
|
||||
* @returns {Object} - Map of modules
|
||||
*/
|
||||
getAllModules() {
|
||||
return this.modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a module to be ready (in FINISHED state)
|
||||
* @param {string} id - Module id to wait for
|
||||
* @param {number} timeout - Optional timeout in ms
|
||||
* @returns {Promise} - Resolves when the module is ready
|
||||
*/
|
||||
waitForModule(id, timeout = null) {
|
||||
if (!this.readyPromises[id]) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
if (timeout) {
|
||||
// Add timeout logic
|
||||
return Promise.race([
|
||||
this.readyPromises[id],
|
||||
new Promise(resolve => setTimeout(() => resolve(false), timeout))
|
||||
]);
|
||||
}
|
||||
|
||||
return this.readyPromises[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for multiple modules to be ready
|
||||
* @param {Array<string>} ids - Array of module ids to wait for
|
||||
* @param {number} timeout - Optional timeout in ms
|
||||
* @returns {Promise} - Resolves when all modules are ready
|
||||
*/
|
||||
waitForModules(ids, timeout = null) {
|
||||
const promises = ids.map(id => this.waitForModule(id, timeout));
|
||||
return Promise.all(promises);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const moduleRegistry = new ModuleRegistry();
|
||||
@@ -0,0 +1,947 @@
|
||||
/**
|
||||
* Options UI Module for AI Interactive Fiction
|
||||
* Provides a user interface for adjusting game settings, TTS options, etc.
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class OptionsUIModule extends BaseModule {
|
||||
/**
|
||||
* Create new options UI
|
||||
*/
|
||||
constructor() {
|
||||
super('options-ui', 'Options UI');
|
||||
this.persistenceManager = null;
|
||||
this.ttsPlayer = null;
|
||||
this.audioManager = null;
|
||||
this.ttsFactory = null;
|
||||
this.modal = null;
|
||||
this.isOpen = false;
|
||||
|
||||
// Configuration
|
||||
this.config = {
|
||||
modalClass: 'options-modal',
|
||||
modalContentClass: 'options-content',
|
||||
backdrop: true
|
||||
};
|
||||
|
||||
// Bound event handlers for proper this context
|
||||
this.handleTtsSystemChanged = this.handleTtsSystemChanged.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Set up event listeners
|
||||
window.addEventListener('tts-system-changed', this.handleTtsSystemChanged);
|
||||
|
||||
// The option modal will be created on demand
|
||||
this.reportProgress(100, "Options UI ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing options UI:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle TTS system changes
|
||||
* @param {CustomEvent} event - The event containing TTS system change details
|
||||
*/
|
||||
handleTtsSystemChanged(event) {
|
||||
console.log("TTS system changed:", event.detail);
|
||||
|
||||
if (this.isOpen) {
|
||||
// Refresh the voices list if the options UI is currently open
|
||||
this.populateVoices();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for dependencies to be ready
|
||||
* @returns {Promise<boolean>} - Resolves when dependencies are ready
|
||||
*/
|
||||
async waitForDependencies() {
|
||||
try {
|
||||
// Wait for the persistence manager if available
|
||||
this.persistenceManager = moduleRegistry.getModule('persistence-manager');
|
||||
this.ttsPlayer = moduleRegistry.getModule('tts');
|
||||
|
||||
// These dependencies are optional - UI will adapt if not available
|
||||
this.audioManager = moduleRegistry.getModule('audio-manager');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error waiting for options UI dependencies:", error);
|
||||
return true; // Non-critical, can continue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the options UI elements
|
||||
*/
|
||||
createModal() {
|
||||
if (this.modal) return;
|
||||
|
||||
// Create modal container
|
||||
this.modal = document.createElement('div');
|
||||
this.modal.className = this.config.modalClass;
|
||||
this.modal.style.display = 'none';
|
||||
|
||||
// Create backdrop if enabled
|
||||
if (this.config.backdrop) {
|
||||
this.backdrop = document.createElement('div');
|
||||
this.backdrop.className = 'modal-backdrop';
|
||||
this.backdrop.addEventListener('click', () => this.hide());
|
||||
this.modal.appendChild(this.backdrop);
|
||||
}
|
||||
|
||||
// Create content container
|
||||
const content = document.createElement('div');
|
||||
content.className = this.config.modalContentClass;
|
||||
|
||||
// Add header with title and close button
|
||||
const header = document.createElement('div');
|
||||
header.className = 'options-header';
|
||||
|
||||
const title = document.createElement('h2');
|
||||
title.textContent = 'Options';
|
||||
header.appendChild(title);
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'close-button';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.setAttribute('aria-label', 'Close options');
|
||||
closeBtn.addEventListener('click', () => this.hide());
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
content.appendChild(header);
|
||||
|
||||
// Create tabs
|
||||
const tabContainer = document.createElement('div');
|
||||
tabContainer.className = 'tabs-container';
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'tabs';
|
||||
|
||||
const tabGeneral = document.createElement('button');
|
||||
tabGeneral.className = 'tab active';
|
||||
tabGeneral.textContent = 'General';
|
||||
tabGeneral.dataset.tab = 'general';
|
||||
|
||||
const tabVoice = document.createElement('button');
|
||||
tabVoice.className = 'tab';
|
||||
tabVoice.textContent = 'Voice';
|
||||
tabVoice.dataset.tab = 'voice';
|
||||
|
||||
const tabAudio = document.createElement('button');
|
||||
tabAudio.className = 'tab';
|
||||
tabAudio.textContent = 'Audio';
|
||||
tabAudio.dataset.tab = 'audio';
|
||||
|
||||
const tabAccess = document.createElement('button');
|
||||
tabAccess.className = 'tab';
|
||||
tabAccess.textContent = 'Accessibility';
|
||||
tabAccess.dataset.tab = 'accessibility';
|
||||
|
||||
tabs.appendChild(tabGeneral);
|
||||
tabs.appendChild(tabVoice);
|
||||
tabs.appendChild(tabAudio);
|
||||
tabs.appendChild(tabAccess);
|
||||
|
||||
tabContainer.appendChild(tabs);
|
||||
content.appendChild(tabContainer);
|
||||
|
||||
// Create tab content sections
|
||||
const tabContent = document.createElement('div');
|
||||
tabContent.className = 'tab-content';
|
||||
|
||||
// General tab content
|
||||
const generalContent = document.createElement('div');
|
||||
generalContent.className = 'tab-pane active';
|
||||
generalContent.dataset.tab = 'general';
|
||||
|
||||
const animSpeedSection = document.createElement('div');
|
||||
animSpeedSection.className = 'option-section';
|
||||
|
||||
const animSpeedLabel = document.createElement('label');
|
||||
animSpeedLabel.textContent = 'Animation Speed';
|
||||
animSpeedLabel.htmlFor = 'option-anim-speed';
|
||||
|
||||
const animSpeedSlider = document.createElement('input');
|
||||
animSpeedSlider.type = 'range';
|
||||
animSpeedSlider.id = 'option-anim-speed';
|
||||
animSpeedSlider.min = '0';
|
||||
animSpeedSlider.max = '100';
|
||||
animSpeedSlider.value = '50'; // Will be updated from preferences
|
||||
|
||||
const animSpeedValue = document.createElement('span');
|
||||
animSpeedValue.className = 'range-value';
|
||||
animSpeedValue.textContent = '50%';
|
||||
|
||||
animSpeedSlider.addEventListener('input', () => {
|
||||
const val = animSpeedSlider.value;
|
||||
animSpeedValue.textContent = `${val}%`;
|
||||
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('animation', 'speed', parseInt(val, 10));
|
||||
}
|
||||
|
||||
// Update animation queue speed if available
|
||||
const animQueue = moduleRegistry.getModule('animation-queue');
|
||||
if (animQueue) {
|
||||
const speed = Math.pow(100.0 - val, 3) / 10000 * 10 + 0.01;
|
||||
animQueue.setSpeed(speed);
|
||||
}
|
||||
});
|
||||
|
||||
animSpeedSection.appendChild(animSpeedLabel);
|
||||
animSpeedSection.appendChild(animSpeedSlider);
|
||||
animSpeedSection.appendChild(animSpeedValue);
|
||||
generalContent.appendChild(animSpeedSection);
|
||||
|
||||
// Voice tab content
|
||||
const voiceContent = document.createElement('div');
|
||||
voiceContent.className = 'tab-pane';
|
||||
voiceContent.dataset.tab = 'voice';
|
||||
|
||||
const ttsSysSection = document.createElement('div');
|
||||
ttsSysSection.className = 'option-section';
|
||||
|
||||
const ttsSysLabel = document.createElement('label');
|
||||
ttsSysLabel.textContent = 'TTS System';
|
||||
ttsSysLabel.htmlFor = 'option-tts-system';
|
||||
|
||||
const ttsSysSelect = document.createElement('select');
|
||||
ttsSysSelect.id = 'option-tts-system';
|
||||
|
||||
// Will populate systems dynamically later
|
||||
ttsSysSection.appendChild(ttsSysLabel);
|
||||
ttsSysSection.appendChild(ttsSysSelect);
|
||||
voiceContent.appendChild(ttsSysSection);
|
||||
|
||||
// Voice selection section
|
||||
const voiceSection = document.createElement('div');
|
||||
voiceSection.className = 'option-section';
|
||||
|
||||
const voiceLabel = document.createElement('label');
|
||||
voiceLabel.textContent = 'Voice';
|
||||
voiceLabel.htmlFor = 'option-voice';
|
||||
|
||||
const voiceSelect = document.createElement('select');
|
||||
voiceSelect.id = 'option-voice';
|
||||
|
||||
// Will populate voices dynamically later
|
||||
voiceSection.appendChild(voiceLabel);
|
||||
voiceSection.appendChild(voiceSelect);
|
||||
voiceContent.appendChild(voiceSection);
|
||||
|
||||
// Voice rate section
|
||||
const rateSection = document.createElement('div');
|
||||
rateSection.className = 'option-section';
|
||||
|
||||
const rateLabel = document.createElement('label');
|
||||
rateLabel.textContent = 'Speech Rate';
|
||||
rateLabel.htmlFor = 'option-speech-rate';
|
||||
|
||||
const rateSlider = document.createElement('input');
|
||||
rateSlider.type = 'range';
|
||||
rateSlider.id = 'option-speech-rate';
|
||||
rateSlider.min = '50';
|
||||
rateSlider.max = '200';
|
||||
rateSlider.value = '100'; // Will be updated from preferences
|
||||
|
||||
const rateValue = document.createElement('span');
|
||||
rateValue.className = 'range-value';
|
||||
rateValue.textContent = '1.0x';
|
||||
|
||||
rateSlider.addEventListener('input', () => {
|
||||
const val = rateSlider.value;
|
||||
const rate = val / 100;
|
||||
rateValue.textContent = `${rate.toFixed(1)}x`;
|
||||
|
||||
if (this.ttsPlayer) {
|
||||
this.ttsPlayer.setSpeed(rate);
|
||||
}
|
||||
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('tts', 'rate', rate);
|
||||
}
|
||||
});
|
||||
|
||||
rateSection.appendChild(rateLabel);
|
||||
rateSection.appendChild(rateSlider);
|
||||
rateSection.appendChild(rateValue);
|
||||
voiceContent.appendChild(rateSection);
|
||||
|
||||
// Audio tab content
|
||||
const audioContent = document.createElement('div');
|
||||
audioContent.className = 'tab-pane';
|
||||
audioContent.dataset.tab = 'audio';
|
||||
|
||||
// Master volume section
|
||||
const masterVolSection = document.createElement('div');
|
||||
masterVolSection.className = 'option-section';
|
||||
|
||||
const masterVolLabel = document.createElement('label');
|
||||
masterVolLabel.textContent = 'Master Volume';
|
||||
masterVolLabel.htmlFor = 'option-master-vol';
|
||||
|
||||
const masterVolSlider = document.createElement('input');
|
||||
masterVolSlider.type = 'range';
|
||||
masterVolSlider.id = 'option-master-vol';
|
||||
masterVolSlider.min = '0';
|
||||
masterVolSlider.max = '100';
|
||||
masterVolSlider.value = '100'; // Will be updated from preferences
|
||||
|
||||
const masterVolValue = document.createElement('span');
|
||||
masterVolValue.className = 'range-value';
|
||||
masterVolValue.textContent = '100%';
|
||||
|
||||
masterVolSlider.addEventListener('input', () => {
|
||||
const val = masterVolSlider.value;
|
||||
masterVolValue.textContent = `${val}%`;
|
||||
|
||||
if (this.audioManager) {
|
||||
this.audioManager.setMasterVolume(val / 100);
|
||||
}
|
||||
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('audio', 'masterVolume', val / 100);
|
||||
}
|
||||
});
|
||||
|
||||
masterVolSection.appendChild(masterVolLabel);
|
||||
masterVolSection.appendChild(masterVolSlider);
|
||||
masterVolSection.appendChild(masterVolValue);
|
||||
audioContent.appendChild(masterVolSection);
|
||||
|
||||
// TTS volume section
|
||||
const ttsVolSection = document.createElement('div');
|
||||
ttsVolSection.className = 'option-section';
|
||||
|
||||
const ttsVolLabel = document.createElement('label');
|
||||
ttsVolLabel.textContent = 'Speech Volume';
|
||||
ttsVolLabel.htmlFor = 'option-tts-vol';
|
||||
|
||||
const ttsVolSlider = document.createElement('input');
|
||||
ttsVolSlider.type = 'range';
|
||||
ttsVolSlider.id = 'option-tts-vol';
|
||||
ttsVolSlider.min = '0';
|
||||
ttsVolSlider.max = '100';
|
||||
ttsVolSlider.value = '100'; // Will be updated from preferences
|
||||
|
||||
const ttsVolValue = document.createElement('span');
|
||||
ttsVolValue.className = 'range-value';
|
||||
ttsVolValue.textContent = '100%';
|
||||
|
||||
ttsVolSlider.addEventListener('input', () => {
|
||||
const val = ttsVolSlider.value;
|
||||
ttsVolValue.textContent = `${val}%`;
|
||||
|
||||
if (this.ttsPlayer) {
|
||||
this.ttsPlayer.setVolume(val / 100);
|
||||
}
|
||||
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('tts', 'volume', val / 100);
|
||||
}
|
||||
});
|
||||
|
||||
ttsVolSection.appendChild(ttsVolLabel);
|
||||
ttsVolSection.appendChild(ttsVolSlider);
|
||||
ttsVolSection.appendChild(ttsVolValue);
|
||||
audioContent.appendChild(ttsVolSection);
|
||||
|
||||
// Music volume section (for future use)
|
||||
const musicVolSection = document.createElement('div');
|
||||
musicVolSection.className = 'option-section';
|
||||
|
||||
const musicVolLabel = document.createElement('label');
|
||||
musicVolLabel.textContent = 'Music Volume';
|
||||
musicVolLabel.htmlFor = 'option-music-vol';
|
||||
|
||||
const musicVolSlider = document.createElement('input');
|
||||
musicVolSlider.type = 'range';
|
||||
musicVolSlider.id = 'option-music-vol';
|
||||
musicVolSlider.min = '0';
|
||||
musicVolSlider.max = '100';
|
||||
musicVolSlider.value = '70'; // Will be updated from preferences
|
||||
|
||||
const musicVolValue = document.createElement('span');
|
||||
musicVolValue.className = 'range-value';
|
||||
musicVolValue.textContent = '70%';
|
||||
|
||||
musicVolSlider.addEventListener('input', () => {
|
||||
const val = musicVolSlider.value;
|
||||
musicVolValue.textContent = `${val}%`;
|
||||
|
||||
if (this.audioManager) {
|
||||
this.audioManager.setMusicVolume(val / 100);
|
||||
}
|
||||
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('audio', 'musicVolume', val / 100);
|
||||
}
|
||||
});
|
||||
|
||||
musicVolSection.appendChild(musicVolLabel);
|
||||
musicVolSection.appendChild(musicVolSlider);
|
||||
musicVolSection.appendChild(musicVolValue);
|
||||
audioContent.appendChild(musicVolSection);
|
||||
|
||||
// SFX volume section (for future use)
|
||||
const sfxVolSection = document.createElement('div');
|
||||
sfxVolSection.className = 'option-section';
|
||||
|
||||
const sfxVolLabel = document.createElement('label');
|
||||
sfxVolLabel.textContent = 'Effects Volume';
|
||||
sfxVolLabel.htmlFor = 'option-sfx-vol';
|
||||
|
||||
const sfxVolSlider = document.createElement('input');
|
||||
sfxVolSlider.type = 'range';
|
||||
sfxVolSlider.id = 'option-sfx-vol';
|
||||
sfxVolSlider.min = '0';
|
||||
sfxVolSlider.max = '100';
|
||||
sfxVolSlider.value = '100'; // Will be updated from preferences
|
||||
|
||||
const sfxVolValue = document.createElement('span');
|
||||
sfxVolValue.className = 'range-value';
|
||||
sfxVolValue.textContent = '100%';
|
||||
|
||||
sfxVolSlider.addEventListener('input', () => {
|
||||
const val = sfxVolSlider.value;
|
||||
sfxVolValue.textContent = `${val}%`;
|
||||
|
||||
if (this.audioManager) {
|
||||
this.audioManager.setSfxVolume(val / 100);
|
||||
}
|
||||
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('audio', 'sfxVolume', val / 100);
|
||||
}
|
||||
});
|
||||
|
||||
sfxVolSection.appendChild(sfxVolLabel);
|
||||
sfxVolSection.appendChild(sfxVolSlider);
|
||||
sfxVolSection.appendChild(sfxVolValue);
|
||||
audioContent.appendChild(sfxVolSection);
|
||||
|
||||
// Accessibility tab content
|
||||
const accessContent = document.createElement('div');
|
||||
accessContent.className = 'tab-pane';
|
||||
accessContent.dataset.tab = 'accessibility';
|
||||
|
||||
// High contrast toggle
|
||||
const contrastSection = document.createElement('div');
|
||||
contrastSection.className = 'option-section checkbox-section';
|
||||
|
||||
const contrastCheckbox = document.createElement('input');
|
||||
contrastCheckbox.type = 'checkbox';
|
||||
contrastCheckbox.id = 'option-high-contrast';
|
||||
|
||||
const contrastLabel = document.createElement('label');
|
||||
contrastLabel.textContent = 'High Contrast Mode';
|
||||
contrastLabel.htmlFor = 'option-high-contrast';
|
||||
|
||||
contrastCheckbox.addEventListener('change', () => {
|
||||
const isEnabled = contrastCheckbox.checked;
|
||||
|
||||
// Apply high contrast class to body
|
||||
if (isEnabled) {
|
||||
document.body.classList.add('high-contrast');
|
||||
} else {
|
||||
document.body.classList.remove('high-contrast');
|
||||
}
|
||||
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('accessibility', 'highContrast', isEnabled);
|
||||
}
|
||||
});
|
||||
|
||||
contrastSection.appendChild(contrastCheckbox);
|
||||
contrastSection.appendChild(contrastLabel);
|
||||
accessContent.appendChild(contrastSection);
|
||||
|
||||
// Larger text toggle
|
||||
const largerTextSection = document.createElement('div');
|
||||
largerTextSection.className = 'option-section checkbox-section';
|
||||
|
||||
const largerTextCheckbox = document.createElement('input');
|
||||
largerTextCheckbox.type = 'checkbox';
|
||||
largerTextCheckbox.id = 'option-larger-text';
|
||||
|
||||
const largerTextLabel = document.createElement('label');
|
||||
largerTextLabel.textContent = 'Larger Text';
|
||||
largerTextLabel.htmlFor = 'option-larger-text';
|
||||
|
||||
largerTextCheckbox.addEventListener('change', () => {
|
||||
const isEnabled = largerTextCheckbox.checked;
|
||||
|
||||
// Apply larger text class to body
|
||||
if (isEnabled) {
|
||||
document.body.classList.add('larger-text');
|
||||
} else {
|
||||
document.body.classList.remove('larger-text');
|
||||
}
|
||||
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('accessibility', 'largerText', isEnabled);
|
||||
}
|
||||
});
|
||||
|
||||
largerTextSection.appendChild(largerTextCheckbox);
|
||||
largerTextSection.appendChild(largerTextLabel);
|
||||
accessContent.appendChild(largerTextSection);
|
||||
|
||||
// Add tab content to container
|
||||
tabContent.appendChild(generalContent);
|
||||
tabContent.appendChild(voiceContent);
|
||||
tabContent.appendChild(audioContent);
|
||||
tabContent.appendChild(accessContent);
|
||||
|
||||
content.appendChild(tabContent);
|
||||
|
||||
// Add buttons at the bottom
|
||||
const buttons = document.createElement('div');
|
||||
buttons.className = 'options-buttons';
|
||||
|
||||
const resetButton = document.createElement('button');
|
||||
resetButton.textContent = 'Reset to Defaults';
|
||||
resetButton.className = 'reset-button';
|
||||
resetButton.addEventListener('click', () => this.resetToDefaults());
|
||||
|
||||
const saveButton = document.createElement('button');
|
||||
saveButton.textContent = 'Save & Close';
|
||||
saveButton.className = 'save-button';
|
||||
saveButton.addEventListener('click', () => this.saveAndClose());
|
||||
|
||||
buttons.appendChild(resetButton);
|
||||
buttons.appendChild(saveButton);
|
||||
content.appendChild(buttons);
|
||||
|
||||
// Set up tab switching
|
||||
tabs.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('tab')) {
|
||||
// Deactivate all tabs and tab panes
|
||||
Array.from(tabs.querySelectorAll('.tab')).forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
Array.from(tabContent.querySelectorAll('.tab-pane')).forEach(pane => {
|
||||
pane.classList.remove('active');
|
||||
});
|
||||
|
||||
// Activate clicked tab and corresponding pane
|
||||
e.target.classList.add('active');
|
||||
const tabName = e.target.dataset.tab;
|
||||
const pane = tabContent.querySelector(`.tab-pane[data-tab="${tabName}"]`);
|
||||
if (pane) {
|
||||
pane.classList.add('active');
|
||||
}
|
||||
|
||||
// If switching to voice tab, ensure voices are updated
|
||||
if (tabName === 'voice') {
|
||||
this.populateTtsSystems();
|
||||
this.populateVoices();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.modal.appendChild(content);
|
||||
document.body.appendChild(this.modal);
|
||||
|
||||
// Store references to UI elements for later use
|
||||
this.elements = {
|
||||
animSpeed: animSpeedSlider,
|
||||
animSpeedValue: animSpeedValue,
|
||||
ttsSystem: ttsSysSelect,
|
||||
voiceSelect: voiceSelect,
|
||||
speechRate: rateSlider,
|
||||
speechRateValue: rateValue,
|
||||
masterVolume: masterVolSlider,
|
||||
masterVolumeValue: masterVolValue,
|
||||
ttsVolume: ttsVolSlider,
|
||||
ttsVolumeValue: ttsVolValue,
|
||||
musicVolume: musicVolSlider,
|
||||
musicVolumeValue: musicVolValue,
|
||||
sfxVolume: sfxVolSlider,
|
||||
sfxVolumeValue: sfxVolValue,
|
||||
highContrast: contrastCheckbox,
|
||||
largerText: largerTextCheckbox
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the options UI
|
||||
*/
|
||||
show() {
|
||||
if (!this.modal) {
|
||||
this.createModal();
|
||||
}
|
||||
|
||||
// Load current preferences
|
||||
this.loadPreferences();
|
||||
|
||||
// Populate TTS systems and voices
|
||||
this.populateTtsSystems();
|
||||
this.populateVoices();
|
||||
|
||||
// Show the modal
|
||||
this.modal.style.display = 'flex';
|
||||
this.isOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the options UI
|
||||
*/
|
||||
hide() {
|
||||
if (this.modal) {
|
||||
this.modal.style.display = 'none';
|
||||
this.isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the options UI visibility
|
||||
*/
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current preferences into UI
|
||||
*/
|
||||
loadPreferences() {
|
||||
if (!this.persistenceManager || !this.elements) return;
|
||||
|
||||
const prefs = this.persistenceManager.getAllPreferences();
|
||||
|
||||
// Animation speed
|
||||
const animSpeed = this.persistenceManager.getPreference('animation', 'speed', 50);
|
||||
this.elements.animSpeed.value = animSpeed;
|
||||
this.elements.animSpeedValue.textContent = `${animSpeed}%`;
|
||||
|
||||
// TTS settings
|
||||
const ttsEnabled = this.persistenceManager.getPreference('tts', 'enabled', false);
|
||||
const ttsProvider = this.persistenceManager.getPreference('tts', 'provider', 'browser');
|
||||
const ttsVoice = this.persistenceManager.getPreference('tts', 'voice', '');
|
||||
const ttsVolume = this.persistenceManager.getPreference('tts', 'volume', 1.0);
|
||||
const ttsRate = this.persistenceManager.getPreference('tts', 'rate', 1.0);
|
||||
|
||||
// TTS rate slider
|
||||
this.elements.speechRate.value = Math.round(ttsRate * 100);
|
||||
this.elements.speechRateValue.textContent = `${ttsRate.toFixed(1)}x`;
|
||||
|
||||
// TTS volume slider
|
||||
this.elements.ttsVolume.value = Math.round(ttsVolume * 100);
|
||||
this.elements.ttsVolumeValue.textContent = `${Math.round(ttsVolume * 100)}%`;
|
||||
|
||||
// Audio volumes
|
||||
const masterVolume = this.persistenceManager.getPreference('audio', 'masterVolume', 1.0);
|
||||
const musicVolume = this.persistenceManager.getPreference('audio', 'musicVolume', 0.7);
|
||||
const sfxVolume = this.persistenceManager.getPreference('audio', 'sfxVolume', 1.0);
|
||||
|
||||
this.elements.masterVolume.value = Math.round(masterVolume * 100);
|
||||
this.elements.masterVolumeValue.textContent = `${Math.round(masterVolume * 100)}%`;
|
||||
|
||||
this.elements.musicVolume.value = Math.round(musicVolume * 100);
|
||||
this.elements.musicVolumeValue.textContent = `${Math.round(musicVolume * 100)}%`;
|
||||
|
||||
this.elements.sfxVolume.value = Math.round(sfxVolume * 100);
|
||||
this.elements.sfxVolumeValue.textContent = `${Math.round(sfxVolume * 100)}%`;
|
||||
|
||||
// Accessibility settings
|
||||
const highContrast = this.persistenceManager.getPreference('accessibility', 'highContrast', false);
|
||||
const largerText = this.persistenceManager.getPreference('accessibility', 'largerText', false);
|
||||
|
||||
this.elements.highContrast.checked = highContrast;
|
||||
this.elements.largerText.checked = largerText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate TTS systems dropdown
|
||||
*/
|
||||
populateTtsSystems() {
|
||||
if (!this.ttsPlayer || !this.elements) return;
|
||||
|
||||
const systems = this.ttsPlayer.getAvailableSystems();
|
||||
const select = this.elements.ttsSystem;
|
||||
|
||||
// Clear existing options and listeners
|
||||
select.innerHTML = '';
|
||||
const newSelect = select.cloneNode(false);
|
||||
select.parentNode.replaceChild(newSelect, select);
|
||||
this.elements.ttsSystem = newSelect;
|
||||
select = newSelect;
|
||||
|
||||
// Get current TTS info
|
||||
const currentInfo = this.ttsPlayer.getTTSInfo();
|
||||
const currentId = currentInfo.type || '';
|
||||
|
||||
// Create an option for each available system
|
||||
systems.forEach(id => {
|
||||
const option = document.createElement('option');
|
||||
option.value = id;
|
||||
|
||||
switch (id) {
|
||||
case 'browser':
|
||||
option.textContent = 'Browser Built-in TTS';
|
||||
break;
|
||||
case 'kokoro':
|
||||
option.textContent = 'Kokoro Neural TTS';
|
||||
break;
|
||||
case 'api':
|
||||
option.textContent = 'API-based TTS';
|
||||
break;
|
||||
default:
|
||||
option.textContent = id.charAt(0).toUpperCase() + id.slice(1);
|
||||
}
|
||||
|
||||
if (id === currentId) {
|
||||
option.selected = true;
|
||||
}
|
||||
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
// Add change listener
|
||||
select.addEventListener('change', () => {
|
||||
const selectedSystem = select.value;
|
||||
if (this.ttsPlayer) {
|
||||
this.ttsPlayer.switchTTS(selectedSystem);
|
||||
|
||||
// Update persistence
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('tts', 'provider', selectedSystem);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate voices dropdown for current TTS system
|
||||
*/
|
||||
async populateVoices() {
|
||||
if (!this.ttsPlayer || !this.elements || !this.ttsPlayer.getVoices) return;
|
||||
|
||||
try {
|
||||
const voices = await this.ttsPlayer.getVoices();
|
||||
const select = this.elements.voiceSelect;
|
||||
|
||||
// Clear existing options and listeners
|
||||
select.innerHTML = '';
|
||||
const newSelect = select.cloneNode(false);
|
||||
select.parentNode.replaceChild(newSelect, select);
|
||||
this.elements.voiceSelect = newSelect;
|
||||
select = newSelect;
|
||||
|
||||
if (!voices || voices.length === 0) {
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = 'No voices available';
|
||||
select.appendChild(option);
|
||||
select.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
select.disabled = false;
|
||||
|
||||
// Get current preference
|
||||
let currentVoice = '';
|
||||
if (this.persistenceManager) {
|
||||
currentVoice = this.persistenceManager.getPreference('tts', 'voice', '');
|
||||
}
|
||||
|
||||
// Add voices to dropdown
|
||||
voices.forEach(voice => {
|
||||
const option = document.createElement('option');
|
||||
option.value = voice.id || voice.name;
|
||||
option.textContent = voice.name;
|
||||
|
||||
if (voice.id === currentVoice || voice.name === currentVoice) {
|
||||
option.selected = true;
|
||||
}
|
||||
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
// Add change listener
|
||||
select.addEventListener('change', () => {
|
||||
const selectedVoice = select.value;
|
||||
|
||||
// Update TTS
|
||||
if (this.ttsPlayer) {
|
||||
this.ttsPlayer.setVoice(selectedVoice);
|
||||
}
|
||||
|
||||
// Update persistence
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('tts', 'voice', selectedVoice);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Voices populated for current TTS system. Selected: ${select.value}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error populating voices:", error);
|
||||
|
||||
const select = this.elements.voiceSelect;
|
||||
select.innerHTML = '';
|
||||
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = 'Error loading voices';
|
||||
select.appendChild(option);
|
||||
select.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all options to defaults
|
||||
*/
|
||||
resetToDefaults() {
|
||||
if (!this.persistenceManager) return;
|
||||
|
||||
const confirmed = confirm('Reset all options to default values?');
|
||||
if (confirmed) {
|
||||
// Reset preferences
|
||||
this.persistenceManager.resetPreferences();
|
||||
|
||||
// Update UI
|
||||
this.loadPreferences();
|
||||
|
||||
// Apply changes
|
||||
this.applySettings();
|
||||
|
||||
// Refresh voice list
|
||||
this.populateVoices();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings and close modal
|
||||
*/
|
||||
saveAndClose() {
|
||||
if (this.persistenceManager && this.elements) {
|
||||
// Save preferences - already saved as they change
|
||||
|
||||
// Apply settings
|
||||
this.applySettings();
|
||||
}
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply current settings to the app
|
||||
*/
|
||||
applySettings() {
|
||||
if (!this.persistenceManager) return;
|
||||
|
||||
// Apply animation speed
|
||||
const animSpeed = this.persistenceManager.getPreference('animation', 'speed', 50);
|
||||
const animQueue = moduleRegistry.getModule('animation-queue');
|
||||
if (animQueue) {
|
||||
const speed = Math.pow(100.0 - animSpeed, 3) / 10000 * 10 + 0.01;
|
||||
animQueue.setSpeed(speed);
|
||||
}
|
||||
|
||||
// Apply TTS settings
|
||||
const ttsEnabled = this.persistenceManager.getPreference('tts', 'enabled', false);
|
||||
const ttsProvider = this.persistenceManager.getPreference('tts', 'provider', 'browser');
|
||||
const ttsVoice = this.persistenceManager.getPreference('tts', 'voice', '');
|
||||
const ttsVolume = this.persistenceManager.getPreference('tts', 'volume', 1.0);
|
||||
const ttsRate = this.persistenceManager.getPreference('tts', 'rate', 1.0);
|
||||
|
||||
if (this.ttsPlayer) {
|
||||
// Set TTS system
|
||||
if (ttsProvider) {
|
||||
this.ttsPlayer.switchTTS(ttsProvider);
|
||||
}
|
||||
|
||||
// Apply voice options
|
||||
this.ttsPlayer.setVoiceOptions({
|
||||
voice: ttsVoice,
|
||||
volume: ttsVolume,
|
||||
rate: ttsRate
|
||||
});
|
||||
}
|
||||
|
||||
// Apply audio volume settings
|
||||
const masterVolume = this.persistenceManager.getPreference('audio', 'masterVolume', 1.0);
|
||||
const musicVolume = this.persistenceManager.getPreference('audio', 'musicVolume', 0.7);
|
||||
const sfxVolume = this.persistenceManager.getPreference('audio', 'sfxVolume', 1.0);
|
||||
|
||||
if (this.audioManager) {
|
||||
this.audioManager.setMasterVolume(masterVolume);
|
||||
this.audioManager.setMusicVolume(musicVolume);
|
||||
this.audioManager.setSfxVolume(sfxVolume);
|
||||
}
|
||||
|
||||
// Apply accessibility settings
|
||||
const highContrast = this.persistenceManager.getPreference('accessibility', 'highContrast', false);
|
||||
const largerText = this.persistenceManager.getPreference('accessibility', 'largerText', false);
|
||||
|
||||
if (highContrast) {
|
||||
document.body.classList.add('high-contrast');
|
||||
} else {
|
||||
document.body.classList.remove('high-contrast');
|
||||
}
|
||||
|
||||
if (largerText) {
|
||||
document.body.classList.add('larger-text');
|
||||
} else {
|
||||
document.body.classList.remove('larger-text');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the TTS factory reference
|
||||
* @param {Object} factory - The TTS factory instance
|
||||
*/
|
||||
setTtsFactory(factory) {
|
||||
this.ttsFactory = factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update available TTS systems info
|
||||
* @param {Object} systemsInfo - Information about available TTS systems
|
||||
*/
|
||||
updateAvailableSystems(systemsInfo) {
|
||||
// Will repopulate next time UI is opened
|
||||
console.log("TTS systems info updated:", systemsInfo);
|
||||
|
||||
// If the options UI is currently open, update it
|
||||
if (this.isOpen) {
|
||||
this.populateTtsSystems();
|
||||
this.populateVoices();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up when module is disposed
|
||||
*/
|
||||
dispose() {
|
||||
// Remove event listeners
|
||||
window.removeEventListener('tts-system-changed', this.handleTtsSystemChanged);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const OptionsUI = new OptionsUIModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(OptionsUI);
|
||||
|
||||
// Export the module
|
||||
export { OptionsUI };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.OptionsUI = OptionsUI;
|
||||
@@ -2,32 +2,90 @@
|
||||
* ParagraphLayout Module
|
||||
* Interfaces with the Knuth-Plass line breaking algorithm to calculate optimal line breaks.
|
||||
*/
|
||||
export class ParagraphLayout {
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class ParagraphLayoutModule extends BaseModule {
|
||||
/**
|
||||
* Create a new ParagraphLayout
|
||||
* @param {Function} kapAlgorithm - The Knuth and Plass algorithm function
|
||||
* @param {Function} measureTextFunc - Function to measure text width
|
||||
*/
|
||||
constructor(kapAlgorithm, measureTextFunc) {
|
||||
this.kapAlgorithm = kapAlgorithm;
|
||||
this.measureText = measureTextFunc;
|
||||
constructor() {
|
||||
super('paragraph-layout', 'Paragraph Layout');
|
||||
this.kapAlgorithm = null;
|
||||
this.measureText = null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load module dependencies
|
||||
* @returns {Promise} - Resolves when dependencies are loaded
|
||||
*/
|
||||
async loadDependencies() {
|
||||
try {
|
||||
// First load linebreak.js if needed
|
||||
if (!window.linebreak) {
|
||||
await this.loadScript('/js/linebreak.js');
|
||||
this.reportProgress(40, "Linebreak algorithm loaded");
|
||||
}
|
||||
|
||||
// Then load knuth-and-plass.js if needed
|
||||
if (!window.kap) {
|
||||
await this.loadScript('/js/knuth-and-plass.js');
|
||||
this.reportProgress(60, "KAP algorithm loaded");
|
||||
}
|
||||
|
||||
this.kapAlgorithm = window.kap;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error loading paragraph layout dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a script dynamically
|
||||
* @param {string} src - Script source URL
|
||||
* @returns {Promise} - Resolves when script is loaded
|
||||
*/
|
||||
loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// The measureText function will be provided by the game controller later
|
||||
this.reportProgress(100, "Paragraph layout initialized");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing paragraph layout:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate layout for a paragraph
|
||||
* @param {string} processedText - The pre-processed text (with SmartyPants and hyphenation)
|
||||
* @param {Array<number>} measures - Array of line width measurements
|
||||
* @param {boolean} debug - Whether to enable debug output
|
||||
* @param {boolean} hyphenate - Whether to enable hyphenation
|
||||
* @param {Function} [measureFunc] - Optional specific measurement function for this call
|
||||
* @returns {Object} Layout data with nodes and breaks
|
||||
*/
|
||||
calculateLayout(processedText, measures, hyphenate = false, measureFunc = null) {
|
||||
calculateLayout(processedText, measures, hyphenate = true, measureFunc = null) {
|
||||
const measure = measureFunc || this.measureText; // Use provided func or fallback to instance default
|
||||
if (typeof measure !== 'function') {
|
||||
console.error("ParagraphLayout: Invalid measure function provided or stored.");
|
||||
// Return a dummy layout or throw an error?
|
||||
return { nodes: [], breaks: [] }; // Return empty layout
|
||||
throw new Error('No text measurement function available');
|
||||
}
|
||||
|
||||
return this.kapAlgorithm(processedText, measure, measures, hyphenate);
|
||||
}
|
||||
|
||||
@@ -47,3 +105,15 @@ export class ParagraphLayout {
|
||||
this.kapAlgorithm = kapFunc;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const ParagraphLayout = new ParagraphLayoutModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(ParagraphLayout);
|
||||
|
||||
// Export the module
|
||||
export { ParagraphLayout };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.ParagraphLayout = ParagraphLayout;
|
||||
|
||||
@@ -1,123 +1,364 @@
|
||||
/**
|
||||
* PersistenceManager Module
|
||||
* Handles saving and loading the game state.
|
||||
* Persistence Manager Module
|
||||
* Handles saving and loading game state and user preferences
|
||||
*/
|
||||
export class PersistenceManager {
|
||||
/**
|
||||
* Create a new PersistenceManager
|
||||
* @param {Object} config - Configuration options
|
||||
* @param {Storage} config.storage - The storage backend (e.g., localStorage)
|
||||
* @param {string} config.saveStateKey - Key for saving the state
|
||||
* @param {string} config.saveHistoryKey - Key for saving the history
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
this.storage = config.storage || window.localStorage;
|
||||
this.saveStateKey = config.saveStateKey || 'save-state';
|
||||
this.saveHistoryKey = config.saveHistoryKey || 'save-history';
|
||||
}
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class PersistenceManagerModule extends BaseModule {
|
||||
/**
|
||||
* Save the current state
|
||||
* @param {Object} stateObject - The state object to save
|
||||
* @param {string} stateObject.inkJson - The serialized Ink state
|
||||
* @param {Array<string>} stateObject.history - Array of HTML strings representing the story history
|
||||
* @returns {boolean} Whether the save was successful
|
||||
* Create a new persistence manager
|
||||
*/
|
||||
saveState(stateObject) {
|
||||
constructor() {
|
||||
super('persistence-manager', 'Persistence Manager');
|
||||
this.storage = window.localStorage;
|
||||
this.stateKey = 'ai_fiction_state';
|
||||
this.prefsKey = 'ai_fiction_prefs';
|
||||
|
||||
// Default preferences
|
||||
this.defaultPreferences = {
|
||||
tts: {
|
||||
enabled: false,
|
||||
provider: 'browser', // 'browser', 'kokoro', 'elevenlabs'
|
||||
voice: '',
|
||||
volume: 1.0
|
||||
},
|
||||
audio: {
|
||||
masterVolume: 1.0,
|
||||
musicVolume: 0.7,
|
||||
sfxVolume: 1.0
|
||||
},
|
||||
animation: {
|
||||
speed: 50, // 0-100 scale
|
||||
fastForwardKey: ' ' // Space key
|
||||
},
|
||||
accessibility: {
|
||||
highContrast: false,
|
||||
largerText: false
|
||||
}
|
||||
};
|
||||
|
||||
// Current preferences (will be loaded from storage)
|
||||
this.preferences = { ...this.defaultPreferences };
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
if (stateObject.inkJson) {
|
||||
this.storage.setItem(this.saveStateKey, stateObject.inkJson);
|
||||
}
|
||||
// Test storage availability
|
||||
this.storage = this.getStorageObject();
|
||||
|
||||
if (stateObject.history) {
|
||||
this.storage.setItem(this.saveHistoryKey, JSON.stringify(stateObject.history));
|
||||
}
|
||||
// Load preferences automatically
|
||||
this.loadPreferences();
|
||||
|
||||
this.reportProgress(100, "Persistence manager ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving state:', error);
|
||||
console.error("Error initializing persistence manager:", error);
|
||||
// Continue without persistence rather than failing
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate storage object, testing availability
|
||||
* @returns {Storage} - The storage object to use
|
||||
*/
|
||||
getStorageObject() {
|
||||
try {
|
||||
// Test if localStorage is available
|
||||
if (window.localStorage) {
|
||||
const testKey = '__storage_test__';
|
||||
window.localStorage.setItem(testKey, testKey);
|
||||
window.localStorage.removeItem(testKey);
|
||||
return window.localStorage;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('localStorage not available, using memory storage');
|
||||
// Create a memory-based storage fallback
|
||||
return this.createMemoryStorage();
|
||||
}
|
||||
|
||||
console.warn('localStorage not available, using memory storage');
|
||||
return this.createMemoryStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a memory-based storage fallback
|
||||
* @returns {Object} - A storage-like object
|
||||
*/
|
||||
createMemoryStorage() {
|
||||
const memoryStore = {};
|
||||
|
||||
return {
|
||||
getItem: (key) => memoryStore[key] || null,
|
||||
setItem: (key, value) => {
|
||||
memoryStore[key] = String(value);
|
||||
},
|
||||
removeItem: (key) => {
|
||||
delete memoryStore[key];
|
||||
},
|
||||
clear: () => {
|
||||
Object.keys(memoryStore).forEach(key => {
|
||||
delete memoryStore[key];
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current game state
|
||||
* @param {Object} state - The game state to save
|
||||
*/
|
||||
saveState(state) {
|
||||
if (!this.storage) {
|
||||
console.warn('No storage available, game state not saved.');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stateString = JSON.stringify(state);
|
||||
this.storage.setItem(this.stateKey, stateString);
|
||||
console.log('Game state saved successfully.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving game state:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load the saved state
|
||||
* @returns {Object|null} The loaded state object or null if no save exists
|
||||
* Load the saved game state
|
||||
* @returns {Object|null} The loaded state or null if no state exists
|
||||
*/
|
||||
loadState() {
|
||||
try {
|
||||
const inkJson = this.storage.getItem(this.saveStateKey);
|
||||
const historyJson = this.storage.getItem(this.saveHistoryKey);
|
||||
|
||||
if (!inkJson && !historyJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = {};
|
||||
|
||||
if (inkJson) {
|
||||
result.inkJson = inkJson;
|
||||
}
|
||||
|
||||
if (historyJson) {
|
||||
result.history = JSON.parse(historyJson);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error loading state:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a saved state exists
|
||||
* @returns {boolean} Whether a saved state exists
|
||||
*/
|
||||
hasSavedState() {
|
||||
return this.storage.getItem(this.saveStateKey) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the saved state
|
||||
* @returns {boolean} Whether the deletion was successful
|
||||
*/
|
||||
deleteSavedState() {
|
||||
try {
|
||||
this.storage.removeItem(this.saveStateKey);
|
||||
this.storage.removeItem(this.saveHistoryKey);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting saved state:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the saved state as a JSON string
|
||||
* @returns {string|null} The exported state as a JSON string or null if no save exists
|
||||
*/
|
||||
exportState() {
|
||||
const state = this.loadState();
|
||||
if (!state) {
|
||||
if (!this.storage) {
|
||||
console.warn('No storage available, cannot load game state.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a state from a JSON string
|
||||
* @param {string} jsonString - The JSON string to import
|
||||
* @returns {boolean} Whether the import was successful
|
||||
*/
|
||||
importState(jsonString) {
|
||||
try {
|
||||
const state = JSON.parse(jsonString);
|
||||
return this.saveState(state);
|
||||
const stateString = this.storage.getItem(this.stateKey);
|
||||
if (!stateString) {
|
||||
console.info('No saved game state found.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = JSON.parse(stateString);
|
||||
console.log('Game state loaded successfully.');
|
||||
return state;
|
||||
} catch (error) {
|
||||
console.error('Error importing state:', error);
|
||||
console.error('Error loading game state:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a saved game state exists
|
||||
* @returns {boolean} Whether a saved state exists
|
||||
*/
|
||||
hasSavedState() {
|
||||
if (!this.storage) return false;
|
||||
return !!this.storage.getItem(this.stateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the saved game state
|
||||
* @returns {boolean} Whether the state was successfully deleted
|
||||
*/
|
||||
clearState() {
|
||||
if (!this.storage) return false;
|
||||
try {
|
||||
this.storage.removeItem(this.stateKey);
|
||||
console.log('Game state cleared.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error clearing game state:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user preferences
|
||||
* @param {Object} [preferences] - Preferences to save (defaults to current preferences)
|
||||
* @returns {boolean} Whether preferences were successfully saved
|
||||
*/
|
||||
savePreferences(preferences = null) {
|
||||
if (!this.storage) {
|
||||
console.warn('No storage available, preferences not saved.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use provided preferences or current preferences
|
||||
const prefsToSave = preferences || this.preferences;
|
||||
|
||||
try {
|
||||
const prefsString = JSON.stringify(prefsToSave);
|
||||
this.storage.setItem(this.prefsKey, prefsString);
|
||||
console.log('Preferences saved successfully.');
|
||||
|
||||
// Update current preferences
|
||||
if (preferences) {
|
||||
this.preferences = { ...this.preferences, ...preferences };
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving preferences:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user preferences
|
||||
* @returns {Object} The loaded preferences or default preferences if none exist
|
||||
*/
|
||||
loadPreferences() {
|
||||
if (!this.storage) {
|
||||
console.warn('No storage available, using default preferences.');
|
||||
return { ...this.defaultPreferences };
|
||||
}
|
||||
|
||||
try {
|
||||
const prefsString = this.storage.getItem(this.prefsKey);
|
||||
if (!prefsString) {
|
||||
console.info('No saved preferences found, using defaults.');
|
||||
this.preferences = { ...this.defaultPreferences };
|
||||
return this.preferences;
|
||||
}
|
||||
|
||||
const loadedPrefs = JSON.parse(prefsString);
|
||||
|
||||
// Merge with default preferences to ensure all fields exist
|
||||
this.preferences = this.mergeWithDefaults(loadedPrefs, this.defaultPreferences);
|
||||
|
||||
console.log('Preferences loaded successfully.');
|
||||
return this.preferences;
|
||||
} catch (error) {
|
||||
console.error('Error loading preferences:', error);
|
||||
this.preferences = { ...this.defaultPreferences };
|
||||
return this.preferences;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge loaded preferences with default values to ensure all fields exist
|
||||
* @param {Object} loaded - The loaded preferences
|
||||
* @param {Object} defaults - The default preferences
|
||||
* @returns {Object} Merged preferences
|
||||
* @private
|
||||
*/
|
||||
mergeWithDefaults(loaded, defaults) {
|
||||
const result = {};
|
||||
|
||||
// Start with defaults
|
||||
for (const key in defaults) {
|
||||
if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) {
|
||||
// Recurse for nested objects
|
||||
if (loaded && loaded[key]) {
|
||||
result[key] = this.mergeWithDefaults(loaded[key], defaults[key]);
|
||||
} else {
|
||||
result[key] = { ...defaults[key] };
|
||||
}
|
||||
} else {
|
||||
// Use loaded value if available, otherwise default
|
||||
result[key] = (loaded && loaded[key] !== undefined) ? loaded[key] : defaults[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific preferences
|
||||
* @param {string} category - The preference category (e.g., 'tts', 'audio')
|
||||
* @param {string} setting - The specific setting name
|
||||
* @param {any} value - The new value
|
||||
* @param {boolean} [saveImmediately=true] - Whether to save immediately
|
||||
*/
|
||||
updatePreference(category, setting, value, saveImmediately = true) {
|
||||
// Ensure the category exists
|
||||
if (!this.preferences[category]) {
|
||||
console.warn(`Preference category '${category}' doesn't exist.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the preference
|
||||
this.preferences[category][setting] = value;
|
||||
|
||||
// Save if requested
|
||||
if (saveImmediately) {
|
||||
return this.savePreferences();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific preference value
|
||||
* @param {string} category - The preference category
|
||||
* @param {string} setting - The specific setting name
|
||||
* @param {any} [defaultValue] - Default value if the preference doesn't exist
|
||||
* @returns {any} The preference value
|
||||
*/
|
||||
getPreference(category, setting, defaultValue = null) {
|
||||
// Check if category exists
|
||||
if (!this.preferences[category]) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Check if setting exists in category
|
||||
if (this.preferences[category].hasOwnProperty(setting)) {
|
||||
return this.preferences[category][setting];
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset preferences to defaults
|
||||
* @param {string} [category] - Optional category to reset (resets all if not specified)
|
||||
* @param {boolean} [saveImmediately=true] - Whether to save immediately
|
||||
*/
|
||||
resetPreferences(category = null, saveImmediately = true) {
|
||||
if (category) {
|
||||
// Reset only specified category
|
||||
if (this.defaultPreferences[category]) {
|
||||
this.preferences[category] = { ...this.defaultPreferences[category] };
|
||||
}
|
||||
} else {
|
||||
// Reset all preferences
|
||||
this.preferences = { ...this.defaultPreferences };
|
||||
}
|
||||
|
||||
// Save if requested
|
||||
if (saveImmediately) {
|
||||
return this.savePreferences();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all preferences
|
||||
* @returns {Object} The current preferences
|
||||
*/
|
||||
getAllPreferences() {
|
||||
return { ...this.preferences };
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const PersistenceManager = new PersistenceManagerModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(PersistenceManager);
|
||||
|
||||
// Export the module
|
||||
export { PersistenceManager };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.PersistenceManager = PersistenceManager;
|
||||
|
||||
+423
-160
@@ -1,176 +1,439 @@
|
||||
/**
|
||||
* Socket Client Module
|
||||
* Manages WebSocket communication with the game server.
|
||||
* Handles WebSocket communication for receiving text fragments and game state
|
||||
*/
|
||||
export class SocketClient {
|
||||
constructor(serverUrl) {
|
||||
this.socket = null;
|
||||
this.serverUrl = serverUrl || window.location.origin; // Default to current origin
|
||||
this.eventListeners = {
|
||||
connect: [],
|
||||
disconnect: [],
|
||||
connect_error: [],
|
||||
gameIntroduction: [],
|
||||
narrativeResponse: [],
|
||||
gameSaved: [],
|
||||
gameLoaded: [],
|
||||
error: [],
|
||||
};
|
||||
}
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
/**
|
||||
* Connects to the WebSocket server.
|
||||
*/
|
||||
connect() {
|
||||
if (this.socket && this.socket.connected) {
|
||||
console.log('SocketClient: Already connected.');
|
||||
return;
|
||||
class SocketClientModule extends BaseModule {
|
||||
constructor() {
|
||||
super('socket-client', 'Socket Client');
|
||||
this.socket = null;
|
||||
this.textBuffer = null;
|
||||
this.isConnected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
this.reconnectDelay = 2000; // 2 seconds
|
||||
this.url = null;
|
||||
this.eventListeners = {};
|
||||
this.defaultHost = 'localhost:3000'; // Default to localhost:3000 if not running in same origin
|
||||
}
|
||||
|
||||
console.log(`SocketClient: Connecting to ${this.serverUrl}...`);
|
||||
// Ensure io is available (it should be loaded globally)
|
||||
if (typeof io === 'undefined') {
|
||||
console.error('Socket.IO client library (io) not found. Make sure it is loaded.');
|
||||
this.triggerEvent('error', { message: 'Socket.IO library not loaded.' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket = io(this.serverUrl, {
|
||||
reconnectionAttempts: 5,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
this.initializeSocketEventHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects from the server.
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
console.log('SocketClient: Disconnecting...');
|
||||
this.socket.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the client is currently connected.
|
||||
* @returns {boolean} True if connected, false otherwise.
|
||||
*/
|
||||
isConnected() {
|
||||
return this.socket && this.socket.connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the listeners for standard socket events.
|
||||
*/
|
||||
initializeSocketEventHandlers() {
|
||||
if (!this.socket) return;
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('SocketClient: Connected to server.');
|
||||
this.triggerEvent('connect');
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log(`SocketClient: Disconnected from server. Reason: ${reason}`);
|
||||
this.triggerEvent('disconnect', reason);
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('SocketClient: Connection error:', error);
|
||||
this.triggerEvent('connect_error', error);
|
||||
});
|
||||
|
||||
// --- Game-specific events ---
|
||||
|
||||
this.socket.on('gameIntroduction', (data) => {
|
||||
console.log('SocketClient: Received gameIntroduction');
|
||||
this.triggerEvent('gameIntroduction', data);
|
||||
});
|
||||
|
||||
this.socket.on('narrativeResponse', (data) => {
|
||||
console.log('SocketClient: Received narrativeResponse');
|
||||
this.triggerEvent('narrativeResponse', data);
|
||||
});
|
||||
|
||||
this.socket.on('gameSaved', (data) => {
|
||||
console.log('SocketClient: Received gameSaved confirmation');
|
||||
this.triggerEvent('gameSaved', data); // Pass data if any
|
||||
});
|
||||
|
||||
this.socket.on('gameLoaded', (data) => {
|
||||
console.log('SocketClient: Received gameLoaded confirmation');
|
||||
this.triggerEvent('gameLoaded', data);
|
||||
});
|
||||
|
||||
this.socket.on('error', (data) => {
|
||||
console.error('SocketClient: Received error from server:', data);
|
||||
this.triggerEvent('error', data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a listener for a specific event.
|
||||
* @param {string} eventName - The name of the event.
|
||||
* @param {function} callback - The function to call when the event occurs.
|
||||
*/
|
||||
on(eventName, callback) {
|
||||
if (this.eventListeners[eventName]) {
|
||||
this.eventListeners[eventName].push(callback);
|
||||
} else {
|
||||
console.warn(`SocketClient: Attempted to register listener for unknown event "${eventName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a specific event, calling all registered listeners.
|
||||
* @param {string} eventName - The name of the event.
|
||||
* @param {*} data - Data to pass to the listeners.
|
||||
*/
|
||||
triggerEvent(eventName, data) {
|
||||
if (this.eventListeners[eventName]) {
|
||||
this.eventListeners[eventName].forEach(callback => {
|
||||
|
||||
/**
|
||||
* Load module dependencies
|
||||
* @returns {Promise} - Resolves when dependencies are loaded
|
||||
*/
|
||||
async loadDependencies() {
|
||||
try {
|
||||
callback(data);
|
||||
// We depend on the text-buffer module
|
||||
this.reportProgress(30, "Waiting for text buffer");
|
||||
|
||||
// Dynamically load Socket.IO client if not already loaded
|
||||
if (!window.io) {
|
||||
this.reportProgress(40, "Loading Socket.IO client");
|
||||
await this.loadSocketIO();
|
||||
this.reportProgress(45, "Socket.IO client loaded");
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`SocketClient: Error in event listener for "${eventName}":`, error);
|
||||
console.error("Error loading Socket Client dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an event to the server.
|
||||
* @param {string} eventName - The name of the event to emit.
|
||||
* @param {object} data - The data to send with the event.
|
||||
*/
|
||||
emit(eventName, data) {
|
||||
if (this.socket && this.socket.connected) {
|
||||
console.log(`SocketClient: Emitting "${eventName}"`, data || '');
|
||||
this.socket.emit(eventName, data);
|
||||
} else {
|
||||
console.error(`SocketClient: Cannot emit "${eventName}", not connected.`);
|
||||
// Optionally trigger an error event or queue the message
|
||||
this.triggerEvent('error', { message: `Cannot send command "${eventName}", not connected.` });
|
||||
|
||||
/**
|
||||
* Load Socket.IO client library
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
loadSocketIO() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if Socket.IO is already loaded
|
||||
if (typeof window.io !== 'undefined') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the Socket.IO client from the same server that served this page
|
||||
const script = document.createElement('script');
|
||||
script.src = '/socket.io/socket.io.js'; // Socket.IO automatically serves this
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
if (typeof window.io !== 'undefined') {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('Failed to load Socket.IO client'));
|
||||
}
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
reject(new Error('Failed to load Socket.IO client script'));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for dependencies to be ready
|
||||
*/
|
||||
async waitForDependencies() {
|
||||
try {
|
||||
// Wait for the text buffer module to be available
|
||||
const textBufferReady = await moduleRegistry.waitForModule('text-buffer', 10000);
|
||||
|
||||
if (textBufferReady) {
|
||||
this.textBuffer = window.TextBuffer;
|
||||
this.reportProgress(60, "Text buffer module ready");
|
||||
return true;
|
||||
} else {
|
||||
console.warn("Text buffer module not ready, Socket Client will have limited functionality");
|
||||
return true; // Continue anyway for graceful degradation
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error waiting for dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Use the current origin for the socket connection
|
||||
// This automatically handles the Docker port mapping situation
|
||||
const currentUrl = window.location.origin;
|
||||
|
||||
console.log(`Socket Client: Using origin for connection: ${currentUrl}`);
|
||||
this.url = currentUrl;
|
||||
|
||||
this.reportProgress(100, "Socket client initialized");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing Socket Client:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the Socket.IO server
|
||||
* @param {string} url - Optional custom WebSocket URL
|
||||
* @returns {Promise<boolean>} - Resolves with connection success
|
||||
*/
|
||||
connect(url = null) {
|
||||
return new Promise((resolve) => {
|
||||
if (this.isConnected) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use provided URL or default
|
||||
const socketUrl = url || this.url;
|
||||
|
||||
try {
|
||||
console.log(`Socket Client: Connecting to ${socketUrl}`);
|
||||
|
||||
// Create Socket.IO connection (will automatically use /socket.io endpoint)
|
||||
this.socket = window.io(socketUrl, {
|
||||
reconnection: false, // We handle reconnection ourselves
|
||||
transports: ['websocket', 'polling'] // Prefer WebSocket
|
||||
});
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Socket Client: Connected to server with ID:', this.socket.id);
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.emitEvent('connect');
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log(`Socket Client: Connection closed: ${reason}`);
|
||||
this.isConnected = false;
|
||||
this.emitEvent('disconnect', reason);
|
||||
this.attemptReconnect();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('Socket Client: Connection error:', error);
|
||||
this.emitEvent('connect_error', error);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
// Set up game-specific event handlers
|
||||
this.setupGameEventHandlers();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Connection error:', error);
|
||||
this.emitEvent('connect_error', error);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event handlers for game-specific Socket.IO events
|
||||
*/
|
||||
setupGameEventHandlers() {
|
||||
if (!this.socket) return;
|
||||
|
||||
// Map all incoming Socket.IO events to our internal event system
|
||||
this.socket.onAny((event, ...args) => {
|
||||
console.log(`Socket Client: Received ${event} event from server`, args);
|
||||
this.emitEvent(event, args[0]);
|
||||
});
|
||||
|
||||
// Special handling for narrative text
|
||||
this.socket.on('narrativeResponse', (data) => {
|
||||
if (data && data.text && this.textBuffer) {
|
||||
this.processTextFragment(data.text);
|
||||
}
|
||||
});
|
||||
|
||||
// Special handling for introduction text
|
||||
this.socket.on('gameIntroduction', (data) => {
|
||||
if (data && data.introduction && this.textBuffer) {
|
||||
this.processTextFragment(data.introduction);
|
||||
}
|
||||
|
||||
if (data && data.initialRoomDescription && this.textBuffer) {
|
||||
this.processTextFragment(data.initialRoomDescription);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a text fragment by adding it to the TextBuffer
|
||||
* @param {string} text - Text fragment to process
|
||||
*/
|
||||
processTextFragment(text) {
|
||||
if (!text) return;
|
||||
|
||||
// Add text to the buffer if available
|
||||
if (this.textBuffer) {
|
||||
console.log(`Socket Client: Processing text fragment: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
this.textBuffer.addText(text);
|
||||
} else {
|
||||
console.error('Socket Client: Text buffer not available');
|
||||
// Attempt to get text buffer again
|
||||
this.textBuffer = moduleRegistry.getModule('text-buffer');
|
||||
if (this.textBuffer) {
|
||||
this.textBuffer.addText(text);
|
||||
} else {
|
||||
// Emit a text event as fallback if no text buffer
|
||||
this.emitEvent('text', text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reconnect to the server
|
||||
*/
|
||||
attemptReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('Socket Client: Max reconnect attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectDelay * this.reconnectAttempts;
|
||||
|
||||
console.log(`Socket Client: Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.isConnected) {
|
||||
this.connect();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the server
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.socket && this.isConnected) {
|
||||
this.socket.disconnect();
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the server
|
||||
* @param {Object|string} data - Data to send
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
send(data) {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
console.error('Socket Client: Not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// For Socket.IO we send structured events
|
||||
if (typeof data === 'object') {
|
||||
const { type, ...restData } = data;
|
||||
if (type) {
|
||||
// Use the type as the event name
|
||||
this.socket.emit(type, restData);
|
||||
} else {
|
||||
// Default to 'message' event
|
||||
this.socket.emit('message', data);
|
||||
}
|
||||
} else {
|
||||
// Plain strings go to 'message' event
|
||||
this.socket.emit('message', { text: data });
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Error sending message:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Convenience methods for game actions ---
|
||||
/**
|
||||
* Send a command to the server
|
||||
* @param {string} command - The player's command
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
sendCommand(command) {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
console.error('Socket Client: Not connected, cannot send command');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit('playerCommand', { command });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Error sending command:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
requestStartGame() {
|
||||
this.emit('startGame');
|
||||
}
|
||||
/**
|
||||
* Request to start a new game
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
requestStartGame() {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
console.error('Socket Client: Not connected, cannot start game');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit('startGame');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Error starting game:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
sendCommand(command) {
|
||||
this.emit('playerCommand', { command });
|
||||
}
|
||||
/**
|
||||
* Request to save the current game state
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
requestSaveGame() {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
console.error('Socket Client: Not connected, cannot save game');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit('saveGame');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Error saving game:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
requestSaveGame() {
|
||||
this.emit('saveGame');
|
||||
}
|
||||
/**
|
||||
* Request to load a saved game state
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
requestLoadGame() {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
console.error('Socket Client: Not connected, cannot load game');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit('loadGame');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Error loading game:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
requestLoadGame() {
|
||||
this.emit('loadGame');
|
||||
}
|
||||
/**
|
||||
* Register an event handler
|
||||
* @param {string} event - Event name
|
||||
* @param {Function} callback - Event callback
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this.eventListeners[event]) {
|
||||
this.eventListeners[event] = [];
|
||||
}
|
||||
this.eventListeners[event].push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an event handler
|
||||
* @param {string} event - Event name
|
||||
* @param {Function} callback - Event callback to remove
|
||||
*/
|
||||
off(event, callback) {
|
||||
if (!this.eventListeners[event]) return;
|
||||
|
||||
if (callback) {
|
||||
// Remove specific callback
|
||||
this.eventListeners[event] = this.eventListeners[event].filter(cb => cb !== callback);
|
||||
} else {
|
||||
// Remove all callbacks for this event
|
||||
delete this.eventListeners[event];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all registered listeners
|
||||
* @param {string} event - Event name
|
||||
* @param {*} data - Event data
|
||||
*/
|
||||
emitEvent(event, data) {
|
||||
if (!this.eventListeners[event]) return;
|
||||
|
||||
for (const callback of this.eventListeners[event]) {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error(`Socket Client: Error in '${event}' event handler:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the socket is connected
|
||||
* @returns {boolean} - Connection status
|
||||
*/
|
||||
getConnectionStatus() {
|
||||
return this.isConnected;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const SocketClient = new SocketClientModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(SocketClient);
|
||||
|
||||
// Export the module
|
||||
export { SocketClient };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.SocketClient = SocketClient;
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const player = require('play-sound')(opts = {});
|
||||
const { ipcMain } = require('electron');
|
||||
|
||||
// Directory where audio files will be cached
|
||||
const cacheDirectory = './speech_cache/';
|
||||
|
||||
// Create cache directory if it does not exist
|
||||
if (!fs.existsSync(cacheDirectory)) {
|
||||
fs.mkdirSync(cacheDirectory);
|
||||
}
|
||||
|
||||
ipcMain.handle('getSpeech', async (event, text) => {
|
||||
// Create a hash of the text to use as a unique filename
|
||||
const filename = crypto.createHash('md5').update(text).digest('hex') + '.mp3';
|
||||
|
||||
// Full path of the audio file in the cache directory
|
||||
const filepath = cacheDirectory + filename;
|
||||
|
||||
// Check if audio file already exists in the cache
|
||||
if (!fs.existsSync(filepath)) {
|
||||
// If audio file does not exist, make API request
|
||||
try {
|
||||
const response = await axios({
|
||||
method: 'post',
|
||||
url: 'https://api.elevenlabs.io/v1/text-to-speech/8JNqTOY3RaSYcHTVJZ0G',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'xi-api-key': 'd191e27c2e5b07573b39fe70f0783f48'
|
||||
},
|
||||
data: {
|
||||
text: text,
|
||||
model_id: 'eleven_multilingual_v1',
|
||||
voice_settings: {
|
||||
stability: 0,
|
||||
similarity_boost: 0,
|
||||
style: 0.5,
|
||||
use_speaker_boost: true
|
||||
}
|
||||
},
|
||||
responseType: 'arraybuffer'
|
||||
});
|
||||
|
||||
// Write the audio data to a file in the cache directory
|
||||
fs.writeFileSync(filepath, response.data);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error making API request: ${error}`);
|
||||
}
|
||||
}
|
||||
return filepath
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* TextBuffer Module
|
||||
* Manages text processing and sentence detection for the UI
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class TextBufferModule extends BaseModule {
|
||||
constructor() {
|
||||
super('text-buffer', 'Text Buffer');
|
||||
this.buffer = '';
|
||||
this.sentenceEndRegex = /[.!?]\s+/g; // Detect sentence endings
|
||||
this.onSentenceReadyCallback = null; // Callback for complete sentences
|
||||
this.processingLock = false; // Lock to prevent concurrent processing
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
this.reportProgress(100, "Text buffer ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing Text Buffer:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback function for when a sentence is ready
|
||||
* @param {Function} callback - Function to call with the sentence and completion callback
|
||||
*/
|
||||
setOnSentenceReady(callback) {
|
||||
if (typeof callback === 'function') {
|
||||
this.onSentenceReadyCallback = callback;
|
||||
console.log("Text Buffer: Sentence ready callback set");
|
||||
} else {
|
||||
console.warn("Text Buffer: Invalid sentence ready callback provided");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add text to the buffer and process sentences
|
||||
* @param {string} text - Text to add to the buffer
|
||||
*/
|
||||
addText(text) {
|
||||
if (!text) return;
|
||||
|
||||
console.log(`TextBuffer: Adding text: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// Add text to buffer
|
||||
this.buffer += text;
|
||||
|
||||
// If we have a trailing newline as a complete sentence, add a period
|
||||
if (this.buffer.endsWith('\n') && !this.buffer.endsWith('.\n')) {
|
||||
const lastChar = this.buffer.charAt(this.buffer.length - 2);
|
||||
if (lastChar !== '.' && lastChar !== '!' && lastChar !== '?') {
|
||||
this.buffer = this.buffer.slice(0, -1) + '.\n';
|
||||
}
|
||||
}
|
||||
|
||||
// Process any complete sentences
|
||||
this.processSentences();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process complete sentences in the buffer
|
||||
*/
|
||||
processSentences() {
|
||||
// Prevent concurrent processing
|
||||
if (this.processingLock) return;
|
||||
this.processingLock = true;
|
||||
|
||||
try {
|
||||
// Check for sentence endings (including newlines as sentence endings)
|
||||
const sentenceEndings = [/[.!?]\s+/g, /[.!?]$/m, /\n/g];
|
||||
|
||||
let foundSentence = false;
|
||||
|
||||
for (const pattern of sentenceEndings) {
|
||||
if (this.buffer.match(pattern)) {
|
||||
foundSentence = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundSentence) {
|
||||
// No complete sentences yet
|
||||
this.processingLock = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Process each complete sentence
|
||||
this.processNextSentence();
|
||||
} catch (error) {
|
||||
console.error("Error processing sentences:", error);
|
||||
this.processingLock = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the next sentence in the buffer
|
||||
*/
|
||||
processNextSentence() {
|
||||
// Check for different sentence endings
|
||||
const patterns = [/[.!?]\s+/, /[.!?]$/, /\n/];
|
||||
let match = null;
|
||||
let endIndex = -1;
|
||||
|
||||
// Try to find the first sentence ending
|
||||
for (const pattern of patterns) {
|
||||
match = this.buffer.match(pattern);
|
||||
if (match) {
|
||||
endIndex = match.index + match[0].length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex === -1) {
|
||||
// No complete sentence found
|
||||
this.processingLock = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const sentence = this.buffer.substring(0, endIndex);
|
||||
|
||||
// Remove the processed sentence from buffer
|
||||
this.buffer = this.buffer.substring(endIndex);
|
||||
|
||||
console.log(`TextBuffer: Processing sentence: "${sentence.trim()}"`);
|
||||
|
||||
// Call the callback if set
|
||||
if (this.onSentenceReadyCallback) {
|
||||
this.onSentenceReadyCallback(sentence, () => {
|
||||
// After processing is complete, check for more sentences
|
||||
setTimeout(() => {
|
||||
if (this.buffer.length > 0) {
|
||||
this.processSentences();
|
||||
} else {
|
||||
this.processingLock = false;
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
} else {
|
||||
// No callback set, just process the next sentence
|
||||
if (this.buffer.length > 0) {
|
||||
this.processSentences();
|
||||
} else {
|
||||
this.processingLock = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the text buffer
|
||||
*/
|
||||
clear() {
|
||||
this.buffer = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current buffer content
|
||||
* @returns {string} - Current buffer content
|
||||
*/
|
||||
getBuffer() {
|
||||
return this.buffer;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const TextBuffer = new TextBufferModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(TextBuffer);
|
||||
|
||||
// Export the module
|
||||
export { TextBuffer };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.TextBuffer = TextBuffer;
|
||||
+298
-55
@@ -1,71 +1,314 @@
|
||||
/**
|
||||
* TextProcessor Module
|
||||
* Encapsulates text pre-processing steps required before layout calculation.
|
||||
* Text Processor Module
|
||||
* Handles text formatting and typography enhancements like smart quotes and hyphenation
|
||||
*/
|
||||
export class TextProcessor {
|
||||
/**
|
||||
* Create a new TextProcessor
|
||||
* @param {Object} smartyPants - The SmartyPants library
|
||||
* @param {Function} [hyphenator] - Optional: The hyphenation function (can be set later)
|
||||
*/
|
||||
constructor(smartyPants, hyphenator = null) { // Make hyphenator optional
|
||||
this.smartyPants = smartyPants;
|
||||
this.hyphenator = hyphenator;
|
||||
this.hyphenationClass = '.hyphenatePipe'; // Default hyphenation class for Knuth-Plass with pipe character
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class TextProcessorModule extends BaseModule {
|
||||
constructor() {
|
||||
super('text-processor', 'Text Processor');
|
||||
this.smartyPants = null; // Store the function reference here
|
||||
this.smartypantsu = null; // Store the function reference here
|
||||
this.hyphenator = null; // For hyphenation function
|
||||
this.hyphenatorReady = false;
|
||||
this.locale = 'en-us';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process text with typographic enhancements and hyphenation
|
||||
* Load module dependencies
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async loadDependencies() {
|
||||
try {
|
||||
this.reportProgress(10, "Loading dependencies");
|
||||
|
||||
// Load SmartyPants script dynamically
|
||||
await this.loadSmartyPantsScript();
|
||||
this.reportProgress(50, "SmartyPants loaded");
|
||||
|
||||
// Initialize hyphenation in the background, but don't wait for it
|
||||
this.initializeHyphenation();
|
||||
|
||||
this.reportProgress(90, "Dependencies loaded");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error loading Text Processor dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the SmartyPants script dynamically and wait for it to be ready
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
loadSmartyPantsScript() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if already loaded globally
|
||||
if (typeof window.SmartyPants === 'object' && typeof window.SmartyPants.smartypants === 'function') {
|
||||
this.smartyPants = window.SmartyPants.smartypants;
|
||||
this.smartypantsu = window.SmartyPants.smartypantsu;
|
||||
console.log("SmartyPants already loaded globally");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the script using a script tag
|
||||
const script = document.createElement('script');
|
||||
script.src = '/js/smartypants.js';
|
||||
script.async = false; // Load synchronously relative to other scripts
|
||||
|
||||
script.onload = () => {
|
||||
// Use a microtask to ensure the script has executed
|
||||
Promise.resolve().then(() => {
|
||||
if (typeof window.SmartyPants === 'object' && typeof window.SmartyPants.smartypants === 'function') {
|
||||
this.smartyPants = window.SmartyPants.smartypants;
|
||||
this.smartypantsu = window.SmartyPants.smartypantsu;
|
||||
console.log("SmartyPants loaded successfully via script tag");
|
||||
resolve();
|
||||
} else {
|
||||
console.error("SmartyPants script loaded but functions not found on window.SmartyPants");
|
||||
reject(new Error('SmartyPants functions not found after loading'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
console.error('Failed to load smartypants.js script');
|
||||
reject(new Error('Failed to load smartypants.js script'));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
this.reportProgress(70, "Initializing text processor");
|
||||
|
||||
// Get locale from Localization module if available
|
||||
const localizationModule = moduleRegistry.getModule('localization');
|
||||
if (localizationModule) {
|
||||
this.locale = localizationModule.getLocale();
|
||||
// Register as an observer for locale changes
|
||||
localizationModule.registerObserver(this, (newLocale) => {
|
||||
this.setLocale(newLocale);
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure global locale is set for SmartyPants
|
||||
window.locale = this.locale;
|
||||
|
||||
// Verify SmartyPants is available via the stored references
|
||||
if (typeof this.smartyPants !== 'function') {
|
||||
console.error("SmartyPants function not available for initialization");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Final initialization steps
|
||||
this.reportProgress(100, "Text processor ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing Text Processor:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize hyphenation using Hyphenopoly
|
||||
*/
|
||||
initializeHyphenation() {
|
||||
// Create custom events for hyphenation loading status
|
||||
const hyphenationLoadedEvent = new CustomEvent('hyphenation-loaded');
|
||||
|
||||
// Add listener for hyphenation loaded event
|
||||
document.addEventListener('hyphenation-loaded', () => {
|
||||
console.log('Hyphenation module loaded');
|
||||
this.hyphenatorReady = true;
|
||||
}, { once: true });
|
||||
|
||||
// Check if Hyphenopoly is loaded
|
||||
if (window.Hyphenopoly) {
|
||||
this.setupHyphenopoly();
|
||||
} else {
|
||||
// Set up listener for when Hyphenopoly might be loaded later
|
||||
window.addEventListener('hyphenopoly-loaded', () => {
|
||||
this.setupHyphenopoly();
|
||||
});
|
||||
|
||||
// Try loading Hyphenopoly if not already loading
|
||||
if (!document.querySelector('script[src*="Hyphenopoly_Loader.js"]')) {
|
||||
this.loadHyphenopolyScript();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the Hyphenopoly script
|
||||
*/
|
||||
loadHyphenopolyScript() {
|
||||
// Create script element for loader
|
||||
const script = document.createElement('script');
|
||||
script.src = '/js/Hyphenopoly_Loader.js';
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
document.dispatchEvent(new CustomEvent('hyphenopoly-script-loaded'));
|
||||
};
|
||||
|
||||
script.onerror = (error) => {
|
||||
console.error('Failed to load Hyphenopoly:', error);
|
||||
document.dispatchEvent(new CustomEvent('hyphenation-error', {
|
||||
detail: { error: 'Failed to load Hyphenopoly script' }
|
||||
}));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
|
||||
// Set up configuration for Hyphenopoly
|
||||
window.Hyphenopoly = {
|
||||
require: {
|
||||
'en-us': 'FORCEHYPHENATION'
|
||||
},
|
||||
paths: {
|
||||
maindir: '/js/',
|
||||
patterndir: '/js/patterns/'
|
||||
},
|
||||
setup: {
|
||||
selectors: {
|
||||
'.hyphenate': {}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Hyphenopoly when it's available
|
||||
*/
|
||||
setupHyphenopoly() {
|
||||
// Wait for hyphenator to be available
|
||||
if (window.Hyphenopoly && window.Hyphenopoly.hyphenators) {
|
||||
// Get hyphenator for English
|
||||
window.Hyphenopoly.hyphenators['en-us'].then((hyphenator) => {
|
||||
console.log('Hyphenator ready');
|
||||
this.hyphenator = hyphenator;
|
||||
this.hyphenatorReady = true;
|
||||
|
||||
// Dispatch event that hyphenation is ready
|
||||
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
|
||||
}).catch(err => {
|
||||
console.error('Error loading hyphenator:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the hyphenator function
|
||||
* @param {Function} hyphenatorFunc - The hyphenator function
|
||||
*/
|
||||
setHyphenator(hyphenatorFunc) {
|
||||
if (typeof hyphenatorFunc === 'function') {
|
||||
this.hyphenator = hyphenatorFunc;
|
||||
this.hyphenatorReady = true;
|
||||
console.log("Hyphenator function set explicitly");
|
||||
} else {
|
||||
console.warn("Invalid hyphenator provided");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process text with SmartyPants and optional hyphenation
|
||||
* @param {string} text - The text to process
|
||||
* @returns {string} The processed text
|
||||
* @param {boolean} useHyphenation - Whether to apply hyphenation
|
||||
* @returns {string} - The processed text
|
||||
*/
|
||||
process(text) {
|
||||
// First apply SmartyPants for typographic enhancement
|
||||
const smartyPantsText = this.smartyPants.smartypantsu(text, 1)
|
||||
// Remove these replacements that were causing the spacing issues
|
||||
// .replace(/\.\s*$/g, '.')
|
||||
// .replace(/\?\s*$/g, '?')
|
||||
// .replace(/!\s*$/g, '!')
|
||||
|
||||
// Instead, ensure proper spacing between sentences
|
||||
.replace(/\.\s+/g, '. ') // Normalize spaces after periods
|
||||
.replace(/\?\s+/g, '? ') // Normalize spaces after question marks
|
||||
.replace(/!\s+/g, '! '); // Normalize spaces after exclamation marks
|
||||
process(text, useHyphenation = false) {
|
||||
if (!text) return '';
|
||||
|
||||
// Then apply hyphenation if available
|
||||
if (typeof this.hyphenator === 'function') {
|
||||
return this.hyphenator(smartyPantsText, this.hyphenationClass);
|
||||
} else {
|
||||
console.warn('TextProcessor: Hyphenator not set, skipping hyphenation.');
|
||||
return smartyPantsText; // Return text without hyphenation if not set
|
||||
let processed = text;
|
||||
|
||||
// Apply SmartyPants for typographic punctuation using stored references
|
||||
try {
|
||||
if (typeof this.smartyPants === 'function') {
|
||||
processed = this.smartyPants(processed);
|
||||
} else {
|
||||
console.warn("SmartyPants function not available for processing");
|
||||
}
|
||||
|
||||
// Convert HTML entities to UTF-8 characters
|
||||
if (typeof this.smartypantsu === 'function') {
|
||||
processed = this.smartypantsu(processed);
|
||||
} else {
|
||||
console.warn("smartypantsu function not available for processing");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error applying SmartyPants:", error);
|
||||
}
|
||||
|
||||
// Apply hyphenation if enabled and available
|
||||
if (useHyphenation && this.hyphenatorReady && this.hyphenator) {
|
||||
try {
|
||||
processed = this.hyphenator(processed);
|
||||
} catch (error) {
|
||||
console.error("Error applying hyphenation:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if hyphenation is available
|
||||
* @returns {boolean} - Whether hyphenation is available
|
||||
*/
|
||||
isHyphenationAvailable() {
|
||||
return this.hyphenatorReady && this.hyphenator !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply only hyphenation to text
|
||||
* @param {string} text - The text to hyphenate
|
||||
* @returns {string} - The hyphenated text
|
||||
*/
|
||||
hyphenate(text) {
|
||||
if (!text || !this.hyphenatorReady || !this.hyphenator) {
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.hyphenator(text);
|
||||
} catch (error) {
|
||||
console.error("Error hyphenating text:", error);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the hyphenator function after initialization.
|
||||
* @param {Function} hyphenatorFunction - The hyphenation function provided by Hyphenopoly.
|
||||
* Set the locale for text processing
|
||||
* @param {string} locale - The locale code (e.g., 'en-us', 'de')
|
||||
*/
|
||||
setHyphenator(hyphenatorFunction) {
|
||||
if (typeof hyphenatorFunction === 'function') {
|
||||
this.hyphenator = hyphenatorFunction;
|
||||
} else {
|
||||
console.error('TextProcessor: Invalid hyphenator function provided.');
|
||||
setLocale(locale) {
|
||||
if (locale && typeof locale === 'string') {
|
||||
this.locale = locale.toLowerCase();
|
||||
// Update global locale for SmartyPants
|
||||
window.locale = this.locale;
|
||||
console.log(`TextProcessor: Locale set to ${locale}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the hyphenation class
|
||||
* @param {string} className - The CSS class for hyphenation
|
||||
*/
|
||||
setHyphenationClass(className) {
|
||||
this.hyphenationClass = className;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current hyphenation class
|
||||
* @returns {string} The current hyphenation class
|
||||
*/
|
||||
getHyphenationClass() {
|
||||
return this.hyphenationClass;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const TextProcessor = new TextProcessorModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(TextProcessor);
|
||||
|
||||
// Export the module
|
||||
export { TextProcessor };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.TextProcessor = TextProcessor;
|
||||
|
||||
+510
-127
@@ -1,172 +1,555 @@
|
||||
/**
|
||||
* TTS Factory for AI Interactive Fiction
|
||||
* Attempts to use Kokoro TTS first, then falls back to browser TTS if needed
|
||||
* Manages different TTS implementations with a common interface
|
||||
*/
|
||||
import { kokoroHandler } from './kokoro-handler.js';
|
||||
import { browserTtsHandler } from './tts-handler.js';
|
||||
|
||||
export class TTSFactory {
|
||||
class TTSFactory {
|
||||
constructor() {
|
||||
this.activeTTSHandler = null;
|
||||
this.ttsHandler = null;
|
||||
this.handlers = {};
|
||||
this.initializationAttempted = false;
|
||||
this.usingKokoro = false;
|
||||
this.initializationPromise = null; // Promise for the factory initialization
|
||||
this.initializationPromise = null;
|
||||
this.ttsEnabled = true;
|
||||
this.progressCallback = null;
|
||||
this.persistenceManager = null;
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => this.initialize());
|
||||
} else {
|
||||
// Use requestAnimationFrame to ensure scripts are parsed
|
||||
requestAnimationFrame(() => this.initialize());
|
||||
/**
|
||||
* Initialize the TTS Factory - Static method for the module loader
|
||||
* @param {Function} reportProgress - Function to report loading progress to the loader
|
||||
* @returns {Promise} - Resolves when TTS is initialized
|
||||
*/
|
||||
static async initializeInterface(reportProgress = null) {
|
||||
console.log('TTS Factory: Initializing interface');
|
||||
|
||||
// Create singleton instance if needed
|
||||
if (!window.ttsFactory) {
|
||||
window.ttsFactory = new TTSFactory();
|
||||
}
|
||||
|
||||
// Initialize TTS with the progress callback
|
||||
window.ttsFactory.progressCallback = reportProgress;
|
||||
|
||||
try {
|
||||
// Start initialization process
|
||||
await window.ttsFactory.initialize();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error initializing TTS Factory:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize available TTS handlers
|
||||
* Initialize the TTS Factory
|
||||
* This will load and initialize all available TTS handlers
|
||||
* @returns {Promise} - Resolves when initialization is complete
|
||||
*/
|
||||
async initialize() {
|
||||
// Prevent multiple initializations
|
||||
if (this.initializationAttempted) return this.initializationPromise;
|
||||
this.initializationAttempted = true;
|
||||
|
||||
console.log('Initializing TTS Factory...');
|
||||
if (this.initializationPromise) {
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
this.initializationPromise = new Promise(async (resolve) => {
|
||||
let kokoroInitialized = false;
|
||||
// Try to initialize Kokoro first (preferred option)
|
||||
try {
|
||||
console.log('Attempting to initialize Kokoro TTS...');
|
||||
|
||||
// --- Increase Timeout for Kokoro Initialization ---
|
||||
// Wait for KokoroHandler's internal initialization promise
|
||||
// Use Promise.race to add a longer timeout specifically for Kokoro init
|
||||
const kokoroTimeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Kokoro initialization timed out in factory')), 60000) // 60 seconds timeout
|
||||
);
|
||||
this.initializationAttempted = true;
|
||||
|
||||
const reportProgress = (percent, message) => {
|
||||
console.log(`TTS progress: ${percent}% - ${message}`);
|
||||
if (this.progressCallback && typeof this.progressCallback === 'function') {
|
||||
this.progressCallback(percent, message);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
kokoroInitialized = await Promise.race([
|
||||
kokoroHandler.initializationPromise,
|
||||
kokoroTimeoutPromise
|
||||
]);
|
||||
} catch (timeoutError) {
|
||||
console.error(timeoutError.message); // Log the timeout error
|
||||
kokoroInitialized = false;
|
||||
}
|
||||
// --- End Increase Timeout ---
|
||||
try {
|
||||
// Report starting initialization
|
||||
reportProgress(10, 'Loading TTS modules');
|
||||
|
||||
// Get persistence manager if available
|
||||
if (window.PersistenceManager) {
|
||||
this.persistenceManager = window.PersistenceManager;
|
||||
reportProgress(15, 'Persistence manager found, loading preferences');
|
||||
|
||||
if (kokoroInitialized) {
|
||||
console.log('Kokoro Handler reported successful initialization.');
|
||||
} else {
|
||||
console.warn('Kokoro Handler reported failed or timed out initialization.');
|
||||
// Load preferences to determine TTS enabled state and preferred provider
|
||||
const prefs = this.persistenceManager.getAllPreferences();
|
||||
if (prefs && prefs.tts) {
|
||||
this.ttsEnabled = prefs.tts.enabled;
|
||||
console.log(`TTS Factory: Setting initial TTS enabled state to ${this.ttsEnabled ? 'enabled' : 'disabled'} from preferences`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing Kokoro Handler:', error);
|
||||
kokoroInitialized = false; // Ensure it's marked as failed
|
||||
}
|
||||
|
||||
// Import needed modules dynamically
|
||||
const [{ BrowserTTSHandler }, { KokoroHandler }, { ApiTTSHandler }] = await Promise.all([
|
||||
import('./browser-tts-handler.js'),
|
||||
import('./kokoro-handler.js'),
|
||||
import('./api-tts-handler.js')
|
||||
]);
|
||||
|
||||
reportProgress(20, 'TTS modules loaded');
|
||||
|
||||
// Create handlers
|
||||
const browserHandler = new BrowserTTSHandler();
|
||||
const kokoroHandler = new KokoroHandler();
|
||||
const apiHandler = new ApiTTSHandler();
|
||||
|
||||
// Store handlers
|
||||
this.handlers = {
|
||||
browser: browserHandler,
|
||||
kokoro: kokoroHandler,
|
||||
api: apiHandler
|
||||
};
|
||||
|
||||
// Get preferred TTS mode from options
|
||||
const preferredTTSMode = this.getPreferredTTSMode();
|
||||
|
||||
// Initialize the preferred handler first
|
||||
if (preferredTTSMode === 'browser') {
|
||||
// User prefers browser TTS
|
||||
await this.initializeBrowserTTS(browserHandler, reportProgress);
|
||||
} else if (preferredTTSMode === 'api') {
|
||||
// User prefers API TTS
|
||||
await this.initializeApiTTS(apiHandler, reportProgress);
|
||||
|
||||
// Fallback to browser TTS if API fails
|
||||
if (!apiHandler.isAvailable()) {
|
||||
await this.initializeBrowserTTS(browserHandler, reportProgress);
|
||||
}
|
||||
} else {
|
||||
// Default flow: prefer Kokoro, with browser as immediate fallback
|
||||
// Initialize browser TTS immediately for a responsive experience
|
||||
await this.initializeBrowserTTS(browserHandler, reportProgress);
|
||||
|
||||
// Then schedule Kokoro loading in the background
|
||||
reportProgress(75, 'Scheduling Kokoro TTS initialization');
|
||||
this.scheduleKokoroInitialization(kokoroHandler, reportProgress).then((kokoroAvailable) => {
|
||||
if (kokoroAvailable) {
|
||||
// Switch to Kokoro as it's the best option and set as preferred
|
||||
this.ttsHandler = kokoroHandler;
|
||||
this.setPreferredTTSMode('kokoro');
|
||||
this.dispatchTTSReadyEvent(true, 'kokoro', kokoroHandler);
|
||||
reportProgress(100, 'Kokoro TTS ready');
|
||||
|
||||
// Apply voice settings from preferences if available
|
||||
this.applyVoiceSettingsFromPreferences();
|
||||
} else if (!this.getPreferredTTSMode()) {
|
||||
// If Kokoro failed and no preference was previously set,
|
||||
// set browser as preferred mode
|
||||
this.setPreferredTTSMode('browser');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Decide which handler to use based on Kokoro's success
|
||||
this.selectActiveHandler(kokoroInitialized);
|
||||
resolve(); // Resolve the factory's promise
|
||||
// Apply voice settings from preferences for initial handler
|
||||
this.applyVoiceSettingsFromPreferences();
|
||||
|
||||
// Resolve initialization even though Kokoro is still loading in background
|
||||
reportProgress(80, 'TTS interface ready' +
|
||||
(preferredTTSMode !== 'kokoro' ? '' : ' (Kokoro loading in background)'));
|
||||
resolve(true);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing TTS Factory:', error);
|
||||
|
||||
// If we have any handler working, consider initialization successful
|
||||
if (this.ttsHandler) {
|
||||
reportProgress(100, `Using ${this.ttsHandler.getId()} TTS (fallback)`);
|
||||
resolve(true);
|
||||
} else {
|
||||
this.dispatchTTSReadyEvent(false);
|
||||
reportProgress(100, 'TTS initialization failed');
|
||||
resolve(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select which TTS handler to use
|
||||
* @param {boolean} kokoroInitialized - Whether Kokoro initialization succeeded
|
||||
* Apply stored voice settings from preferences
|
||||
* @private
|
||||
*/
|
||||
selectActiveHandler(kokoroInitialized) {
|
||||
// First choice: Kokoro if it's available and initialized successfully
|
||||
if (kokoroInitialized && kokoroHandler.kokoroReady) {
|
||||
console.log('Using Kokoro TTS as primary TTS system');
|
||||
this.activeTTSHandler = kokoroHandler;
|
||||
this.usingKokoro = true;
|
||||
}
|
||||
// Fallback to browser TTS if available
|
||||
else if (browserTtsHandler) {
|
||||
console.log('Falling back to browser TTS.');
|
||||
this.activeTTSHandler = browserTtsHandler;
|
||||
this.usingKokoro = false;
|
||||
}
|
||||
// No TTS available
|
||||
else {
|
||||
console.error('No TTS system available.');
|
||||
this.activeTTSHandler = null;
|
||||
this.usingKokoro = false;
|
||||
}
|
||||
|
||||
// Expose the active handler as the global ttsHandler for compatibility
|
||||
window.ttsHandler = this.activeTTSHandler;
|
||||
|
||||
// Log the active TTS system
|
||||
if (this.usingKokoro) {
|
||||
console.log('TTS Factory initialized with Kokoro TTS');
|
||||
} else if (this.activeTTSHandler) {
|
||||
console.log('TTS Factory initialized with browser TTS');
|
||||
} else {
|
||||
console.log('TTS Factory initialized with no available TTS');
|
||||
}
|
||||
applyVoiceSettingsFromPreferences() {
|
||||
if (!this.ttsHandler || !this.persistenceManager) return;
|
||||
|
||||
// Dispatch an event to notify the UI that TTS is ready (or not)
|
||||
const ttsReadyEvent = new CustomEvent('tts-ready', {
|
||||
detail: {
|
||||
available: !!this.activeTTSHandler,
|
||||
type: this.usingKokoro ? 'kokoro' : (this.activeTTSHandler ? 'browser' : 'none'),
|
||||
handler: this.activeTTSHandler
|
||||
const prefs = this.persistenceManager.getAllPreferences();
|
||||
if (prefs && prefs.tts) {
|
||||
if (prefs.tts.voice) {
|
||||
console.log(`TTS Factory: Setting voice to ${prefs.tts.voice} from preferences`);
|
||||
// Check if setVoice exists, otherwise try setting through voiceOptions
|
||||
if (typeof this.ttsHandler.setVoice === 'function') {
|
||||
this.ttsHandler.setVoice(prefs.tts.voice);
|
||||
} else if (typeof this.ttsHandler.setVoiceOptions === 'function') {
|
||||
this.ttsHandler.setVoiceOptions({ voice: prefs.tts.voice });
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(ttsReadyEvent);
|
||||
}
|
||||
|
||||
if (prefs.tts.rate !== undefined) {
|
||||
console.log(`TTS Factory: Setting speech rate to ${prefs.tts.rate} from preferences`);
|
||||
// Check if setSpeed exists, otherwise try setting through voiceOptions
|
||||
if (typeof this.ttsHandler.setSpeed === 'function') {
|
||||
this.ttsHandler.setSpeed(prefs.tts.rate);
|
||||
} else if (typeof this.ttsHandler.setVoiceOptions === 'function') {
|
||||
this.ttsHandler.setVoiceOptions({ rate: prefs.tts.rate });
|
||||
}
|
||||
}
|
||||
|
||||
if (prefs.tts.volume !== undefined && typeof this.ttsHandler.setVolume === 'function') {
|
||||
console.log(`TTS Factory: Setting volume to ${prefs.tts.volume} from preferences`);
|
||||
this.ttsHandler.setVolume(prefs.tts.volume);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get info about the active TTS system
|
||||
* Initialize browser TTS
|
||||
* @param {BrowserTTSHandler} handler - The browser TTS handler
|
||||
* @param {Function} reportProgress - Progress reporting function
|
||||
* @returns {Promise<boolean>} - Resolves with availability status
|
||||
*/
|
||||
getActiveTTSInfo() {
|
||||
if (!this.activeTTSHandler) {
|
||||
return { available: false, type: 'none', name: 'None' };
|
||||
async initializeBrowserTTS(handler, reportProgress) {
|
||||
reportProgress(30, 'Initializing browser TTS');
|
||||
const browserAvailable = await handler.initialize();
|
||||
|
||||
if (browserAvailable) {
|
||||
this.ttsHandler = handler;
|
||||
this.dispatchTTSReadyEvent(true, 'browser', handler);
|
||||
reportProgress(40, 'Browser TTS ready');
|
||||
} else {
|
||||
reportProgress(40, 'Browser TTS not available');
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
type: this.usingKokoro ? 'kokoro' : 'browser',
|
||||
name: this.usingKokoro ? 'Kokoro TTS' : 'Browser TTS'
|
||||
};
|
||||
return browserAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize API TTS
|
||||
* @param {ApiTTSHandler} handler - The API TTS handler
|
||||
* @param {Function} reportProgress - Progress reporting function
|
||||
* @returns {Promise<boolean>} - Resolves with availability status
|
||||
*/
|
||||
async initializeApiTTS(handler, reportProgress) {
|
||||
reportProgress(50, 'Initializing API TTS');
|
||||
const apiAvailable = await handler.initialize();
|
||||
|
||||
if (apiAvailable) {
|
||||
this.ttsHandler = handler;
|
||||
this.dispatchTTSReadyEvent(true, 'api', handler);
|
||||
reportProgress(70, 'API TTS ready');
|
||||
}
|
||||
|
||||
return apiAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force switching to a specific TTS system
|
||||
* @param {string} type - Either 'kokoro' or 'browser'
|
||||
* Get preferred TTS mode from storage
|
||||
* @returns {string|null} - Preferred TTS mode or null if not set
|
||||
*/
|
||||
switchTTS(type) {
|
||||
if (type === 'kokoro' && kokoroHandler && kokoroHandler.kokoroReady) {
|
||||
this.activeTTSHandler = kokoroHandler;
|
||||
this.usingKokoro = true;
|
||||
window.ttsHandler = this.activeTTSHandler;
|
||||
console.log('Switched to Kokoro TTS');
|
||||
// Dispatch event on switch
|
||||
const ttsReadyEvent = new CustomEvent('tts-ready', { detail: { available: true, type: 'kokoro', handler: this.activeTTSHandler } });
|
||||
window.dispatchEvent(ttsReadyEvent);
|
||||
return true;
|
||||
} else if (type === 'browser' && browserTtsHandler) {
|
||||
this.activeTTSHandler = browserTtsHandler;
|
||||
this.usingKokoro = false;
|
||||
window.ttsHandler = this.activeTTSHandler;
|
||||
console.log('Switched to browser TTS');
|
||||
// Dispatch event on switch
|
||||
const ttsReadyEvent = new CustomEvent('tts-ready', { detail: { available: true, type: 'browser', handler: this.activeTTSHandler } });
|
||||
window.dispatchEvent(ttsReadyEvent);
|
||||
return true;
|
||||
getPreferredTTSMode() {
|
||||
// First check persistent settings if available
|
||||
if (this.persistenceManager) {
|
||||
const prefs = this.persistenceManager.getAllPreferences();
|
||||
if (prefs && prefs.tts && prefs.tts.provider) {
|
||||
console.log(`TTS Factory: Using preferred TTS mode '${prefs.tts.provider}' from persistence manager`);
|
||||
return prefs.tts.provider;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Failed to switch to ${type} TTS - not available`);
|
||||
return false;
|
||||
// Fallback to localStorage if persistence manager is not available
|
||||
try {
|
||||
const savedMode = localStorage.getItem('preferred-tts-mode');
|
||||
if (savedMode) {
|
||||
console.log(`TTS Factory: Using preferred TTS mode '${savedMode}' from localStorage`);
|
||||
return savedMode;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not read TTS preference from localStorage');
|
||||
}
|
||||
|
||||
// Default to Kokoro if no preference is found
|
||||
return "kokoro";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set preferred TTS mode in storage
|
||||
* @param {string} mode - The TTS mode to save as preferred
|
||||
*/
|
||||
setPreferredTTSMode(mode) {
|
||||
// Update in persistence manager if available
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('tts', 'provider', mode);
|
||||
console.log(`TTS Factory: Saved preferred TTS mode '${mode}' to persistence manager`);
|
||||
}
|
||||
|
||||
// Also save to localStorage as backup
|
||||
try {
|
||||
localStorage.setItem('preferred-tts-mode', mode);
|
||||
} catch (e) {
|
||||
console.warn('Could not save TTS preference to localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule Kokoro initialization during idle time
|
||||
* @param {Object} kokoroHandler - The Kokoro handler instance
|
||||
* @param {Function} reportProgress - Progress reporting function
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
scheduleKokoroInitialization(kokoroHandler, reportProgress) {
|
||||
// Immediately dispatch the loading started event so tts-player can catch it
|
||||
window.dispatchEvent(new CustomEvent('kokoro-loading-started'));
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Create the initialization function
|
||||
const startKokoroInit = async () => {
|
||||
try {
|
||||
// Initialize Kokoro with progress callback
|
||||
const kokoroAvailable = await kokoroHandler.initialize((percent, message) => {
|
||||
// Scale progress to 80-95% range for the TTS module's overall progress
|
||||
const scaledProgress = 80 + Math.floor(percent * 0.15);
|
||||
reportProgress(scaledProgress, message || `Loading Kokoro TTS: ${percent}%`);
|
||||
});
|
||||
|
||||
// Mark completion
|
||||
if (kokoroAvailable) {
|
||||
reportProgress(95, "Kokoro TTS initialized successfully");
|
||||
} else {
|
||||
reportProgress(95, "Kokoro TTS unavailable - using fallback");
|
||||
}
|
||||
|
||||
// Always dispatch event to indicate completion status
|
||||
window.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
|
||||
detail: { success: kokoroAvailable }
|
||||
}));
|
||||
|
||||
resolve(kokoroAvailable);
|
||||
} catch (error) {
|
||||
console.error('Error initializing Kokoro:', error);
|
||||
reportProgress(95, 'Kokoro TTS failed to initialize - using fallback');
|
||||
|
||||
// Dispatch completion event with error information
|
||||
window.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
|
||||
detail: { success: false, error: error.message }
|
||||
}));
|
||||
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Add timeout protection with a reasonable timeout (30 seconds for resource-intensive operations)
|
||||
const timeoutId = setTimeout(() => {
|
||||
reportProgress(95, 'Kokoro initialization timed out - using fallback');
|
||||
window.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
|
||||
detail: { success: false, error: "Timeout" }
|
||||
}));
|
||||
resolve(false);
|
||||
}, 30000); // Increased timeout to 30 seconds since model loading is resource intensive
|
||||
|
||||
// Use requestIdleCallback to start initialization during idle time
|
||||
if (window.requestIdleCallback) {
|
||||
reportProgress(75, 'Scheduling Kokoro TTS for background loading');
|
||||
|
||||
window.requestIdleCallback(() => {
|
||||
startKokoroInit().then(() => clearTimeout(timeoutId));
|
||||
}, { timeout: 10000 });
|
||||
} else {
|
||||
reportProgress(75, 'Background loading not available, loading Kokoro normally');
|
||||
|
||||
// Use a microtask to avoid blocking the UI thread
|
||||
Promise.resolve().then(() => startKokoroInit().then(() => clearTimeout(timeoutId)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a custom event when TTS is ready
|
||||
* @param {boolean} available - Whether TTS is available
|
||||
* @param {string} type - The type of TTS
|
||||
* @param {Object} handler - The TTS handler object
|
||||
*/
|
||||
dispatchTTSReadyEvent(available, type = null, handler = null) {
|
||||
const event = new CustomEvent('tts-ready', {
|
||||
detail: {
|
||||
available,
|
||||
type,
|
||||
handler,
|
||||
enabled: this.ttsEnabled
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about the active TTS system
|
||||
* @returns {Object} - TTS system info
|
||||
*/
|
||||
getActiveTTSInfo() {
|
||||
if (!this.ttsHandler) {
|
||||
return { available: false, type: 'none', name: 'None' };
|
||||
}
|
||||
|
||||
const id = this.ttsHandler.getId();
|
||||
const name = {
|
||||
'browser': 'Browser TTS',
|
||||
'kokoro': 'Kokoro Neural TTS',
|
||||
'api': 'ElevenLabs API TTS'
|
||||
}[id] || 'Unknown TTS';
|
||||
|
||||
return {
|
||||
available: true,
|
||||
type: id,
|
||||
name: name
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a specific TTS handler
|
||||
* @param {string} type - The handler ID to use
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
switchTTS(type) {
|
||||
if (!this.handlers[type] || !this.handlers[type].isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.ttsHandler = this.handlers[type];
|
||||
this.dispatchTTSReadyEvent(true, type, this.ttsHandler);
|
||||
|
||||
// Update preferred TTS mode
|
||||
this.setPreferredTTSMode(type);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text using the active TTS handler
|
||||
* @param {string} text - Text to speak
|
||||
* @param {Function} callback - Called when speech completes
|
||||
* @returns {boolean} - True if speech started successfully
|
||||
*/
|
||||
speak(text, callback = null) {
|
||||
if (!this.ttsEnabled || !this.ttsHandler) {
|
||||
console.warn("TTSFactory: No active TTS handler available or TTS disabled");
|
||||
if (callback) callback("No TTS handler");
|
||||
return false;
|
||||
}
|
||||
|
||||
const handlerType = this.ttsHandler.getId();
|
||||
console.log(`TTSFactory: Using ${handlerType} handler to speak "${text}"`);
|
||||
|
||||
try {
|
||||
this.ttsHandler.speak(text, (result) => {
|
||||
console.log(`TTSFactory: Speech completed using ${handlerType}`, result);
|
||||
if (callback) callback(result);
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error speaking:', error);
|
||||
if (callback) callback(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any ongoing speech
|
||||
*/
|
||||
stop() {
|
||||
if (this.ttsHandler) {
|
||||
this.ttsHandler.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options for the active handler
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options = {}) {
|
||||
if (this.ttsHandler && typeof this.ttsHandler.setVoiceOptions === 'function') {
|
||||
this.ttsHandler.setVoiceOptions(options);
|
||||
|
||||
// Save settings to persistence manager if available
|
||||
if (this.persistenceManager) {
|
||||
if (options.voice !== undefined) {
|
||||
this.persistenceManager.updatePreference('tts', 'voice', options.voice, false);
|
||||
}
|
||||
if (options.rate !== undefined) {
|
||||
this.persistenceManager.updatePreference('tts', 'rate', options.rate, false);
|
||||
}
|
||||
if (options.volume !== undefined) {
|
||||
this.persistenceManager.updatePreference('tts', 'volume', options.volume, false);
|
||||
}
|
||||
// Save all changes at once
|
||||
this.persistenceManager.savePreferences();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle TTS on/off
|
||||
* @returns {boolean} - New TTS enabled state
|
||||
*/
|
||||
toggle() {
|
||||
this.ttsEnabled = !this.ttsEnabled;
|
||||
console.log(`TTS Factory: Toggling TTS to ${this.ttsEnabled ? 'enabled' : 'disabled'}`);
|
||||
|
||||
if (!this.ttsEnabled && this.ttsHandler) {
|
||||
this.ttsHandler.stop();
|
||||
}
|
||||
|
||||
// Save the new state to preferences if persistence manager is available
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
|
||||
console.log(`TTS Factory: Saved enabled state (${this.ttsEnabled}) to persistence manager`);
|
||||
}
|
||||
|
||||
return this.ttsEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TTS is enabled
|
||||
* @returns {boolean} - Current TTS enabled state
|
||||
*/
|
||||
isEnabled() {
|
||||
return this.ttsEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available handlers
|
||||
* @returns {Object} - Map of available handlers
|
||||
*/
|
||||
getAvailableHandlers() {
|
||||
const available = {};
|
||||
|
||||
Object.entries(this.handlers).forEach(([id, handler]) => {
|
||||
if (handler.isAvailable()) {
|
||||
available[id] = handler;
|
||||
}
|
||||
});
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices from active handler
|
||||
* @returns {Promise<Array>} - Array of available voices
|
||||
*/
|
||||
async getVoices() {
|
||||
if (!this.ttsHandler || typeof this.ttsHandler.getVoices !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.ttsHandler.getVoices();
|
||||
} catch (error) {
|
||||
console.error('Error getting voices:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
export const ttsFactory = new TTSFactory();
|
||||
// Create singleton instance
|
||||
const ttsFactory = new TTSFactory();
|
||||
|
||||
// Keep a reference in window for compatibility with existing code
|
||||
// Export the factory
|
||||
export { ttsFactory };
|
||||
|
||||
// Keep global reference
|
||||
window.ttsFactory = ttsFactory;
|
||||
+110
-414
@@ -1,414 +1,110 @@
|
||||
/**
|
||||
* Text-to-Speech Handler for AI Interactive Fiction
|
||||
* Enhanced version with improved voice selection, caching, and playback controls
|
||||
*/
|
||||
|
||||
export class TTSHandler {
|
||||
constructor() {
|
||||
this.enabled = false;
|
||||
this.speaking = false;
|
||||
this.paused = false;
|
||||
this.utterance = null;
|
||||
this.voiceCache = [];
|
||||
this.preferredVoice = null;
|
||||
this.audioCache = new Map(); // Cache for audio segments
|
||||
this.currentSpeed = 1.0;
|
||||
this.hasUserActivation = false;
|
||||
this.permissionError = false;
|
||||
this.speakQueue = [];
|
||||
this.isSpeakingFromQueue = false;
|
||||
|
||||
// Flag to track when we're deliberately stopping speech
|
||||
this.intentionalStop = false;
|
||||
|
||||
// Initialize if speech synthesis is available
|
||||
if ('speechSynthesis' in window) {
|
||||
this.synth = window.speechSynthesis;
|
||||
|
||||
// Load voices when they become available
|
||||
if (this.synth.getVoices().length > 0) {
|
||||
this.voiceCache = this.synth.getVoices();
|
||||
this.selectPreferredVoice();
|
||||
}
|
||||
|
||||
this.synth.onvoiceschanged = () => {
|
||||
this.voiceCache = this.synth.getVoices();
|
||||
this.selectPreferredVoice();
|
||||
console.log("Voices loaded:", this.voiceCache.length);
|
||||
};
|
||||
|
||||
// Disabled by default until user activates it
|
||||
this.enabled = false;
|
||||
|
||||
// Set up periodic check to detect and fix stuck speech
|
||||
setInterval(() => {
|
||||
// If we think we're speaking but the browser doesn't, reset state
|
||||
if (this.speaking && !this.synth.speaking && !this.isSpeakingFromQueue) {
|
||||
console.log("Detected stuck speech state, resetting");
|
||||
this.speaking = false;
|
||||
|
||||
// Try to continue the queue if there are more items
|
||||
if (this.speakQueue.length > 0) {
|
||||
this.processSpeakQueue();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
console.warn("Text-to-speech functionality not available in this browser.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the preferred voice based on language and quality
|
||||
*/
|
||||
selectPreferredVoice() {
|
||||
// Prefer high-quality voices - ordered by preference
|
||||
const preferredVoiceNames = [
|
||||
"Google UK English Female",
|
||||
"Microsoft Hazel Desktop",
|
||||
"Microsoft Susan",
|
||||
"Daniel",
|
||||
"Karen"
|
||||
];
|
||||
|
||||
// Debug: Print all available voices
|
||||
console.log("Available voices:", this.voiceCache.map(v => v.name + " (" + v.lang + ")").join(", "));
|
||||
|
||||
// Try to find one of our preferred voices
|
||||
for (const name of preferredVoiceNames) {
|
||||
const voice = this.voiceCache.find(v => v.name === name);
|
||||
if (voice) {
|
||||
this.preferredVoice = voice;
|
||||
console.log("Selected preferred voice:", name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to any English voice if preferred not found
|
||||
const englishVoice = this.voiceCache.find(v => v.lang.startsWith('en'));
|
||||
if (englishVoice) {
|
||||
this.preferredVoice = englishVoice;
|
||||
console.log("Selected English voice:", englishVoice.name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Last resort: use the first available voice
|
||||
if (this.voiceCache.length > 0) {
|
||||
this.preferredVoice = this.voiceCache[0];
|
||||
console.log("Selected fallback voice:", this.voiceCache[0].name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle TTS functionality on/off
|
||||
* @returns {boolean} New state of TTS (enabled/disabled)
|
||||
*/
|
||||
toggle() {
|
||||
if (!this.synth) return false;
|
||||
|
||||
// Set user activation flag when toggle is called
|
||||
this.hasUserActivation = true;
|
||||
|
||||
// Clear permission error on toggle
|
||||
this.permissionError = false;
|
||||
|
||||
this.enabled = !this.enabled;
|
||||
console.log("TTS toggled:", this.enabled ? "ON" : "OFF");
|
||||
|
||||
// Stop any ongoing speech when disabling
|
||||
if (!this.enabled && this.speaking) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
// Try a test utterance to request permissions
|
||||
if (this.enabled) {
|
||||
try {
|
||||
// Reset any current utterance first
|
||||
this.synth.cancel();
|
||||
this.speakQueue = [];
|
||||
this.isSpeakingFromQueue = false;
|
||||
|
||||
// Create a silent utterance to trigger permission request
|
||||
const testUtterance = new SpeechSynthesisUtterance("Hello");
|
||||
testUtterance.volume = 0.05; // Very quiet but not silent to ensure it works
|
||||
testUtterance.rate = 1.0;
|
||||
|
||||
// Handle any errors that might occur
|
||||
testUtterance.onerror = (event) => {
|
||||
console.warn("Permission error for TTS:", event);
|
||||
if (event.error === "not-allowed") {
|
||||
this.permissionError = true;
|
||||
this.enabled = false;
|
||||
alert("Text-to-speech was blocked by your browser. Please allow speech in your browser settings.");
|
||||
}
|
||||
};
|
||||
|
||||
// Try to speak the test utterance
|
||||
this.synth.speak(testUtterance);
|
||||
} catch (e) {
|
||||
console.error("Failed to initialize TTS:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the speech rate/speed
|
||||
* @param {number} speed - Speed multiplier (0.1 to 2.0)
|
||||
*/
|
||||
setSpeed(speed) {
|
||||
this.currentSpeed = Math.max(0.1, Math.min(2.0, speed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process text for better speech synthesis
|
||||
* @param {string} text - Text to process
|
||||
* @returns {string} - Processed text
|
||||
*/
|
||||
processTextForSpeech(text) {
|
||||
if (!text) return "";
|
||||
|
||||
// Remove markdown/formatting that would sound strange when read
|
||||
text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); // Bold
|
||||
text = text.replace(/\*([^*]+)\*/g, '$1'); // Italic
|
||||
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // Links
|
||||
|
||||
// Clean up any HTML tags
|
||||
text = text.replace(/<[^>]+>/g, '');
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into sentences for better speech handling
|
||||
* @param {string} text - Text to split
|
||||
* @returns {string[]} - Array of sentences
|
||||
*/
|
||||
splitIntoSentences(text) {
|
||||
if (!text) return [];
|
||||
|
||||
// Split by sentence terminators, keeping the terminator with the sentence
|
||||
const sentenceRegex = /[^.!?]+[.!?]+/g;
|
||||
const sentences = text.match(sentenceRegex) || [text];
|
||||
|
||||
// If we have very long sentences, break them up by commas too
|
||||
return sentences.reduce((result, sentence) => {
|
||||
if (sentence.length > 150 && sentence.includes(',')) {
|
||||
// Split long sentences at commas
|
||||
const parts = sentence.split(/,\s*/);
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
result.push(parts[i] + ',');
|
||||
}
|
||||
result.push(parts[parts.length - 1]);
|
||||
return result;
|
||||
}
|
||||
result.push(sentence);
|
||||
return result;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak a single utterance with proper configuration
|
||||
* @param {string} text - Text to speak
|
||||
* @param {function} onEndCallback - Callback to execute when finished
|
||||
* @private
|
||||
*/
|
||||
speakUtterance(text, onEndCallback) {
|
||||
if (!text || text.trim() === '') {
|
||||
if (onEndCallback) onEndCallback();
|
||||
this.processSpeakQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
|
||||
if (this.preferredVoice) {
|
||||
utterance.voice = this.preferredVoice;
|
||||
console.log("Using voice:", this.preferredVoice.name);
|
||||
}
|
||||
|
||||
utterance.rate = this.currentSpeed;
|
||||
utterance.pitch = 1.0;
|
||||
utterance.volume = 1.0;
|
||||
|
||||
utterance.onstart = () => {
|
||||
this.speaking = true;
|
||||
console.log("TTS started speaking:", text.substring(0, 30) + "...");
|
||||
};
|
||||
|
||||
utterance.onend = () => {
|
||||
console.log("TTS finished speaking utterance");
|
||||
if (onEndCallback) onEndCallback();
|
||||
this.processSpeakQueue();
|
||||
};
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
// Don't treat interrupted errors as real errors when we're deliberately stopping
|
||||
if (event.error === "interrupted" && this.intentionalStop) {
|
||||
console.log("Speech intentionally interrupted");
|
||||
} else {
|
||||
console.error("Speech synthesis error:", event);
|
||||
if (event.error === "not-allowed") {
|
||||
this.permissionError = true;
|
||||
this.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (onEndCallback) onEndCallback();
|
||||
this.processSpeakQueue();
|
||||
};
|
||||
|
||||
// Actually speak
|
||||
this.synth.speak(utterance);
|
||||
|
||||
// Workaround for Chrome bug where speech synthesis gets stuck
|
||||
if (!this.synth.speaking) {
|
||||
this.synth.pause();
|
||||
this.synth.resume();
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error("Error in speakUtterance:", e);
|
||||
if (onEndCallback) onEndCallback();
|
||||
this.processSpeakQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the next item in the speak queue
|
||||
* @private
|
||||
*/
|
||||
processSpeakQueue() {
|
||||
if (this.speakQueue.length === 0) {
|
||||
this.isSpeakingFromQueue = false;
|
||||
this.speaking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip processing if we're already speaking (prevent overlapping sentences)
|
||||
if (this.synth.speaking) {
|
||||
setTimeout(() => this.processSpeakQueue(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSpeakingFromQueue = true;
|
||||
const queueItem = this.speakQueue.shift();
|
||||
|
||||
console.log(`Speaking queue item (${this.speakQueue.length} remaining):`, queueItem.text.substring(0, 30) + "...");
|
||||
|
||||
this.speakUtterance(queueItem.text, queueItem.callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak the provided text by queueing sentences
|
||||
* @param {string} text - Text to be spoken
|
||||
* @param {function} onEndCallback - Callback when all speech ends
|
||||
*/
|
||||
speak(text, onEndCallback = null) {
|
||||
if (!this.synth || !this.enabled || !text) {
|
||||
if (onEndCallback) onEndCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't attempt to speak if there's been a permission error
|
||||
if (this.permissionError) {
|
||||
console.warn("Not attempting to speak due to permission error");
|
||||
if (onEndCallback) onEndCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't attempt to speak without user activation
|
||||
if (!this.hasUserActivation) {
|
||||
console.warn("Not attempting to speak because there hasn't been user interaction yet");
|
||||
if (onEndCallback) onEndCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Process text for better speech
|
||||
const processedText = this.processTextForSpeech(text);
|
||||
console.log("TTS attempting to speak:", processedText.substring(0, 50) + "...");
|
||||
|
||||
// Stop any existing speech
|
||||
this.stop();
|
||||
|
||||
// Split into sentences for better handling
|
||||
const sentences = this.splitIntoSentences(processedText);
|
||||
|
||||
// Last sentence gets the callback
|
||||
for (let i = 0; i < sentences.length; i++) {
|
||||
this.speakQueue.push({
|
||||
text: sentences[i],
|
||||
callback: i === sentences.length - 1 ? onEndCallback : null
|
||||
});
|
||||
}
|
||||
|
||||
// Start processing the queue if not already processing
|
||||
if (!this.isSpeakingFromQueue) {
|
||||
this.processSpeakQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the current speech
|
||||
*/
|
||||
pause() {
|
||||
if (!this.synth || !this.speaking) return;
|
||||
|
||||
this.synth.pause();
|
||||
this.paused = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume paused speech
|
||||
*/
|
||||
resume() {
|
||||
if (!this.synth || !this.paused) return;
|
||||
|
||||
this.synth.resume();
|
||||
this.paused = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the current speech
|
||||
*/
|
||||
stop() {
|
||||
if (!this.synth) return;
|
||||
|
||||
// Set flag to indicate this is an intentional stop before canceling
|
||||
this.intentionalStop = true;
|
||||
|
||||
// Cancel any current speech synthesis
|
||||
this.synth.cancel();
|
||||
|
||||
// Reset state
|
||||
this.speaking = false;
|
||||
this.paused = false;
|
||||
this.utterance = null;
|
||||
this.speakQueue = [];
|
||||
this.isSpeakingFromQueue = false;
|
||||
|
||||
// Reset the intentional stop flag after a short delay
|
||||
setTimeout(() => {
|
||||
this.intentionalStop = false;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TTS is currently active/enabled
|
||||
*/
|
||||
isEnabled() {
|
||||
return this.enabled && !this.permissionError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if speech is currently in progress
|
||||
*/
|
||||
isSpeaking() {
|
||||
return this.speaking;
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const browserTtsHandler = new TTSHandler();
|
||||
/**
|
||||
* TTS Handler Base Class
|
||||
* Abstract base class defining the interface for all TTS handlers
|
||||
*/
|
||||
export class TTSHandler {
|
||||
constructor() {
|
||||
this.voiceOptions = {};
|
||||
this.isReady = false;
|
||||
|
||||
// Set up event dispatcher
|
||||
this.eventTarget = document.createElement('div');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get handler ID
|
||||
* @returns {string} - Handler identifier
|
||||
*/
|
||||
getId() {
|
||||
throw new Error('getId() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the TTS handler
|
||||
* @param {Function} progressCallback - Optional progress callback
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize(progressCallback = null) {
|
||||
throw new Error('initialize() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this TTS handler is available
|
||||
* @returns {boolean} - True if handler is ready to use
|
||||
*/
|
||||
isAvailable() {
|
||||
return this.isReady;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if voice is currently speaking
|
||||
* @returns {boolean} - True if speaking
|
||||
*/
|
||||
isSpeaking() {
|
||||
return false; // Default implementation
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text using this handler
|
||||
* @param {string} text - The text to speak
|
||||
* @param {Function} callback - Optional callback when speech completes
|
||||
*/
|
||||
speak(text, callback = null) {
|
||||
throw new Error('speak() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop speech
|
||||
*/
|
||||
stop() {
|
||||
throw new Error('stop() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options = {}) {
|
||||
// Default implementation merges options
|
||||
this.voiceOptions = { ...this.voiceOptions, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
* @returns {Promise<Array>} - Resolves with array of voice objects
|
||||
*/
|
||||
async getVoices() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a custom event
|
||||
* @param {string} eventName - Name of the event
|
||||
* @param {Object} detail - Event details
|
||||
*/
|
||||
dispatchEvent(eventName, detail = {}) {
|
||||
const event = new CustomEvent(eventName, {
|
||||
detail: { handlerId: this.getId(), ...detail },
|
||||
bubbles: true
|
||||
});
|
||||
this.eventTarget.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add event listener
|
||||
* @param {string} eventName - Name of the event
|
||||
* @param {Function} callback - Event handler function
|
||||
*/
|
||||
addEventListener(eventName, callback) {
|
||||
this.eventTarget.addEventListener(eventName, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove event listener
|
||||
* @param {string} eventName - Name of the event
|
||||
* @param {Function} callback - Event handler function
|
||||
*/
|
||||
removeEventListener(eventName, callback) {
|
||||
this.eventTarget.removeEventListener(eventName, callback);
|
||||
}
|
||||
}
|
||||
|
||||
+238
-106
@@ -1,137 +1,269 @@
|
||||
/**
|
||||
* TTS Player Module
|
||||
* Manages text-to-speech playback integration with animation queue.
|
||||
* TTS Player Module for AI Interactive Fiction
|
||||
* Handles Text-to-Speech functionality with resource-aware loading and progress reporting
|
||||
*/
|
||||
export class TtsPlayer {
|
||||
/**
|
||||
* Create a new TtsPlayer
|
||||
* @param {Object} config - Configuration options
|
||||
* @param {string} config.apiKey - API key for TTS service (if applicable)
|
||||
* @param {Object} config.animationQueue - AnimationQueue instance for synchronization
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
this.animationQueue = config.animationQueue;
|
||||
this.ttsHandler = null;
|
||||
this.enabled = false; // Start with TTS disabled by default
|
||||
this.currentAudio = null;
|
||||
import { BaseModule, ModuleEvent } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
// Bind methods to ensure 'this' context
|
||||
this.speak = this.speak.bind(this);
|
||||
this.stop = this.stop.bind(this);
|
||||
class TTSPlayerModule extends BaseModule {
|
||||
constructor() {
|
||||
super('tts', 'Text-to-Speech');
|
||||
this.ttsFactory = null;
|
||||
this.isInitialized = false;
|
||||
this.kokoroLoadingPromise = null;
|
||||
this.kokoroLoadingStarted = false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the TTS handler
|
||||
* @param {Object} ttsHandler - The TTS handler instance
|
||||
* Load module dependencies
|
||||
* @returns {Promise} - Resolves when dependencies are loaded
|
||||
*/
|
||||
setTtsHandler(ttsHandler) {
|
||||
if (!ttsHandler) {
|
||||
console.warn("TtsPlayer: No valid TTS handler provided.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("TtsPlayer: Handler set to", ttsHandler.constructor.name);
|
||||
this.ttsHandler = ttsHandler;
|
||||
|
||||
// Make sure the window.ttsHandler is also set for global access
|
||||
if (!window.ttsHandler) {
|
||||
window.ttsHandler = ttsHandler;
|
||||
async loadDependencies() {
|
||||
try {
|
||||
// Import the TTS Factory module
|
||||
const { ttsFactory } = await import('./tts-factory.js');
|
||||
this.ttsFactory = ttsFactory;
|
||||
this.reportProgress(20, "TTS Factory loaded");
|
||||
|
||||
// Set up event listeners
|
||||
window.addEventListener('tts-ready', this.handleTTSReadyEvent.bind(this));
|
||||
|
||||
// Create a Promise that resolves when Kokoro is loaded
|
||||
this.kokoroLoadingPromise = new Promise(resolve => {
|
||||
// Listen for when Kokoro starts loading
|
||||
window.addEventListener('kokoro-loading-started', () => {
|
||||
this.kokoroLoadingStarted = true;
|
||||
this.reportProgress(50, "Loading Kokoro TTS");
|
||||
});
|
||||
|
||||
// Listen for when Kokoro completes loading
|
||||
window.addEventListener('kokoro-loading-complete', (event) => {
|
||||
// Check if loading was successful from the event details
|
||||
if (event.detail && event.detail.success === false) {
|
||||
this.reportProgress(95, "Kokoro TTS failed to load - using fallback");
|
||||
console.warn("Kokoro failed to load:", event.detail?.error || "unknown error");
|
||||
} else {
|
||||
this.reportProgress(95, "Kokoro TTS loaded");
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error loading TTS dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Enable or disable TTS
|
||||
* @param {boolean} enabled - Whether TTS should be enabled
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
setEnabled(enabled = true) {
|
||||
this.enabled = enabled;
|
||||
console.log(`TtsPlayer: TTS ${enabled ? 'enabled' : 'disabled'}`);
|
||||
|
||||
// If disabling while audio is playing, stop it
|
||||
if (!enabled && this.currentAudio) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
// Also set the handler's state if available
|
||||
if (this.ttsHandler && typeof this.ttsHandler.setEnabled === 'function') {
|
||||
this.ttsHandler.setEnabled(enabled);
|
||||
} else if (window.ttsHandler && typeof window.ttsHandler.setEnabled === 'function') {
|
||||
window.ttsHandler.setEnabled(enabled);
|
||||
async initialize() {
|
||||
try {
|
||||
// Initialize TTS Factory
|
||||
await this.ttsFactory.constructor.initializeInterface((percent, message) => {
|
||||
// Scale to 20-90% of our progress range
|
||||
const scaledPercent = 20 + (percent * 0.7);
|
||||
this.reportProgress(scaledPercent, message);
|
||||
});
|
||||
|
||||
// IMPORTANT: Always wait for Kokoro's loading promise to resolve
|
||||
this.reportProgress(90, "Waiting for Kokoro TTS to complete loading");
|
||||
|
||||
// Wait for the Kokoro loading promise to complete with a timeout
|
||||
try {
|
||||
// Add a timeout to prevent waiting forever
|
||||
const timeoutPromise = new Promise(resolve => setTimeout(() => {
|
||||
console.log("TTS Player: Kokoro loading timed out, continuing without Kokoro");
|
||||
resolve(false);
|
||||
}, 10000)); // 10 second timeout
|
||||
|
||||
// Race between normal completion and timeout
|
||||
await Promise.race([this.kokoroLoadingPromise, timeoutPromise]);
|
||||
|
||||
this.reportProgress(95, "Kokoro TTS loading completed or timed out");
|
||||
} catch (err) {
|
||||
console.warn("TTS Player: Error waiting for Kokoro:", err);
|
||||
this.reportProgress(95, "Error waiting for Kokoro, continuing anyway");
|
||||
}
|
||||
|
||||
this.isInitialized = true;
|
||||
|
||||
// Final status check
|
||||
const ttsInfo = this.ttsFactory.getActiveTTSInfo();
|
||||
if (ttsInfo.available) {
|
||||
this.reportProgress(100, `TTS Player initialized using ${ttsInfo.name}`);
|
||||
return true;
|
||||
} else {
|
||||
this.reportProgress(100, "TTS initialization complete but no voices available");
|
||||
return true; // Still consider this a success, just with no voices
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initializing TTS Player:", error);
|
||||
this.reportProgress(100, "TTS initialization failed, continuing without TTS");
|
||||
this.isInitialized = true; // Mark as initialized anyway to not block other modules
|
||||
return true; // Return true to not block the application
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Toggle TTS state
|
||||
* @returns {boolean} The new enabled state
|
||||
* Handle TTS ready event from the factory
|
||||
* @param {CustomEvent} event - The TTS ready event
|
||||
*/
|
||||
handleTTSReadyEvent(event) {
|
||||
const { available, type } = event.detail;
|
||||
|
||||
if (available && type) {
|
||||
this.reportProgress(95, `TTS system ready: ${type}`);
|
||||
} else {
|
||||
this.reportProgress(95, "No TTS system available");
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods
|
||||
|
||||
/**
|
||||
* Get information about the active TTS system
|
||||
* @returns {Object} - TTS system info
|
||||
*/
|
||||
getTTSInfo() {
|
||||
if (!this.ttsFactory) return { available: false, type: 'none', name: 'None' };
|
||||
return this.ttsFactory.getActiveTTSInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle TTS functionality on/off
|
||||
* @returns {boolean} - New TTS enabled state
|
||||
*/
|
||||
toggle() {
|
||||
this.setEnabled(!this.enabled);
|
||||
return this.enabled;
|
||||
if (!this.ttsFactory) return false;
|
||||
return this.ttsFactory.toggle();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if TTS is enabled
|
||||
* @returns {boolean} Whether TTS is enabled
|
||||
* Speak text using the active TTS system
|
||||
* @param {string} text - Text to speak
|
||||
* @param {Function} callback - Called when speech completes
|
||||
*/
|
||||
isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text
|
||||
* @param {string} text - The text to speak
|
||||
*/
|
||||
speak(text) {
|
||||
if (!this.enabled || !text) return;
|
||||
|
||||
// Stop any current speech
|
||||
this.stop();
|
||||
|
||||
console.log(`TtsPlayer: Speaking - "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// Try to use our handler first
|
||||
if (this.ttsHandler && typeof this.ttsHandler.speak === 'function') {
|
||||
this.ttsHandler.speak(text);
|
||||
}
|
||||
// Fall back to window.ttsHandler if available
|
||||
else if (window.ttsHandler && typeof window.ttsHandler.speak === 'function') {
|
||||
window.ttsHandler.speak(text);
|
||||
}
|
||||
else {
|
||||
console.warn("TtsPlayer: No TTS handler available to speak text");
|
||||
speak(text, callback) {
|
||||
if (!this.ttsFactory) {
|
||||
console.warn("TTS Factory not available for speak");
|
||||
if (callback) callback("TTS not available");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`TTS Player speaking: "${text}"`);
|
||||
this.ttsFactory.speak(text, (result) => {
|
||||
console.log("TTS Player speak complete", result);
|
||||
if (callback) callback(result);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Stop current speech
|
||||
* Stop any ongoing speech
|
||||
*/
|
||||
stop() {
|
||||
// Try to use our handler first
|
||||
if (this.ttsHandler && typeof this.ttsHandler.stop === 'function') {
|
||||
this.ttsHandler.stop();
|
||||
}
|
||||
// Fall back to window.ttsHandler if available
|
||||
else if (window.ttsHandler && typeof window.ttsHandler.stop === 'function') {
|
||||
window.ttsHandler.stop();
|
||||
if (this.ttsFactory) {
|
||||
this.ttsFactory.stop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fast forward current speech (may skip or speed up)
|
||||
* Set voice options for the active TTS system
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
fastForward() {
|
||||
// Try to use our handler first
|
||||
if (this.ttsHandler && typeof this.ttsHandler.fastForward === 'function') {
|
||||
this.ttsHandler.fastForward();
|
||||
setVoiceOptions(options) {
|
||||
if (this.ttsFactory) {
|
||||
this.ttsFactory.setVoiceOptions(options);
|
||||
}
|
||||
// Fall back to window.ttsHandler if available
|
||||
else if (window.ttsHandler && typeof window.ttsHandler.fastForward === 'function') {
|
||||
window.ttsHandler.fastForward();
|
||||
}
|
||||
// If no fastForward method, just stop the speech
|
||||
else {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set speech rate/speed
|
||||
* @param {number} speed - Speech rate (0.5-2.0)
|
||||
*/
|
||||
setSpeed(speed) {
|
||||
this.setVoiceOptions({ rate: speed });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the volume for speech
|
||||
* @param {number} volume - Volume level (0.0-1.0)
|
||||
*/
|
||||
setVolume(volume) {
|
||||
this.setVoiceOptions({ volume: volume });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the voice for speech
|
||||
* @param {string} voice - Voice identifier
|
||||
*/
|
||||
setVoice(voice) {
|
||||
this.setVoiceOptions({ voice: voice });
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a specific TTS system
|
||||
* @param {string} type - The TTS system to use ('kokoro', 'browser', or 'api')
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
switchTTS(type) {
|
||||
if (!this.ttsFactory) return false;
|
||||
const result = this.ttsFactory.switchTTS(type);
|
||||
|
||||
// If the switch was successful, refresh the voice list
|
||||
if (result) {
|
||||
// Notify listeners that the TTS system changed
|
||||
window.dispatchEvent(new CustomEvent('tts-system-changed', {
|
||||
detail: {
|
||||
type,
|
||||
info: this.getTTSInfo()
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available TTS systems
|
||||
* @returns {Array<string>} - Array of available TTS system IDs
|
||||
*/
|
||||
getAvailableSystems() {
|
||||
if (!this.ttsFactory) return [];
|
||||
const handlers = this.ttsFactory.getAvailableHandlers();
|
||||
return Object.keys(handlers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices for the active TTS system
|
||||
* @returns {Promise<Array>} - Array of voice objects
|
||||
*/
|
||||
async getVoices() {
|
||||
if (!this.ttsFactory) return [];
|
||||
return this.ttsFactory.getVoices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Is TTS enabled currently
|
||||
* @returns {boolean} - Whether TTS is enabled
|
||||
*/
|
||||
isEnabled() {
|
||||
if (!this.ttsFactory) return false;
|
||||
return this.ttsFactory.isEnabled();
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const TTSPlayer = new TTSPlayerModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(TTSPlayer);
|
||||
|
||||
// Export the module
|
||||
export { TTSPlayer };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.TTSPlayer = TTSPlayer;
|
||||
|
||||
+418
-414
@@ -1,441 +1,445 @@
|
||||
/**
|
||||
* UiController Module
|
||||
* Manages user interface interactions and updates UI elements.
|
||||
*/
|
||||
export class UiController {
|
||||
/**
|
||||
* Create a new UiController
|
||||
* @param {Object} config - Configuration options
|
||||
* @param {Object} config.animationQueue - The AnimationQueue instance
|
||||
* @param {Object} config.ttsPlayer - The TtsPlayer instance
|
||||
* @param {Object} config.inputHandler - The InputHandler instance
|
||||
* @param {Object} config.socketClient - The SocketClient instance (or rely on callbacks)
|
||||
* @param {HTMLElement} config.commandHistoryContainerElement - The command history container
|
||||
* @param {HTMLElement} config.storyContainerElement - The story container
|
||||
* @param {HTMLElement} config.speedSliderElement - The speed slider element
|
||||
* @param {HTMLElement} config.rewindButtonElement - The rewind button element
|
||||
* @param {HTMLElement} config.saveButtonElement - The save button element
|
||||
* @param {HTMLElement} config.loadButtonElement - The load button element
|
||||
* @param {HTMLElement} config.speechButtonElement - The speech button element
|
||||
* @param {HTMLElement} config.speedResetElement - The speed reset button element
|
||||
* @param {Object} config.translations - Translations object
|
||||
* @param {string} config.locale - Locale string
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
// Store dependencies
|
||||
this.animationQueue = config.animationQueue;
|
||||
this.ttsPlayer = config.ttsPlayer; // Handles enabling/disabling TTS via its own logic
|
||||
this.inputHandler = config.inputHandler; // Needed for focus, suggestions?
|
||||
this.socketClient = config.socketClient; // Direct access or use callbacks
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
import { ModuleEvent } from './base-module.js';
|
||||
|
||||
// Callbacks for actions (to be set by AnimatedFiction)
|
||||
this.onRestartRequest = null;
|
||||
this.onSaveRequest = null;
|
||||
this.onLoadRequest = null;
|
||||
|
||||
// Active TTS handler (set via setTtsHandler)
|
||||
class UIController extends BaseModule {
|
||||
constructor() {
|
||||
super('ui-controller');
|
||||
|
||||
// Declare dependencies on TTS, animation-queue, and our new UI modules
|
||||
this.dependencies = ['tts', 'animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects'];
|
||||
|
||||
// References to sub-modules
|
||||
this.displayHandler = null;
|
||||
this.inputHandler = null;
|
||||
this.effects = null;
|
||||
|
||||
// UI state
|
||||
this.isReady = false;
|
||||
this.isVisible = false;
|
||||
|
||||
// Book interface elements
|
||||
this.bookElement = null;
|
||||
this.leftPage = null;
|
||||
this.rightPage = null;
|
||||
this.storyElement = null;
|
||||
|
||||
// Additional module references
|
||||
this.textBuffer = null;
|
||||
this.ttsHandler = null;
|
||||
this.socketClient = null;
|
||||
this.animationQueue = null;
|
||||
|
||||
// Add TTS toggle state
|
||||
this.ttsEnabled = false;
|
||||
|
||||
// Bind methods that use 'this' internally or are used as callbacks/event handlers
|
||||
this.initialize = this.initialize.bind(this); // Bind initialize as it calls dispatchEvent
|
||||
this.handleCommand = this.handleCommand.bind(this); // Bind event handler
|
||||
this.displayText = this.displayText.bind(this); // Bind if passed as callback
|
||||
this.setupBookInterface = this.setupBookInterface.bind(this);
|
||||
this.applyBookSizing = this.applyBookSizing.bind(this);
|
||||
this.setupEventListeners = this.setupEventListeners.bind(this);
|
||||
this.setupMainUI = this.setupMainUI.bind(this);
|
||||
this.initializeTextBuffer = this.initializeTextBuffer.bind(this);
|
||||
this.showUI = this.showUI.bind(this);
|
||||
this.hideUI = this.hideUI.bind(this);
|
||||
this.clearDisplay = this.clearDisplay.bind(this);
|
||||
this.sendCommand = this.sendCommand.bind(this);
|
||||
this.updateButtonStates = this.updateButtonStates.bind(this);
|
||||
|
||||
// UI elements
|
||||
this.speedSlider = config.speedSliderElement || document.getElementById('speed');
|
||||
this.commandHistoryContainer = config.commandHistoryContainerElement; // Added
|
||||
this.storyContainer = config.storyContainerElement; // Added
|
||||
this.rewindButton = config.rewindButtonElement || document.getElementById('rewind');
|
||||
this.saveButton = config.saveButtonElement || document.getElementById('save');
|
||||
this.loadButton = config.loadButtonElement || document.getElementById('reload');
|
||||
this.speechButton = config.speechButtonElement || document.getElementById('speech');
|
||||
this.speedReset = config.speedResetElement || document.getElementById('speed_reset');
|
||||
|
||||
// Translations
|
||||
this.translations = config.translations || {};
|
||||
this.locale = config.locale || 'en-us';
|
||||
|
||||
// Initial UI state
|
||||
this.updateButtonStates({ started: false, canLoad: false }); // Start with buttons disabled
|
||||
this.updateSpeechButtonAvailability(false); // Start with speech disabled
|
||||
// Store a bound version of dispatchEvent for use in methods
|
||||
this._dispatchModuleEvent = (name, detail) => {
|
||||
document.dispatchEvent(new CustomEvent(name, {
|
||||
detail: { moduleId: this.id, ...detail },
|
||||
bubbles: true
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Speed slider
|
||||
if (this.speedSlider) {
|
||||
this.speedSlider.addEventListener('input', this.handleSpeedChange.bind(this));
|
||||
}
|
||||
async initialize() {
|
||||
this.reportProgress(0, 'Initializing UI Controller');
|
||||
|
||||
// Speed reset button
|
||||
if (this.speedReset) {
|
||||
this.speedReset.addEventListener('click', this.handleSpeedReset.bind(this));
|
||||
}
|
||||
|
||||
// Rewind button
|
||||
if (this.rewindButton) {
|
||||
this.rewindButton.addEventListener('click', this.handleRewindClick.bind(this));
|
||||
}
|
||||
|
||||
// Save button
|
||||
if (this.saveButton) {
|
||||
this.saveButton.addEventListener('click', this.handleSaveClick.bind(this));
|
||||
}
|
||||
|
||||
// Load button
|
||||
if (this.loadButton) {
|
||||
this.loadButton.addEventListener('click', this.handleLoadClick.bind(this));
|
||||
}
|
||||
|
||||
// Speech button
|
||||
if (this.speechButton) {
|
||||
this.speechButton.addEventListener('click', this.handleSpeechToggle.bind(this));
|
||||
}
|
||||
|
||||
// Fast forward (spacebar or click on right page)
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.code === 'Space') {
|
||||
this.handleFastForward();
|
||||
try {
|
||||
this.reportProgress(20, 'Setting up book interface');
|
||||
|
||||
// Set up book interface
|
||||
this.setupBookInterface();
|
||||
|
||||
this.reportProgress(30, 'Setting up UI components');
|
||||
|
||||
// Get module references
|
||||
this.displayHandler = moduleRegistry.getModule('ui-display-handler');
|
||||
this.inputHandler = moduleRegistry.getModule('ui-input-handler');
|
||||
this.effects = moduleRegistry.getModule('ui-effects');
|
||||
|
||||
// Get additional dependencies
|
||||
this.textBuffer = moduleRegistry.getModule('text-buffer');
|
||||
this.ttsHandler = moduleRegistry.getModule('tts');
|
||||
this.socketClient = moduleRegistry.getModule('socket-client');
|
||||
this.animationQueue = moduleRegistry.getModule('animation-queue');
|
||||
|
||||
if (!this.displayHandler || !this.inputHandler || !this.effects) {
|
||||
console.error('UI Controller: Required UI modules not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.reportProgress(50, 'Setting up event listeners');
|
||||
|
||||
// Set up event listeners between components
|
||||
this.setupEventListeners();
|
||||
|
||||
this.reportProgress(80, 'Finalizing UI initialization');
|
||||
|
||||
// Initialize main UI container
|
||||
await this.setupMainUI();
|
||||
|
||||
// Initialize text buffer handler
|
||||
this.initializeTextBuffer();
|
||||
|
||||
this.isReady = true;
|
||||
this.isVisible = true;
|
||||
this.reportProgress(100, 'UI Controller ready');
|
||||
|
||||
// Start ambient effects
|
||||
this.effects.startAmbientEffects();
|
||||
|
||||
// Use the DOM event API directly instead of this.dispatchEvent
|
||||
this._dispatchModuleEvent('ui:ready', { controller: this });
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error initializing UI Controller:', error);
|
||||
this.changeState('ERROR');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setupBookInterface() {
|
||||
// Create or get the book interface elements
|
||||
this.bookElement = document.getElementById('book');
|
||||
this.leftPage = document.getElementById('page_left');
|
||||
this.rightPage = document.getElementById('page_right');
|
||||
this.storyElement = document.getElementById('story');
|
||||
|
||||
// Apply book sizing based on viewport
|
||||
this.applyBookSizing();
|
||||
|
||||
// Set up window resize handler
|
||||
window.addEventListener('resize', () => this.applyBookSizing());
|
||||
}
|
||||
|
||||
applyBookSizing() {
|
||||
// Apply book sizing based on viewport dimensions
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const aspectRatio = viewportWidth / viewportHeight;
|
||||
|
||||
document.documentElement.style.setProperty('--viewport-aspect-ratio', aspectRatio);
|
||||
|
||||
const maxBookHeight = viewportHeight * 0.9;
|
||||
document.documentElement.style.setProperty('--book-height', `${maxBookHeight}px`);
|
||||
|
||||
const bookWidth = maxBookHeight * Math.min(aspectRatio, 1.613);
|
||||
document.documentElement.style.setProperty('--book-width', `${bookWidth}px`);
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Listen for command events from input handler - use arrow function to preserve context
|
||||
document.addEventListener('ui:command', (event) => {
|
||||
this.handleCommand(event.detail);
|
||||
});
|
||||
|
||||
document.getElementById('page_right')?.addEventListener('click', this.handleFastForward.bind(this));
|
||||
// Listen for text display events - use arrow function to preserve context
|
||||
document.addEventListener('ui:text:complete', () => {
|
||||
// Use the DOM event API directly
|
||||
this._dispatchModuleEvent('ui:ready:for:next', {});
|
||||
});
|
||||
|
||||
// Window resize
|
||||
window.addEventListener('resize', this.handleWindowResize.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle speed slider change
|
||||
* @param {Event} event - The input event
|
||||
*/
|
||||
handleSpeedChange(event) {
|
||||
if (!this.animationQueue) return;
|
||||
// Listen for socket connection events
|
||||
document.addEventListener('socket:connected', () => {
|
||||
console.log('UI Controller: Socket connected');
|
||||
});
|
||||
|
||||
const value = parseFloat(event.target.value);
|
||||
const speed = Math.pow(100.0 - value, 3) / 10000 * 10 + 0.01;
|
||||
this.animationQueue.setSpeed(speed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle speed reset button click
|
||||
*/
|
||||
handleSpeedReset() {
|
||||
if (!this.speedSlider || !this.animationQueue) return;
|
||||
document.addEventListener('socket:disconnected', () => {
|
||||
console.log('UI Controller: Socket disconnected');
|
||||
});
|
||||
|
||||
this.speedSlider.value = 50;
|
||||
const speed = Math.pow(100.0 - 50, 3) / 10000 * 10 + 0.01;
|
||||
this.animationQueue.setSpeed(speed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle rewind button click
|
||||
*/
|
||||
handleRewindClick() {
|
||||
if (this.rewindButton.getAttribute('disabled') === 'disabled') {
|
||||
return;
|
||||
// Handle speed reset
|
||||
const speedReset = document.getElementById('speed_reset');
|
||||
if (speedReset) {
|
||||
speedReset.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const speedSlider = document.getElementById('speed');
|
||||
if (speedSlider) {
|
||||
speedSlider.value = 50;
|
||||
if (this.animationQueue) {
|
||||
this.animationQueue.setSpeed(1.0);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Use localized confirm message if available
|
||||
const confirmMsg = this.translations[this.locale]?.confirm_restart || 'Are you sure you want to restart the game? All progress will be lost.';
|
||||
if (confirm(confirmMsg)) {
|
||||
if (this.onRestartRequest) {
|
||||
this.onRestartRequest();
|
||||
} else {
|
||||
console.warn("UiController: onRestartRequest callback not set.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle save button click
|
||||
*/
|
||||
handleSaveClick() {
|
||||
if (this.saveButton.getAttribute('disabled') === 'disabled') {
|
||||
return;
|
||||
}
|
||||
if (this.onSaveRequest) {
|
||||
this.onSaveRequest();
|
||||
} else {
|
||||
console.warn("UiController: onSaveRequest callback not set.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle load button click
|
||||
*/
|
||||
handleLoadClick() {
|
||||
if (this.loadButton.getAttribute('disabled') === 'disabled') {
|
||||
return;
|
||||
}
|
||||
if (this.onLoadRequest) {
|
||||
this.onLoadRequest();
|
||||
} else {
|
||||
console.warn("UiController: onLoadRequest callback not set.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle speech toggle button click
|
||||
*/
|
||||
handleSpeechToggle() {
|
||||
if (!this.ttsHandler) {
|
||||
console.warn("UiController: ttsHandler not set. Cannot toggle speech.");
|
||||
// Attempt to use ttsPlayer as fallback if needed, but prefer ttsHandler
|
||||
if (this.ttsPlayer && this.speechButton.getAttribute('disabled') !== 'disabled') {
|
||||
const enabled = this.ttsPlayer.toggle();
|
||||
this.updateSpeechButtonStyling(enabled);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.speechButton.getAttribute('disabled') === 'disabled') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure AudioContext is resumed on user interaction if using Kokoro
|
||||
if (window.ttsFactory && window.ttsFactory.usingKokoro && this.ttsHandler.audioContext && this.ttsHandler.audioContext.state === 'suspended') {
|
||||
this.ttsHandler.audioContext.resume().catch(err => console.error('Error resuming AudioContext on click:', err));
|
||||
}
|
||||
|
||||
// Set user activation flag for the handler
|
||||
this.ttsHandler.hasUserActivation = true;
|
||||
const enabled = this.ttsHandler.toggle();
|
||||
this.updateSpeechButtonStyling(enabled); // Update visual style
|
||||
|
||||
if (enabled) {
|
||||
// Speak the last narrative if speech was just enabled and story container is available
|
||||
if (this.storyContainer) {
|
||||
const lastNarrative = this.storyContainer.lastElementChild;
|
||||
if (lastNarrative && lastNarrative.classList.contains('narrative')) { // Check if it's narrative text
|
||||
console.log("Speaking last narrative on toggle");
|
||||
// Use a slight delay to ensure audio context is resumed
|
||||
setTimeout(() => this.ttsHandler.speak(lastNarrative.textContent), 50);
|
||||
|
||||
// Handle speed slider change for animation speed
|
||||
const speedSlider = document.getElementById('speed');
|
||||
if (speedSlider) {
|
||||
speedSlider.addEventListener('input', (e) => {
|
||||
if (this.animationQueue) {
|
||||
// Convert slider value (0-100) to animation speed
|
||||
// Using formula from Documentation.md: lower values = slower speed
|
||||
const value = parseInt(e.target.value);
|
||||
const speed = Math.pow(100.0 - value, 3) / 10000 * 10 + 0.01;
|
||||
this.animationQueue.setSpeed(speed);
|
||||
console.log(`UI Controller: Animation speed set to ${speed.toFixed(3)}`);
|
||||
|
||||
// Save to persistence manager if available
|
||||
if (window.PersistenceManager) {
|
||||
window.PersistenceManager.updatePreference('animation', 'speed', value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial speed from persistence manager if available
|
||||
if (window.PersistenceManager) {
|
||||
const savedSpeed = window.PersistenceManager.getPreference('animation', 'speed', 50);
|
||||
speedSlider.value = savedSpeed;
|
||||
// Apply initial speed
|
||||
if (this.animationQueue) {
|
||||
const speed = Math.pow(100.0 - savedSpeed, 3) / 10000 * 10 + 0.01;
|
||||
this.animationQueue.setSpeed(speed);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If disabling, ensure speech stops
|
||||
this.ttsHandler.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fast forward (spacebar or click)
|
||||
*/
|
||||
handleFastForward() {
|
||||
if (!this.animationQueue) return;
|
||||
|
||||
this.animationQueue.fastForward();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle window resize
|
||||
*/
|
||||
handleWindowResize() {
|
||||
this.updateBookDimensions();
|
||||
this.updateParagraphHeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active TTS handler.
|
||||
* @param {object} handler - The TTS handler instance (e.g., KokoroHandler, BrowserTtsHandler).
|
||||
*/
|
||||
setTtsHandler(handler) {
|
||||
this.ttsHandler = handler;
|
||||
console.log("UiController: TTS Handler set.", handler);
|
||||
// Update button state based on the new handler's status
|
||||
this.updateSpeechButtonStyling(this.ttsHandler ? this.ttsHandler.isEnabled() : false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the book dimensions based on viewport size
|
||||
*/
|
||||
updateBookDimensions() {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const viewportAspectRatio = vw / vh;
|
||||
const imageAspectRatio = 2727 / 1691;
|
||||
|
||||
let bookWidth, bookHeight;
|
||||
|
||||
if (viewportAspectRatio > imageAspectRatio) {
|
||||
bookWidth = vh * imageAspectRatio;
|
||||
bookHeight = vh;
|
||||
} else {
|
||||
bookWidth = vw;
|
||||
bookHeight = vw / imageAspectRatio;
|
||||
}
|
||||
|
||||
document.documentElement.style.setProperty('--book-width', `${bookWidth}px`);
|
||||
document.documentElement.style.setProperty('--book-height', `${bookHeight}px`);
|
||||
|
||||
// Setting a CSS variable that will be either vw or vh depending on the viewport aspect ratio
|
||||
document.documentElement.style.setProperty(
|
||||
"--viewport-dimension",
|
||||
viewportAspectRatio > imageAspectRatio ? 'vw' : 'vh'
|
||||
);
|
||||
|
||||
document.documentElement.style.setProperty('--viewport-aspect-ratio', viewportAspectRatio);
|
||||
|
||||
const story = document.getElementById("story");
|
||||
if (story) {
|
||||
const paddingTop = window.getComputedStyle(story).paddingTop;
|
||||
const paddingBottom = window.getComputedStyle(story).paddingBottom;
|
||||
document.documentElement.style.setProperty('--story-line-height', (story.clientHeight - paddingTop - paddingBottom) / 28);
|
||||
// Handle speech toggle with proper state management
|
||||
const speechToggle = document.getElementById('speech');
|
||||
if (speechToggle && this.ttsHandler) {
|
||||
// Remove disabled attribute to make it clickable
|
||||
speechToggle.removeAttribute('disabled');
|
||||
|
||||
speechToggle.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
console.log('Speech toggle clicked');
|
||||
|
||||
// Toggle TTS state
|
||||
if (this.ttsHandler && typeof this.ttsHandler.toggle === 'function') {
|
||||
this.ttsEnabled = this.ttsHandler.toggle();
|
||||
|
||||
// Update button text
|
||||
speechToggle.textContent = this.ttsEnabled ? 'mute' : 'speech';
|
||||
|
||||
// Save preference if persistence manager is available
|
||||
const persistenceManager = moduleRegistry.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
|
||||
}
|
||||
|
||||
console.log(`UI Controller: TTS ${this.ttsEnabled ? 'enabled' : 'disabled'}`);
|
||||
} else {
|
||||
console.warn('TTS Handler does not have toggle method');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update paragraph heights based on viewport
|
||||
*/
|
||||
updateParagraphHeight() {
|
||||
document.querySelectorAll("#story p").forEach((element) => {
|
||||
if (element.dataset.vpc) {
|
||||
const pHeight = parseFloat(window.getComputedStyle(document.getElementById('page_right')).height);
|
||||
const newHeight = pHeight * element.dataset.vpc / 100 + 'px';
|
||||
element.style.height = newHeight;
|
||||
|
||||
// Add options button to controls section
|
||||
const controlsSection = document.getElementById('controls');
|
||||
if (controlsSection) {
|
||||
// Check if options button already exists
|
||||
if (!document.getElementById('options-button')) {
|
||||
const optionsButton = document.createElement('a');
|
||||
optionsButton.id = 'options-button';
|
||||
optionsButton.href = '#';
|
||||
optionsButton.textContent = 'options';
|
||||
optionsButton.title = 'Show game options';
|
||||
|
||||
// Add event listener
|
||||
optionsButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const optionsUI = moduleRegistry.getModule('options-ui');
|
||||
if (optionsUI && optionsUI.toggle) {
|
||||
optionsUI.toggle();
|
||||
}
|
||||
});
|
||||
|
||||
// Add to controls
|
||||
controlsSection.appendChild(document.createTextNode(' | '));
|
||||
controlsSection.appendChild(optionsButton);
|
||||
}
|
||||
}
|
||||
|
||||
// Enable all controls buttons
|
||||
const controlButtons = document.querySelectorAll('#controls a');
|
||||
controlButtons.forEach(button => {
|
||||
button.removeAttribute('disabled');
|
||||
});
|
||||
|
||||
// Book click for fast-forwarding - make sure it triggers the animation queue
|
||||
if (this.bookElement) {
|
||||
this.bookElement.addEventListener('click', (event) => {
|
||||
// Only if not clicking on a link or control
|
||||
if (event.target.tagName !== 'A' &&
|
||||
!event.target.closest('#controls') &&
|
||||
!event.target.closest('#command_input')) {
|
||||
if (this.animationQueue) {
|
||||
console.log('UI Controller: Fast-forwarding animations');
|
||||
this.animationQueue.fastForward();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Space key for fast-forwarding
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ' &&
|
||||
document.activeElement.tagName !== 'TEXTAREA' &&
|
||||
document.activeElement.tagName !== 'INPUT') {
|
||||
if (this.animationQueue) {
|
||||
console.log('UI Controller: Fast-forwarding animations (space key)');
|
||||
this.animationQueue.fastForward();
|
||||
e.preventDefault(); // Prevent page scrolling
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the speech button styling based on enabled state.
|
||||
* @param {boolean} enabled - Whether speech is enabled.
|
||||
*/
|
||||
updateSpeechButtonStyling(enabled = false) {
|
||||
if (!this.speechButton) return;
|
||||
|
||||
if (enabled) {
|
||||
this.speechButton.style.fontWeight = 'bold';
|
||||
this.speechButton.style.color = '#000';
|
||||
this.speechButton.style.backgroundColor = '#eee';
|
||||
} else {
|
||||
this.speechButton.style.fontWeight = 'normal';
|
||||
this.speechButton.style.color = '#333';
|
||||
this.speechButton.style.backgroundColor = '';
|
||||
|
||||
async setupMainUI() {
|
||||
// Ensure all UI components exist
|
||||
if (!this.bookElement || !this.leftPage || !this.rightPage || !this.storyElement) {
|
||||
console.log('UI Controller: Creating missing UI elements');
|
||||
this.displayHandler.setupBookStructure();
|
||||
|
||||
// Re-get elements
|
||||
this.bookElement = document.getElementById('book');
|
||||
this.leftPage = document.getElementById('page_left');
|
||||
this.rightPage = document.getElementById('page_right');
|
||||
this.storyElement = document.getElementById('story');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the enabled/disabled state and title of the speech button.
|
||||
* @param {boolean} available - Whether any TTS system is available.
|
||||
* @param {string} [type] - The type of TTS system available ('kokoro', 'browser', etc.).
|
||||
*/
|
||||
updateSpeechButtonAvailability(available, type) {
|
||||
if (!this.speechButton) return;
|
||||
|
||||
if (available) {
|
||||
this.speechButton.removeAttribute('disabled');
|
||||
const ttsName = type === 'kokoro' ? 'Kokoro TTS' : (type === 'browser' ? 'Browser TTS' : 'TTS');
|
||||
const title = this.translations[this.locale]?.title_speech || `Toggle Text-to-Speech (${ttsName})`;
|
||||
this.speechButton.setAttribute('title', title);
|
||||
// Update style based on current handler state if available
|
||||
this.updateSpeechButtonStyling(this.ttsHandler ? this.ttsHandler.isEnabled() : false);
|
||||
} else {
|
||||
this.speechButton.setAttribute('disabled', 'disabled');
|
||||
const title = this.translations[this.locale]?.title_speech_unavailable || 'Text-to-Speech not available';
|
||||
this.speechButton.setAttribute('title', title);
|
||||
this.updateSpeechButtonStyling(false); // Ensure style is off
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the enabled/disabled state of control buttons based on game state.
|
||||
* @param {object} gameState - The current game state from AnimatedFiction.
|
||||
* @param {boolean} gameState.started - Whether the game has started.
|
||||
* @param {boolean} [gameState.canLoad] - Whether a saved game exists to be loaded.
|
||||
*/
|
||||
updateButtonStates(gameState) {
|
||||
if (this.rewindButton) {
|
||||
if (gameState.started) {
|
||||
this.rewindButton.removeAttribute('disabled');
|
||||
} else {
|
||||
this.rewindButton.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
}
|
||||
if (this.saveButton) {
|
||||
if (gameState.started) {
|
||||
this.saveButton.removeAttribute('disabled');
|
||||
} else {
|
||||
this.saveButton.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
}
|
||||
if (this.loadButton) {
|
||||
// Enable load button if a save exists (indicated by canLoad flag or similar)
|
||||
// We might need a more robust way to check for saved state existence.
|
||||
// For now, enable if game started OR if canLoad is explicitly true.
|
||||
if (gameState.started || gameState.canLoad) {
|
||||
this.loadButton.removeAttribute('disabled');
|
||||
} else {
|
||||
this.loadButton.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
}
|
||||
// Speech button availability is handled separately by updateSpeechButtonAvailability
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the visual display of the speed slider.
|
||||
* @param {number} value - The speed value (0-100).
|
||||
*/
|
||||
updateSpeedDisplay(value) {
|
||||
if (this.speedSlider) {
|
||||
this.speedSlider.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an element after a delay (Helper, potentially move elsewhere or keep if used)
|
||||
* @param {number} delay - The delay in milliseconds
|
||||
* @param {HTMLElement} target - The target element to append to
|
||||
* @param {HTMLElement} el - The element to insert
|
||||
* @param {boolean} fadeIn - Whether to fade in the element
|
||||
*/
|
||||
insertAfter(delay, target, el, fadeIn = true) {
|
||||
if (this.animationQueue) {
|
||||
if (fadeIn) {
|
||||
el.classList.add("fade-in");
|
||||
this.animationQueue.schedule(function() {
|
||||
target.appendChild(el);
|
||||
}, delay);
|
||||
} else {
|
||||
this.animationQueue.schedule(function() {
|
||||
target.appendChild(el);
|
||||
}, delay);
|
||||
}
|
||||
} else {
|
||||
// Fallback if no animation queue
|
||||
if (fadeIn) {
|
||||
el.classList.add("fade-in");
|
||||
setTimeout(() => {
|
||||
target.appendChild(el);
|
||||
}, delay);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
target.appendChild(el);
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the locale for translations
|
||||
* @param {string} locale - The locale code
|
||||
*/
|
||||
setLocale(locale) {
|
||||
this.locale = locale;
|
||||
|
||||
if (this.translations[locale]) {
|
||||
Object.keys(this.translations[locale]).forEach(key => {
|
||||
const prefix = key.substring(0, 5);
|
||||
const postfix = key.substring(6, key.length);
|
||||
const elements = document.querySelectorAll(`.l10n-${(prefix === 'title' ? postfix : key)}`);
|
||||
|
||||
elements.forEach(element => {
|
||||
if (prefix === "title") {
|
||||
element.title = this.translations[locale][key];
|
||||
} else {
|
||||
element.innerHTML = this.translations[locale][key];
|
||||
}
|
||||
});
|
||||
|
||||
initializeTextBuffer() {
|
||||
// Initialize text buffer handling
|
||||
if (this.textBuffer) {
|
||||
this.textBuffer.setOnSentenceReady((text, callback) => {
|
||||
console.log('UI Controller: Displaying sentence');
|
||||
this.displayText(text).then(callback);
|
||||
});
|
||||
} else {
|
||||
console.error(`Locale ${locale} is not defined`);
|
||||
}
|
||||
}
|
||||
|
||||
handleCommand(command) {
|
||||
// Route commands to appropriate handlers
|
||||
switch (command.type) {
|
||||
case 'display':
|
||||
this.displayHandler.processCommand(command);
|
||||
break;
|
||||
case 'effect':
|
||||
this.effects.processCommand(command);
|
||||
break;
|
||||
case 'continue':
|
||||
if (this.animationQueue) {
|
||||
this.animationQueue.fastForward();
|
||||
}
|
||||
break;
|
||||
case 'input':
|
||||
if (this.socketClient) {
|
||||
this.socketClient.sendCommand(command.text);
|
||||
}
|
||||
break;
|
||||
case 'menu':
|
||||
// Toggle options menu
|
||||
const optionsUI = moduleRegistry.getModule('options-ui');
|
||||
if (optionsUI) {
|
||||
optionsUI.toggle();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Handle general UI commands or pass to game logic
|
||||
this._dispatchModuleEvent('ui:command', command);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI button states based on game state
|
||||
* @param {Object} state - Game state information
|
||||
*/
|
||||
updateButtonStates(state = {}) {
|
||||
const { canSave, canLoad, canRestart } = state;
|
||||
|
||||
// Get button elements
|
||||
const saveButton = document.getElementById('save');
|
||||
const loadButton = document.getElementById('reload');
|
||||
const restartButton = document.getElementById('rewind');
|
||||
|
||||
// Update save button state
|
||||
if (saveButton) {
|
||||
if (canSave) {
|
||||
saveButton.removeAttribute('disabled');
|
||||
} else {
|
||||
saveButton.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Update load button state
|
||||
if (loadButton) {
|
||||
if (canLoad) {
|
||||
loadButton.removeAttribute('disabled');
|
||||
} else {
|
||||
loadButton.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Update restart button state
|
||||
if (restartButton) {
|
||||
if (canRestart) {
|
||||
restartButton.removeAttribute('disabled');
|
||||
} else {
|
||||
restartButton.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods
|
||||
|
||||
showUI() {
|
||||
if (!this.isVisible) {
|
||||
this.isVisible = true;
|
||||
this.displayHandler.show();
|
||||
this.effects.startAmbientEffects();
|
||||
}
|
||||
}
|
||||
|
||||
hideUI() {
|
||||
if (this.isVisible) {
|
||||
this.isVisible = false;
|
||||
this.displayHandler.hide();
|
||||
this.effects.stopAmbientEffects();
|
||||
}
|
||||
}
|
||||
|
||||
displayText(text, options = {}) {
|
||||
return this.displayHandler.displayText(text, options);
|
||||
}
|
||||
|
||||
clearDisplay() {
|
||||
this.displayHandler.clear();
|
||||
}
|
||||
|
||||
sendCommand(command) {
|
||||
if (this.socketClient) {
|
||||
return this.socketClient.sendCommand(command);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const uiController = new UIController();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(uiController);
|
||||
|
||||
// Export the module
|
||||
export { uiController as UIController };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.UIController = uiController;
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
import { ModuleEvent } from './base-module.js';
|
||||
|
||||
class UIDisplayHandler extends BaseModule {
|
||||
constructor() {
|
||||
super('ui-display-handler');
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = ['animation-queue', 'tts', 'text-processor', 'paragraph-layout'];
|
||||
|
||||
// Display state
|
||||
this.container = null;
|
||||
this.textBuffer = [];
|
||||
this.currentAnimation = null;
|
||||
this.textElements = [];
|
||||
this.maxParagraphs = 5; // Number of paragraphs to keep in view
|
||||
|
||||
// Required module references
|
||||
this.animationQueue = null;
|
||||
this.tts = null;
|
||||
this.textProcessor = null;
|
||||
this.paragraphLayout = null;
|
||||
|
||||
// Formatting settings
|
||||
this.formatting = {
|
||||
fontSize: '1.1rem',
|
||||
lineHeight: '1.5',
|
||||
paragraphSpacing: '1.2rem'
|
||||
};
|
||||
|
||||
// Resources to preload
|
||||
this.cssPath = '/css/style.css';
|
||||
this.imagesToPreload = [
|
||||
'/images/book-3057904.png',
|
||||
'/images/brown-wooden-flooring.jpg'
|
||||
];
|
||||
|
||||
// Bind methods used as event handlers or passed as callbacks
|
||||
this.handleAnimationEnd = this.handleAnimationEnd.bind(this);
|
||||
this.displayText = this.displayText.bind(this);
|
||||
this.measureText = this.measureText.bind(this);
|
||||
this.typesetParagraph = this.typesetParagraph.bind(this);
|
||||
|
||||
// Store a bound version of dispatchEvent for use in methods
|
||||
this._dispatchModuleEvent = (name, detail) => {
|
||||
document.dispatchEvent(new CustomEvent(name, {
|
||||
detail: { moduleId: this.id, ...detail },
|
||||
bubbles: true
|
||||
}));
|
||||
};
|
||||
|
||||
// Add flag to track if we're currently animating text
|
||||
this.isAnimating = false;
|
||||
|
||||
console.log('UIDisplayHandler: Constructor initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load dependencies and resources
|
||||
* @returns {Promise<boolean>} - Resolves when dependencies are loaded
|
||||
*/
|
||||
async loadDependencies() {
|
||||
try {
|
||||
this.reportProgress(10, "Loading CSS stylesheets");
|
||||
|
||||
// Load CSS file
|
||||
await this.loadCSS(this.cssPath);
|
||||
this.reportProgress(30, "CSS loaded successfully");
|
||||
|
||||
// Preload images
|
||||
this.reportProgress(40, "Preloading UI images");
|
||||
await this.preloadImages(this.imagesToPreload);
|
||||
this.reportProgress(80, "UI images preloaded");
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error loading UI display resources:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load CSS file asynchronously and wait for it to be applied
|
||||
* @param {string} cssPath - Path to CSS file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
loadCSS(cssPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if the stylesheet is already loaded
|
||||
const existingLinks = document.querySelectorAll('link[rel="stylesheet"]');
|
||||
for (const link of existingLinks) {
|
||||
if (link.href.includes(cssPath)) {
|
||||
console.log(`UIDisplayHandler: CSS ${cssPath} already loaded`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create link element
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = cssPath;
|
||||
|
||||
// Set up load and error handlers
|
||||
link.onload = () => {
|
||||
console.log(`UIDisplayHandler: CSS ${cssPath} loaded successfully`);
|
||||
|
||||
// Give a small delay for the CSS to be applied
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
link.onerror = () => {
|
||||
const error = new Error(`Failed to load CSS: ${cssPath}`);
|
||||
console.error(error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
// Append to document head
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload images
|
||||
* @param {Array<string>} imagePaths - Array of image paths to preload
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
preloadImages(imagePaths) {
|
||||
return new Promise((resolve) => {
|
||||
if (!imagePaths || imagePaths.length === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let loaded = 0;
|
||||
const totalImages = imagePaths.length;
|
||||
|
||||
const checkAllLoaded = () => {
|
||||
loaded++;
|
||||
|
||||
// Update progress proportionally
|
||||
const percent = Math.round((loaded / totalImages) * 100);
|
||||
this.reportProgress(40 + percent * 0.4, `Preloaded ${loaded}/${totalImages} images`);
|
||||
|
||||
if (loaded === totalImages) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
// Preload each image
|
||||
imagePaths.forEach(path => {
|
||||
const img = new Image();
|
||||
img.onload = checkAllLoaded;
|
||||
img.onerror = () => {
|
||||
console.warn(`UIDisplayHandler: Failed to preload image: ${path}`);
|
||||
checkAllLoaded();
|
||||
};
|
||||
img.src = path;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.reportProgress(0, 'Initializing UI Display Handler');
|
||||
|
||||
try {
|
||||
this.reportProgress(20, 'Setting up display container');
|
||||
|
||||
// Create book structure first
|
||||
this.setupBookStructure();
|
||||
|
||||
// Create or get the text display container
|
||||
this.container = document.getElementById('story') || this.createDisplayContainer();
|
||||
|
||||
this.reportProgress(40, 'Configuring display settings');
|
||||
|
||||
// Apply initial formatting
|
||||
this.applyFormatting();
|
||||
|
||||
this.reportProgress(60, 'Setting up animation and text processing dependencies');
|
||||
|
||||
// Get references to required modules
|
||||
this.animationQueue = moduleRegistry.getModule('animation-queue');
|
||||
this.tts = moduleRegistry.getModule('tts');
|
||||
this.textProcessor = moduleRegistry.getModule('text-processor');
|
||||
this.paragraphLayout = moduleRegistry.getModule('paragraph-layout');
|
||||
|
||||
// Set up our text measuring function for the paragraph layout
|
||||
if (this.paragraphLayout) {
|
||||
this.paragraphLayout.setMeasureFunction(this.measureText);
|
||||
}
|
||||
|
||||
// Check if we have all required modules
|
||||
if (!this.animationQueue) {
|
||||
console.error('UIDisplayHandler: animation-queue module not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.textProcessor) {
|
||||
console.warn('UIDisplayHandler: text-processor module not found, text will not be formatted properly');
|
||||
}
|
||||
|
||||
if (!this.paragraphLayout) {
|
||||
console.warn('UIDisplayHandler: paragraph-layout module not found, text will not be justified properly');
|
||||
}
|
||||
|
||||
// Set up event listeners for animation sync
|
||||
this.setupEventListeners();
|
||||
|
||||
this.reportProgress(100, 'UI Display Handler ready');
|
||||
|
||||
// Notify that display handler is ready
|
||||
this._dispatchModuleEvent('ui:display:ready', {});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error initializing UI Display Handler:', error);
|
||||
this.changeState('ERROR');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setupBookStructure() {
|
||||
// Create book structure based on reference HTML
|
||||
const book = document.getElementById('book') || this.createBookElement();
|
||||
|
||||
// Create page_left if it doesn't exist
|
||||
const pageLeft = document.getElementById('page_left') ||
|
||||
this.createElement('div', 'page_left', book);
|
||||
|
||||
// Create page_right if it doesn't exist
|
||||
const pageRight = document.getElementById('page_right') ||
|
||||
this.createElement('div', 'page_right', book);
|
||||
|
||||
// Create header in page_left if needed
|
||||
let header = pageLeft.querySelector('.header');
|
||||
if (!header) {
|
||||
header = this.createElement('div', null, pageLeft, 'header');
|
||||
|
||||
// Add header content
|
||||
const byline = this.createElement('h2', null, header, 'byline l10n-by');
|
||||
byline.textContent = 'powered by Generative AI';
|
||||
|
||||
const title = this.createElement('h1', null, header, 'title');
|
||||
title.textContent = 'AI Interactive Fiction';
|
||||
|
||||
const subtitle = this.createElement('h3', null, header, 'subtitle');
|
||||
subtitle.textContent = 'An open-world text adventure';
|
||||
|
||||
const separator = this.createElement('div', null, header, 'separator');
|
||||
const double = this.createElement('double', null, separator);
|
||||
double.textContent = '❦';
|
||||
}
|
||||
|
||||
// Create controls if needed
|
||||
if (!document.getElementById('controls')) {
|
||||
const controls = this.createElement('div', 'controls', pageLeft, 'buttons');
|
||||
|
||||
// Add speech toggle
|
||||
const speechLink = this.createElement('a', 'speech', controls, 'l10n-speech');
|
||||
speechLink.title = 'Toggle text to speech';
|
||||
speechLink.disabled = 'disabled';
|
||||
speechLink.textContent = 'speech';
|
||||
|
||||
// Add speed control
|
||||
const speedSpan = this.createElement('span', null, controls);
|
||||
const speedReset = this.createElement('a', 'speed_reset', speedSpan);
|
||||
const speedSpanInner = this.createElement('span', null, speedReset, 'l10n-speed');
|
||||
speedSpanInner.innerHTML = 'speed<sup>*</sup>';
|
||||
|
||||
const speedInput = document.createElement('input');
|
||||
speedInput.type = 'range';
|
||||
speedInput.min = '0';
|
||||
speedInput.max = '100';
|
||||
speedInput.value = '50';
|
||||
speedInput.id = 'speed';
|
||||
speedInput.name = 'speed';
|
||||
speedSpan.appendChild(speedInput);
|
||||
|
||||
// Add restart button
|
||||
const restartLink = this.createElement('a', 'rewind', controls, 'l10n-restart');
|
||||
restartLink.title = 'Restart story from beginning';
|
||||
restartLink.disabled = 'disabled';
|
||||
restartLink.textContent = 'restart';
|
||||
|
||||
// Add save button
|
||||
const saveLink = this.createElement('a', 'save', controls, 'l10n-save');
|
||||
saveLink.title = 'Save progress';
|
||||
saveLink.textContent = 'save';
|
||||
|
||||
// Add load button
|
||||
const loadLink = this.createElement('a', 'reload', controls, 'l10n-load');
|
||||
loadLink.title = 'Reload from save point';
|
||||
loadLink.disabled = 'disabled';
|
||||
loadLink.textContent = 'load';
|
||||
}
|
||||
|
||||
// Create remark section if needed
|
||||
if (!document.getElementById('remark')) {
|
||||
const remark = this.createElement('div', 'remark', pageLeft, 'l10n-remark');
|
||||
remark.innerHTML = '<i><sup>*</sup>click on page or press spacebar to fast forward text animation</i>';
|
||||
}
|
||||
|
||||
// Create story container in page_right if needed
|
||||
if (!document.getElementById('story')) {
|
||||
const story = this.createElement('div', 'story', pageRight, 'container');
|
||||
}
|
||||
|
||||
// Create lighting element if needed
|
||||
if (!document.getElementById('lighting')) {
|
||||
const lighting = this.createElement('div', 'lighting', document.body);
|
||||
}
|
||||
|
||||
// Create ruler and indent elements if needed
|
||||
if (!document.getElementById('ruler')) {
|
||||
this.createElement('div', 'ruler', document.body);
|
||||
}
|
||||
|
||||
if (!document.getElementById('indent')) {
|
||||
const indent = this.createElement('div', 'indent', document.body, 'l10n-prompt');
|
||||
indent.textContent = 'What do you want to do next?';
|
||||
}
|
||||
}
|
||||
|
||||
createBookElement() {
|
||||
const book = this.createElement('div', 'book', document.body);
|
||||
return book;
|
||||
}
|
||||
|
||||
createElement(tagName, id, parent, className) {
|
||||
const element = document.createElement(tagName);
|
||||
if (id) element.id = id;
|
||||
if (className) element.className = className;
|
||||
if (parent) parent.appendChild(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
createDisplayContainer() {
|
||||
const storyContainer = document.getElementById('story');
|
||||
if (storyContainer) return storyContainer;
|
||||
|
||||
// If not found, create necessary structure
|
||||
const book = document.getElementById('book') || this.createBookElement();
|
||||
const pageRight = document.getElementById('page_right') ||
|
||||
this.createElement('div', 'page_right', book);
|
||||
|
||||
// Create story container
|
||||
return this.createElement('div', 'story', pageRight, 'container');
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Use the bound method directly as the listener
|
||||
document.addEventListener('animationend', this.handleAnimationEnd);
|
||||
}
|
||||
|
||||
handleAnimationEnd(event) {
|
||||
// Check if the event target is a story paragraph before proceeding
|
||||
if (!event.target.classList.contains('story-paragraph')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paragraph = event.target;
|
||||
paragraph.classList.remove('fade-in');
|
||||
|
||||
// Notify that text display is complete
|
||||
this._dispatchModuleEvent('ui:text:complete', {});
|
||||
}
|
||||
|
||||
applyFormatting() {
|
||||
if (this.container) {
|
||||
this.container.style.fontSize = this.formatting.fontSize;
|
||||
this.container.style.lineHeight = this.formatting.lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure text width for paragraph layout
|
||||
* @param {string} text - Text to measure
|
||||
* @param {string} [style] - Optional CSS style
|
||||
* @returns {number} - Text width in pixels
|
||||
*/
|
||||
measureText(text, style = '') {
|
||||
// Create a temporary span for text measurement
|
||||
const ruler = document.getElementById('ruler') || this.createRuler();
|
||||
|
||||
// Apply any custom style if provided
|
||||
if (style) {
|
||||
ruler.style.cssText = style;
|
||||
}
|
||||
|
||||
// Set text and measure
|
||||
ruler.textContent = text;
|
||||
return ruler.offsetWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ruler element for text measurement
|
||||
* @returns {HTMLElement} - The ruler element
|
||||
*/
|
||||
createRuler() {
|
||||
const ruler = document.createElement('div');
|
||||
ruler.id = 'ruler';
|
||||
ruler.style.position = 'absolute';
|
||||
ruler.style.visibility = 'hidden';
|
||||
ruler.style.whiteSpace = 'nowrap';
|
||||
ruler.style.font = window.getComputedStyle(this.container || document.body).font;
|
||||
document.body.appendChild(ruler);
|
||||
return ruler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typeset a paragraph based on calculated line breaks
|
||||
* @param {Object} paragraphData - Line breaking data from ParagraphLayout
|
||||
* @param {number} delay - Initial delay for animation
|
||||
* @param {Array} measures - Line width measurements
|
||||
* @returns {Array} - [Paragraph element, final delay]
|
||||
*/
|
||||
typesetParagraph(paragraphData, delay = 0, measures = []) {
|
||||
// Create paragraph element
|
||||
const p = document.createElement('p');
|
||||
p.className = 'story-paragraph';
|
||||
|
||||
// Set up initial styling
|
||||
p.style.position = 'relative';
|
||||
p.style.width = '100%';
|
||||
|
||||
let lineHeight = parseInt(this.formatting.lineHeight) || 1.5;
|
||||
let lineTop = 0;
|
||||
|
||||
// Iterate through lines from paragraph_data.breaks
|
||||
for(let i = 1; i < paragraphData.breaks.length; i++) {
|
||||
// Get the current line (from the previous break position to the current break position)
|
||||
let lineStart = paragraphData.breaks[i-1].position;
|
||||
let lineEnd = paragraphData.breaks[i].position;
|
||||
|
||||
// Process each node (word, space, tag) within the line
|
||||
for(let j = lineStart; j <= lineEnd; j++) {
|
||||
const node = paragraphData.nodes[j];
|
||||
|
||||
if (!node || !node.type) continue; // Skip invalid nodes
|
||||
|
||||
// Handle different node types
|
||||
if (node.type === 'box' || node.type === 'tag') {
|
||||
// Create span for word or tag
|
||||
const span = document.createElement('span');
|
||||
span.style.position = 'absolute';
|
||||
span.style.left = `${node.left || 0}px`;
|
||||
span.style.top = `${lineTop}px`;
|
||||
span.style.opacity = '0'; // Start invisible for fade-in
|
||||
|
||||
// Set content based on node type
|
||||
if (node.type === 'box') {
|
||||
span.textContent = node.value;
|
||||
} else if (node.type === 'tag') {
|
||||
// Handle HTML tags (e.g., <b>, <i>, etc.)
|
||||
span.innerHTML = node.value;
|
||||
}
|
||||
|
||||
// Add to paragraph
|
||||
p.appendChild(span);
|
||||
|
||||
// Schedule animation using AnimationQueue
|
||||
if (this.animationQueue) {
|
||||
const wordLength = node.value ? node.value.length : 1;
|
||||
this.animationQueue.schedule(() => {
|
||||
span.style.opacity = '1'; // Fade in
|
||||
span.classList.add('animated');
|
||||
}, delay);
|
||||
|
||||
// Calculate delay for next element based on word length
|
||||
delay += (wordLength * 50); // Adjust timing as needed
|
||||
} else {
|
||||
// Without animation queue, make visible immediately
|
||||
span.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
// Glue (spaces) don't need visible elements
|
||||
}
|
||||
|
||||
// Update line top position for next line
|
||||
lineTop += lineHeight * 16; // Assuming 1em = 16px, adjust based on font size
|
||||
}
|
||||
|
||||
// Set paragraph height based on final line position
|
||||
p.style.height = `${lineTop + lineHeight}px`;
|
||||
|
||||
return [p, delay];
|
||||
}
|
||||
|
||||
/**
|
||||
* Display text with formatting, animation, and optional TTS
|
||||
* @param {string} text - Text to display
|
||||
* @param {Object} options - Display options
|
||||
* @returns {Promise} - Resolves when text display is complete
|
||||
*/
|
||||
async displayText(text, options = {}) {
|
||||
if (!this.container || !text) return false;
|
||||
|
||||
console.log(`UIDisplayHandler: Processing text for display: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// Set animating flag
|
||||
this.isAnimating = true;
|
||||
|
||||
// Process text
|
||||
let processedText = text;
|
||||
if (this.textProcessor) {
|
||||
try {
|
||||
processedText = this.textProcessor.process(text, true);
|
||||
console.log('UIDisplayHandler: Text processed with typography enhancements');
|
||||
} catch (error) {
|
||||
console.error('Error processing text:', error);
|
||||
// Continue with unprocessed text
|
||||
}
|
||||
}
|
||||
|
||||
// Create a simple paragraph to display the text
|
||||
const paragraph = document.createElement('p');
|
||||
paragraph.className = 'story-paragraph fade-in';
|
||||
paragraph.textContent = processedText;
|
||||
|
||||
// Apply any custom styling from options
|
||||
if (options.style && paragraph) {
|
||||
Object.assign(paragraph.style, options.style);
|
||||
}
|
||||
|
||||
// Add to DOM
|
||||
this.container.appendChild(paragraph);
|
||||
this.textElements.push(paragraph);
|
||||
|
||||
// Limit the number of paragraphs
|
||||
this.limitParagraphs();
|
||||
|
||||
// Scroll to the new paragraph
|
||||
this.scrollToBottom();
|
||||
|
||||
// If TTS is available and enabled, speak the text
|
||||
if (this.tts) {
|
||||
console.log('UIDisplayHandler: Starting TTS playback');
|
||||
this.tts.speak(text);
|
||||
}
|
||||
|
||||
// Return a promise that resolves when animation is complete
|
||||
return new Promise(resolve => {
|
||||
// Use a simple timeout for animation completion
|
||||
setTimeout(() => {
|
||||
console.log('UIDisplayHandler: Text animation complete');
|
||||
this.isAnimating = false;
|
||||
|
||||
// Dispatch text complete event
|
||||
document.dispatchEvent(new CustomEvent('ui:text:complete', {
|
||||
detail: { moduleId: this.id }
|
||||
}));
|
||||
|
||||
resolve();
|
||||
}, 1000); // Default animation time
|
||||
});
|
||||
}
|
||||
|
||||
limitParagraphs() {
|
||||
while (this.textElements.length > this.maxParagraphs) {
|
||||
const oldestElement = this.textElements.shift();
|
||||
if (oldestElement && oldestElement.parentElement) {
|
||||
oldestElement.parentElement.removeChild(oldestElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
if (this.container) {
|
||||
this.container.scrollTop = this.container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
this.textElements = [];
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
if (this.container) {
|
||||
this.container.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this.container) {
|
||||
this.container.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
processCommand(command) {
|
||||
switch (command.action) {
|
||||
case 'display':
|
||||
this.displayText(command.text, command.options);
|
||||
break;
|
||||
case 'clear':
|
||||
this.clear();
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown display command: ${command.action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const uiDisplayHandler = new UIDisplayHandler();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(uiDisplayHandler);
|
||||
|
||||
// Export the module
|
||||
export { uiDisplayHandler as UIDisplayHandler };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
console.log('UIDisplayHandler: Registering with window');
|
||||
window.UIDisplayHandler = uiDisplayHandler;
|
||||
@@ -0,0 +1,319 @@
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
import { ModuleEvent } from './base-module.js';
|
||||
|
||||
class UIEffects extends BaseModule {
|
||||
constructor() {
|
||||
super('ui-effects');
|
||||
|
||||
// No external dependencies
|
||||
this.dependencies = [];
|
||||
|
||||
// Effects state
|
||||
this.activeEffects = new Map();
|
||||
this.ambientEffectsActive = false;
|
||||
|
||||
// Effects configuration
|
||||
this.effectsConfig = {
|
||||
candleFlicker: {
|
||||
intensity: 0.5,
|
||||
speed: 0.8
|
||||
},
|
||||
textShadow: {
|
||||
enabled: true,
|
||||
color: 'rgba(0, 0, 0, 0.5)'
|
||||
},
|
||||
backgroundEffects: {
|
||||
enabled: true
|
||||
}
|
||||
};
|
||||
|
||||
// Bind methods that use 'this' internally or are used as callbacks/event handlers
|
||||
this.initialize = this.initialize.bind(this); // Bind initialize as it calls dispatchEvent
|
||||
this.updateCandleEffect = this.updateCandleEffect.bind(this); // Used with requestAnimationFrame
|
||||
this.setupEffectElements = this.setupEffectElements.bind(this);
|
||||
this.createEffectsOverlay = this.createEffectsOverlay.bind(this);
|
||||
this.createCandleEffect = this.createCandleEffect.bind(this);
|
||||
this.createLightingElement = this.createLightingElement.bind(this);
|
||||
this.setupAmbientEffects = this.setupAmbientEffects.bind(this);
|
||||
this.setupCandleFlickerEffect = this.setupCandleFlickerEffect.bind(this);
|
||||
this.startAmbientEffects = this.startAmbientEffects.bind(this);
|
||||
this.stopAmbientEffects = this.stopAmbientEffects.bind(this);
|
||||
this.applyEffect = this.applyEffect.bind(this);
|
||||
this.applyShakeEffect = this.applyShakeEffect.bind(this);
|
||||
this.applyFlashEffect = this.applyFlashEffect.bind(this);
|
||||
this.applyTextEmphasis = this.applyTextEmphasis.bind(this);
|
||||
this.processCommand = this.processCommand.bind(this);
|
||||
|
||||
// Store a bound version of dispatchEvent for use in methods
|
||||
this._dispatchModuleEvent = (name, detail) => {
|
||||
document.dispatchEvent(new CustomEvent(name, {
|
||||
detail: { moduleId: this.id, ...detail },
|
||||
bubbles: true
|
||||
}));
|
||||
};
|
||||
|
||||
console.log('UIEffects: Constructor initialized');
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.reportProgress(0, 'Initializing UI Effects');
|
||||
|
||||
try {
|
||||
this.reportProgress(30, 'Setting up effect elements');
|
||||
|
||||
// Create or get effect elements
|
||||
this.setupEffectElements();
|
||||
|
||||
this.reportProgress(60, 'Preparing ambient effects');
|
||||
|
||||
// Set up ambient effect animations
|
||||
this.setupAmbientEffects();
|
||||
|
||||
this.reportProgress(100, 'UI Effects ready');
|
||||
|
||||
// Use the DOM event API directly instead of this.dispatchEvent
|
||||
this._dispatchModuleEvent('ui:effects:ready', {});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error initializing UI Effects:', error);
|
||||
this.changeState('ERROR');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setupEffectElements() {
|
||||
console.log('UIEffects: Setting up effect elements');
|
||||
// Create overlay for effects if it doesn't exist
|
||||
this.effectsOverlay = document.getElementById('effects-overlay') || this.createEffectsOverlay();
|
||||
|
||||
// Create candle effect element
|
||||
this.candleEffectElement = document.getElementById('candle-effect') || this.createCandleEffect();
|
||||
|
||||
// Ensure lighting element exists
|
||||
this.lightingElement = document.getElementById('lighting') || this.createLightingElement();
|
||||
}
|
||||
|
||||
createEffectsOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'effects-overlay';
|
||||
overlay.className = 'effects-overlay';
|
||||
document.body.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
createCandleEffect() {
|
||||
const candleEffect = document.createElement('div');
|
||||
candleEffect.id = 'candle-effect';
|
||||
candleEffect.className = 'candle-effect';
|
||||
if (this.effectsOverlay) {
|
||||
this.effectsOverlay.appendChild(candleEffect);
|
||||
} else {
|
||||
document.body.appendChild(candleEffect);
|
||||
}
|
||||
return candleEffect;
|
||||
}
|
||||
|
||||
createLightingElement() {
|
||||
const lighting = document.createElement('div');
|
||||
lighting.id = 'lighting';
|
||||
document.body.appendChild(lighting);
|
||||
return lighting;
|
||||
}
|
||||
|
||||
setupAmbientEffects() {
|
||||
// Initialize candle flicker effect
|
||||
if (this.candleEffectElement && this.effectsConfig.candleFlicker.enabled !== false) {
|
||||
this.setupCandleFlickerEffect();
|
||||
}
|
||||
}
|
||||
|
||||
setupCandleFlickerEffect() {
|
||||
// Store the animation frame ID for later cancellation
|
||||
this.candleAnimationId = null;
|
||||
}
|
||||
|
||||
updateCandleEffect() {
|
||||
if (!this.candleEffectElement || !this.ambientEffectsActive) return;
|
||||
|
||||
const { intensity, speed } = this.effectsConfig.candleFlicker;
|
||||
|
||||
// Create subtle random flickering effect
|
||||
const flickerAmount = Math.random() * intensity;
|
||||
const opacity = 0.2 + flickerAmount * 0.2;
|
||||
|
||||
this.candleEffectElement.style.opacity = opacity;
|
||||
|
||||
// Schedule next update
|
||||
this.candleAnimationId = requestAnimationFrame(this.updateCandleEffect);
|
||||
}
|
||||
|
||||
// Public methods
|
||||
|
||||
startAmbientEffects() {
|
||||
if (this.ambientEffectsActive) return;
|
||||
|
||||
this.ambientEffectsActive = true;
|
||||
|
||||
// Start candle flicker
|
||||
if (this.candleEffectElement) {
|
||||
this.updateCandleEffect();
|
||||
}
|
||||
|
||||
// Apply lighting animation
|
||||
if (this.lightingElement) {
|
||||
this.lightingElement.style.animation = 'gradient-animation-shrink 2s infinite alternate';
|
||||
}
|
||||
}
|
||||
|
||||
stopAmbientEffects() {
|
||||
this.ambientEffectsActive = false;
|
||||
|
||||
// Stop candle flicker
|
||||
if (this.candleAnimationId) {
|
||||
cancelAnimationFrame(this.candleAnimationId);
|
||||
this.candleAnimationId = null;
|
||||
}
|
||||
|
||||
// Stop lighting animation
|
||||
if (this.lightingElement) {
|
||||
this.lightingElement.style.animation = '';
|
||||
}
|
||||
}
|
||||
|
||||
applyEffect(effectName, options = {}) {
|
||||
switch (effectName) {
|
||||
case 'shake':
|
||||
return this.applyShakeEffect(options);
|
||||
case 'flash':
|
||||
return this.applyFlashEffect(options);
|
||||
case 'emphasis':
|
||||
return this.applyTextEmphasis(options.text, options);
|
||||
default:
|
||||
console.warn(`Unknown effect: ${effectName}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
applyShakeEffect(options = {}) {
|
||||
const target = options.target || document.getElementById('book');
|
||||
if (!target) return null;
|
||||
|
||||
const intensity = options.intensity || 'medium';
|
||||
const duration = options.duration || 500;
|
||||
|
||||
// Store original styles
|
||||
const originalTransition = target.style.transition;
|
||||
const originalTransform = target.style.transform;
|
||||
|
||||
// Apply the shake animation
|
||||
target.style.transition = `transform ${duration}ms ease`;
|
||||
target.style.transform = 'translate(0, 0)';
|
||||
target.style.animation = `shake ${duration}ms 1`;
|
||||
|
||||
// Return to normal after animation
|
||||
const effectId = setTimeout(() => {
|
||||
target.style.transition = originalTransition;
|
||||
target.style.transform = originalTransform;
|
||||
target.style.animation = '';
|
||||
this.activeEffects.delete(effectId);
|
||||
}, duration);
|
||||
|
||||
this.activeEffects.set(effectId, { name: 'shake', target });
|
||||
return effectId;
|
||||
}
|
||||
|
||||
applyFlashEffect(options = {}) {
|
||||
const color = options.color || 'white';
|
||||
const duration = options.duration || 300;
|
||||
|
||||
// Create flash overlay if not exists
|
||||
let flashOverlay = document.getElementById('flash-overlay');
|
||||
if (!flashOverlay) {
|
||||
flashOverlay = document.createElement('div');
|
||||
flashOverlay.id = 'flash-overlay';
|
||||
flashOverlay.style.position = 'fixed';
|
||||
flashOverlay.style.top = '0';
|
||||
flashOverlay.style.left = '0';
|
||||
flashOverlay.style.width = '100%';
|
||||
flashOverlay.style.height = '100%';
|
||||
flashOverlay.style.pointerEvents = 'none';
|
||||
flashOverlay.style.zIndex = '9999';
|
||||
flashOverlay.style.opacity = '0';
|
||||
flashOverlay.style.transition = `opacity ${duration / 2}ms ease-in-out`;
|
||||
document.body.appendChild(flashOverlay);
|
||||
}
|
||||
|
||||
// Set color and make visible
|
||||
flashOverlay.style.backgroundColor = color;
|
||||
|
||||
// Start animation
|
||||
setTimeout(() => { flashOverlay.style.opacity = '0.8'; }, 10);
|
||||
|
||||
const effectId = setTimeout(() => {
|
||||
flashOverlay.style.opacity = '0';
|
||||
this.activeEffects.delete(effectId);
|
||||
|
||||
// Remove element after fade out
|
||||
setTimeout(() => {
|
||||
if (flashOverlay.parentNode) {
|
||||
flashOverlay.parentNode.removeChild(flashOverlay);
|
||||
}
|
||||
}, duration / 2);
|
||||
}, duration / 2);
|
||||
|
||||
this.activeEffects.set(effectId, { name: 'flash' });
|
||||
return effectId;
|
||||
}
|
||||
|
||||
applyTextEmphasis(text, options = {}) {
|
||||
// Use existing display handler to show emphasized text
|
||||
const displayHandler = moduleRegistry.getModule('ui-display-handler');
|
||||
if (!displayHandler) return null;
|
||||
|
||||
const style = {
|
||||
fontWeight: 'bold',
|
||||
color: options.color || '#990000',
|
||||
fontSize: options.size || '1.2em'
|
||||
};
|
||||
|
||||
return displayHandler.displayText(text, { style, speak: true });
|
||||
}
|
||||
|
||||
processCommand(command) {
|
||||
switch (command.action) {
|
||||
case 'apply':
|
||||
return this.applyEffect(command.effect, command.options);
|
||||
case 'cancel':
|
||||
const effectId = command.effectId;
|
||||
if (this.activeEffects.has(effectId)) {
|
||||
clearTimeout(effectId);
|
||||
this.activeEffects.delete(effectId);
|
||||
}
|
||||
break;
|
||||
case 'ambient':
|
||||
if (command.state === 'start') {
|
||||
this.startAmbientEffects();
|
||||
} else if (command.state === 'stop') {
|
||||
this.stopAmbientEffects();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown effect command: ${command.action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const uiEffects = new UIEffects();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(uiEffects);
|
||||
|
||||
// Export the module
|
||||
export { uiEffects as UIEffects };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
console.log('UIEffects: Registering with window');
|
||||
window.UIEffects = uiEffects;
|
||||
@@ -0,0 +1,449 @@
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
import { ModuleEvent } from './base-module.js';
|
||||
|
||||
class UIInputHandler extends BaseModule {
|
||||
constructor() {
|
||||
super('ui-input-handler');
|
||||
|
||||
// Explicitly declare ui-display-handler as a dependency
|
||||
this.dependencies = ['ui-display-handler'];
|
||||
|
||||
// Reference to display handler
|
||||
this.displayHandler = null;
|
||||
|
||||
// Input elements
|
||||
this.inputArea = null;
|
||||
this.playerInput = null;
|
||||
this.cursor = null;
|
||||
this.commandHistoryElement = null; // Changed: renamed to avoid conflict
|
||||
|
||||
// Input state
|
||||
this.inputEnabled = true;
|
||||
this.historyIndex = -1;
|
||||
this.commandHistory = []; // Now this is clearly the array of previous commands
|
||||
this.inputBuffer = '';
|
||||
|
||||
// Add this method to properly dispatch custom events
|
||||
this._dispatchModuleEvent = (name, detail) => {
|
||||
document.dispatchEvent(new CustomEvent(name, {
|
||||
detail: { moduleId: this.id, ...detail },
|
||||
bubbles: true
|
||||
}));
|
||||
};
|
||||
|
||||
// Bind method contexts
|
||||
this.setupInputElements = this.setupInputElements.bind(this);
|
||||
this.handlePlayerInput = this.handlePlayerInput.bind(this);
|
||||
this.handleInputKeyDown = this.handleInputKeyDown.bind(this);
|
||||
this.positionCursor = this.positionCursor.bind(this);
|
||||
this.handleKeyboardInput = this.handleKeyboardInput.bind(this);
|
||||
|
||||
console.log('UIInputHandler: Constructor initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for dependencies before initializing
|
||||
* This ensures displayHandler is ready before we try to use it
|
||||
*/
|
||||
async waitForDependencies() {
|
||||
try {
|
||||
// Explicitly wait for the display handler to be ready
|
||||
console.log('UIInputHandler: Waiting for display handler to be ready');
|
||||
|
||||
// Get reference to the display handler
|
||||
this.displayHandler = moduleRegistry.getModule('ui-display-handler');
|
||||
|
||||
if (!this.displayHandler) {
|
||||
console.error('UIInputHandler: Display handler dependency not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait for display handler to reach FINISHED state
|
||||
const displayHandlerReady = await moduleRegistry.waitForModule('ui-display-handler');
|
||||
if (!displayHandlerReady) {
|
||||
console.error('UIInputHandler: Display handler not ready after waiting');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('UIInputHandler: Display handler is ready');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('UIInputHandler: Error waiting for dependencies:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize input handler
|
||||
*/
|
||||
async initialize() {
|
||||
this.reportProgress(0, 'Initializing UI Input Handler');
|
||||
|
||||
try {
|
||||
// Double-check display handler reference
|
||||
if (!this.displayHandler) {
|
||||
this.displayHandler = moduleRegistry.getModule('ui-display-handler');
|
||||
|
||||
if (!this.displayHandler) {
|
||||
console.error('UIInputHandler: Display handler still not available');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.reportProgress(30, 'Setting up keyboard listeners');
|
||||
|
||||
// Set up keyboard event listeners
|
||||
document.addEventListener('keydown', (event) => {
|
||||
this.handleKeyboardInput(event);
|
||||
});
|
||||
|
||||
this.reportProgress(60, 'Setting up input elements');
|
||||
|
||||
// Set up input elements
|
||||
this.setupInputElements();
|
||||
|
||||
this.reportProgress(100, 'UI Input Handler ready');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error initializing UI Input Handler:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard shortcuts and input globally
|
||||
* @param {KeyboardEvent} event - The keyboard event
|
||||
*/
|
||||
handleKeyboardInput(event) {
|
||||
// Handle global keyboard shortcuts here
|
||||
// This is different from the input field's specific key handling
|
||||
|
||||
// For example: Escape key to blur the input
|
||||
if (event.key === 'Escape') {
|
||||
if (document.activeElement === this.playerInput) {
|
||||
this.playerInput.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupInputElements() {
|
||||
console.log("UIInputHandler: Setting up input elements in document flow");
|
||||
|
||||
// Find the left page - this is created by the display handler
|
||||
const pageLeft = document.getElementById('page_left');
|
||||
if (!pageLeft) {
|
||||
console.error('UIInputHandler: Left page not found, cannot create input elements');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only create choices container if it doesn't already exist
|
||||
let choicesContainer = document.getElementById('choices');
|
||||
if (!choicesContainer) {
|
||||
choicesContainer = document.createElement('div');
|
||||
choicesContainer.id = 'choices';
|
||||
choicesContainer.className = 'container';
|
||||
|
||||
// Use natural document flow, not absolute positioning
|
||||
// Do NOT add a separator here, as it already exists in the CSS
|
||||
|
||||
pageLeft.appendChild(choicesContainer);
|
||||
}
|
||||
|
||||
// Create command history container if needed
|
||||
let commandHistory = document.getElementById('command_history');
|
||||
if (!commandHistory) {
|
||||
commandHistory = document.createElement('div');
|
||||
commandHistory.id = 'command_history';
|
||||
choicesContainer.appendChild(commandHistory);
|
||||
this.commandHistoryElement = commandHistory; // Changed: store in renamed property
|
||||
} else {
|
||||
this.commandHistoryElement = commandHistory; // Changed: store in renamed property
|
||||
}
|
||||
|
||||
// Create input container if needed
|
||||
let commandInput = document.getElementById('command_input');
|
||||
if (!commandInput) {
|
||||
commandInput = document.createElement('div');
|
||||
commandInput.id = 'command_input';
|
||||
choicesContainer.appendChild(commandInput);
|
||||
}
|
||||
|
||||
// Create input wrapper if needed
|
||||
let inputWrapper = commandInput.querySelector('.input-wrapper');
|
||||
if (!inputWrapper) {
|
||||
inputWrapper = document.createElement('div');
|
||||
inputWrapper.className = 'input-wrapper';
|
||||
commandInput.appendChild(inputWrapper);
|
||||
}
|
||||
|
||||
// Create the textarea if needed
|
||||
let playerInput = document.getElementById('player_input');
|
||||
if (!playerInput) {
|
||||
playerInput = document.createElement('textarea');
|
||||
playerInput.id = 'player_input';
|
||||
playerInput.rows = 1;
|
||||
playerInput.placeholder = 'What will you do?';
|
||||
playerInput.setAttribute('autocomplete', 'off');
|
||||
playerInput.setAttribute('spellcheck', 'true');
|
||||
|
||||
// Fix horizontal scrolling by ensuring the textbox wraps text
|
||||
playerInput.style.overflowX = 'hidden';
|
||||
playerInput.style.wordWrap = 'break-word';
|
||||
playerInput.style.whiteSpace = 'pre-wrap';
|
||||
|
||||
inputWrapper.appendChild(playerInput);
|
||||
this.playerInput = playerInput;
|
||||
}
|
||||
|
||||
// Create the cursor if needed
|
||||
let cursor = document.getElementById('cursor');
|
||||
if (!cursor) {
|
||||
cursor = document.createElement('span');
|
||||
cursor.id = 'cursor';
|
||||
inputWrapper.appendChild(cursor);
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
// Set up input event handlers
|
||||
if (playerInput) {
|
||||
playerInput.addEventListener('input', this.handlePlayerInput);
|
||||
playerInput.addEventListener('keydown', this.handleInputKeyDown);
|
||||
|
||||
// Auto-resize input field
|
||||
playerInput.addEventListener('input', () => {
|
||||
playerInput.style.height = 'auto';
|
||||
playerInput.style.height = playerInput.scrollHeight + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
// Position the cursor
|
||||
if (playerInput && cursor) {
|
||||
this.positionCursor(playerInput, cursor);
|
||||
|
||||
// Focus the input to let user start typing immediately
|
||||
setTimeout(() => {
|
||||
playerInput.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
console.log('UIInputHandler: Input elements setup complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle player input changes
|
||||
* @param {Event} e - Input event
|
||||
*/
|
||||
handlePlayerInput(e) {
|
||||
if (!this.playerInput) return;
|
||||
|
||||
// Auto-resize the input field based on content
|
||||
this.playerInput.style.height = 'auto';
|
||||
this.playerInput.style.height = `${this.playerInput.scrollHeight}px`;
|
||||
|
||||
// Update the cursor position with the current input text
|
||||
if (this.cursor) {
|
||||
this.positionCursor(this.playerInput, this.cursor);
|
||||
}
|
||||
|
||||
// Dispatch event using the properly defined method
|
||||
this._dispatchModuleEvent('ui:input:change', {
|
||||
text: this.playerInput.value
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle key down events in the input field
|
||||
* @param {KeyboardEvent} e - Keyboard event
|
||||
*/
|
||||
handleInputKeyDown(e) {
|
||||
if (!this.playerInput) return;
|
||||
|
||||
// Check for Enter key
|
||||
if (e.key === 'Enter') {
|
||||
if (!e.shiftKey) {
|
||||
// Prevent default (new line) if not holding shift
|
||||
e.preventDefault();
|
||||
|
||||
// Submit command
|
||||
this.submitCommand();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the current input as a command
|
||||
*/
|
||||
submitCommand() {
|
||||
if (!this.playerInput || !this.playerInput.value.trim()) return;
|
||||
|
||||
const command = this.playerInput.value.trim();
|
||||
console.log(`UIInputHandler: Submitting command: "${command}"`);
|
||||
|
||||
// Add command to history
|
||||
this.addToHistory(command);
|
||||
|
||||
// Dispatch command event
|
||||
this._dispatchModuleEvent('ui:command', {
|
||||
type: 'input',
|
||||
text: command
|
||||
});
|
||||
|
||||
// Clear input field
|
||||
this.playerInput.value = '';
|
||||
this.playerInput.style.height = 'auto';
|
||||
|
||||
// Update cursor position
|
||||
if (this.cursor) {
|
||||
this.positionCursor(this.playerInput, this.cursor);
|
||||
}
|
||||
|
||||
// Focus input field
|
||||
this.playerInput.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add command to history
|
||||
* @param {string} command - Command to add to history
|
||||
*/
|
||||
addToHistory(command) {
|
||||
// Add to history array
|
||||
this.commandHistory.push(command);
|
||||
|
||||
// Limit history size
|
||||
if (this.commandHistory.length > 50) {
|
||||
this.commandHistory.shift();
|
||||
}
|
||||
|
||||
// Reset history index
|
||||
this.historyIndex = -1;
|
||||
|
||||
// Update visual history if element exists
|
||||
if (this.commandHistoryElement && this.commandHistoryElement.appendChild) {
|
||||
const historyItem = document.createElement('div');
|
||||
historyItem.className = 'history-item';
|
||||
historyItem.textContent = `> ${command}`;
|
||||
this.commandHistoryElement.appendChild(historyItem);
|
||||
|
||||
// Limit visible history items
|
||||
while (this.commandHistoryElement.childElementCount > 10) {
|
||||
this.commandHistoryElement.removeChild(this.commandHistoryElement.firstChild);
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
this.commandHistoryElement.scrollTop = this.commandHistoryElement.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the cursor position to the start.
|
||||
*/
|
||||
resetCursorPosition() {
|
||||
if (this.cursor) {
|
||||
this.cursor.style.left = '0px';
|
||||
// Adjust top based on computed style padding or a default
|
||||
const computedStyle = window.getComputedStyle(this.playerInput);
|
||||
const paddingTop = parseFloat(computedStyle.paddingTop) || 6;
|
||||
this.cursor.style.top = `${paddingTop}px`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Position cursor based on input text position
|
||||
* @param {HTMLTextAreaElement} inputElement - The input element
|
||||
* @param {HTMLElement} cursorElement - The visual cursor element
|
||||
*/
|
||||
positionCursor(inputElement, cursorElement) {
|
||||
if (!inputElement || !cursorElement) return;
|
||||
|
||||
this.cursor = cursorElement;
|
||||
this.playerInput = inputElement;
|
||||
const updatePosition = () => {
|
||||
try {
|
||||
const input = this.playerInput;
|
||||
const cursor = this.cursor;
|
||||
const caretPosition = input.selectionStart || 0;
|
||||
const inputText = input.value;
|
||||
|
||||
// If no text, position cursor at the beginning based on padding
|
||||
if (inputText.length === 0 && caretPosition === 0) {
|
||||
this.resetCursorPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a temporary measurement div
|
||||
const div = document.createElement('div');
|
||||
const style = getComputedStyle(input);
|
||||
|
||||
// Apply relevant styles from the textarea to the div
|
||||
div.style.position = 'absolute';
|
||||
div.style.top = '-9999px';
|
||||
div.style.left = '-9999px';
|
||||
div.style.width = style.width;
|
||||
div.style.height = 'auto';
|
||||
div.style.padding = style.padding;
|
||||
div.style.border = style.border;
|
||||
div.style.fontFamily = style.fontFamily;
|
||||
div.style.fontSize = style.fontSize;
|
||||
div.style.fontWeight = style.fontWeight;
|
||||
div.style.lineHeight = style.lineHeight;
|
||||
div.style.whiteSpace = 'pre-wrap';
|
||||
div.style.wordWrap = 'break-word';
|
||||
div.style.boxSizing = style.boxSizing;
|
||||
|
||||
// Create spans for text before and after the caret, and a marker span
|
||||
const preCaretText = document.createTextNode(inputText.substring(0, caretPosition));
|
||||
const caretMarker = document.createElement('span');
|
||||
caretMarker.innerHTML = ' '; // Use non-breaking space for measurement
|
||||
const postCaretText = document.createTextNode(inputText.substring(caretPosition));
|
||||
|
||||
// Append spans to the div
|
||||
div.appendChild(preCaretText);
|
||||
div.appendChild(caretMarker);
|
||||
div.appendChild(postCaretText);
|
||||
|
||||
// Append div to body for measurement
|
||||
document.body.appendChild(div);
|
||||
|
||||
// Get position relative to the div's content box
|
||||
const markerRect = caretMarker.getBoundingClientRect();
|
||||
const divRect = div.getBoundingClientRect();
|
||||
|
||||
// Calculate position relative to the input's top-left, considering scroll
|
||||
const cursorLeft = markerRect.left - divRect.left;
|
||||
const cursorTop = markerRect.top - divRect.top - input.scrollTop;
|
||||
|
||||
// Set cursor position
|
||||
cursor.style.left = `${cursorLeft}px`;
|
||||
cursor.style.top = `${cursorTop}px`;
|
||||
|
||||
// Clean up the temporary div
|
||||
document.body.removeChild(div); } catch (error) {
|
||||
console.error('Error positioning cursor:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Update on various events
|
||||
inputElement.addEventListener('input', updatePosition);
|
||||
inputElement.addEventListener('click', updatePosition);
|
||||
inputElement.addEventListener('keyup', updatePosition);
|
||||
inputElement.addEventListener('focus', updatePosition);
|
||||
|
||||
// Initial position update
|
||||
updatePosition();
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const uiInputHandler = new UIInputHandler();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(uiInputHandler);
|
||||
|
||||
// Export the module
|
||||
export { uiInputHandler as UIInputHandler };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
console.log('UIInputHandler: Registering with window');
|
||||
window.UIInputHandler = uiInputHandler;
|
||||
Reference in New Issue
Block a user