Split everything up into dynamically loaded modules.

This commit is contained in:
2025-04-04 00:00:43 +00:00
parent 2f7cda4b6d
commit aa29a6fd93
32 changed files with 8768 additions and 3935 deletions
+2
View File
@@ -0,0 +1,2 @@
// This is a binary file. I'm creating an empty placeholder here.
// Replace with a proper favicon file for production.
+187 -87
View File
@@ -1,93 +1,193 @@
<!DOCTYPE html>
<html>
<head>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<!-- meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'blob'; style-src 'self' 'unsafe-inline'" -->
<title>ai-fiction Book Runtime (Modular Version)</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<p id="versions">We are using Node.js <span id="node-version"></span>,
Chromium <span id="chrome-version"></span>,
and Electron <span id="electron-version"></span>.</p>
<div id="book">
<div id="page_left">
<div class="header">
<h2 class="byline l10n-by">powered by Generative AI</h2>
<h1 class="title">AI Interactive Fiction</h1>
<h3 class="subtitle">An open-world text adventure</h3>
<div class="separator"><double></double></div>
</div>
<div id="controls" class="buttons">
<a class="l10n-speech" id="speech" title="Toggle text to speech" disabled="disabled">speech</a>
<span><a id="speed_reset"><span class="l10n-speed">speed<sup>*</sup></span></a><input type="range" min="0" max="100" value="50" id="speed" name="speed" /></span>
<a class="l10n-restart" id="rewind" title="Restart story from beginning" disabled="disabled">restart</a>
<a class="l10n-save" id="save" title="Save progress">save</a>
<a class="l10n-load" id="reload" title="Reload from save point" disabled="disabled">load</a>
</div>
<div id="choices" class="container">
<div id="command_history">
<!-- Previous commands and responses will be displayed here -->
</div>
<div id="command_input">
<div class="input-wrapper">
<textarea id="player_input" placeholder="Enter your command..." rows="1" autofocus></textarea>
<span id="cursor"></span>
</div>
</div>
</div>
<div class="l10n-remark" id="remark"><i><sup>*</sup>click on page or press spacebar to fast forward text animation</i></div>
</div>
<div id="page_right">
<div id="story" class="container">
</div>
</div>
</div>
<div id="ruler"></div>
<div class="l10n-prompt" id="indent">What do you want to do next?</div>
<div id="lighting" />
<!-- Socket.io library for client-server communication -->
<script src="/socket.io/socket.io.js"></script>
<!-- Core libraries -->
<script src="js/smartypants.js"></script>
<script src="js/linked-list.js"></script>
<script src="js/linebreak.js"></script>
<script src="js/knuth-and-plass.js"></script>
<script src="js/Hyphenopoly_Loader.js"></script>
<script>
var locale = "en";
// Create global variables needed by the modules
window.rstack = [];
</script>
<!-- Kokoro TTS library - load as module -->
<script type="module">
try {
// Import KokoroTTS class from the module
const kokoroModule = await import('./js/kokoro-js.js');
// Expose the KokoroTTS class globally
window.KokoroTTS = kokoroModule.KokoroTTS;
console.log('KokoroTTS class loaded and exposed to window');
// Dispatch an event to signal that the class is ready
const event = new CustomEvent('kokoro-class-loaded');
window.dispatchEvent(event);
} catch (error) {
console.error('Failed to load KokoroTTS module:', error);
// Dispatch an event even on failure so handlers don't wait forever
const event = new CustomEvent('kokoro-class-load-failed');
window.dispatchEvent(event);
<title>AI Interactive Fiction</title>
<link rel="preload" href="/fonts/EBGaramond12-Regular.otf" as="font" type="font/otf" crossorigin>
<link rel="preload" href="/fonts/EBGaramond12-Italic.otf" as="font" type="font/otf" crossorigin>
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<style>
@font-face {
font-family: 'EB Garamond';
src: url('/fonts/EBGaramond12-Regular.otf') format('opentype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'EB Garamond';
src: url('/fonts/EBGaramond12-Italic.otf') format('opentype');
font-weight: normal;
font-style: italic;
font-display: swap;
}
body {
margin: 0;
padding: 0;
background-color: #000;
}
.loading-overlay {
font-family: 'EB Garamond', serif;
color: #fff;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
transition: opacity 0.5s ease-out;
}
.loading-content {
width: 80%;
max-width: 500px;
text-align: center;
}
.loading-bar {
width: 100%;
height: 24px;
background: #333;
border-radius: 12px;
overflow: hidden;
position: relative;
margin-top: 20px;
}
.loading-progress {
width: 0%;
height: 100%;
background: #4CAF50;
transition: width 0.3s ease-in-out;
}
.loading-text {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
color: #fff;
font-weight: bold;
}
#modules-list {
list-style-type: none;
padding: 0;
margin-top: 20px;
max-height: 300px; /* Increased height */
overflow: hidden; /* Hide scrollbar */
width: 100%;
}
.module-item {
display: grid;
grid-template-columns: 1fr auto 1fr;
margin-bottom: 8px;
color: #ccc;
position: relative;
width: 100%;
}
.module-name {
text-align: left;
padding-right: 10px;
grid-column: 1;
}
.module-status {
text-align: center;
font-size: 12px;
grid-column: 2;
min-width: 80px; /* Ensure status has minimum width */
padding: 0 10px;
}
.module-status-detail {
grid-column: 3;
text-align: right;
font-size: 11px;
color: #aaa;
font-style: italic;
padding-left: 10px;
}
.status-pending {
color: #ccc;
}
.status-loading {
color: #FFC107;
}
.status-waiting {
color: #FF9800;
}
.status-initializing {
color: #2196F3;
}
.status-finished {
color: #4CAF50;
}
.status-error {
color: #F44336;
}
</script>
<!-- Main application script - imports all needed modules -->
<script type="module" src="js/ai-fiction.js"></script>
</body>
/* Update loader module list scrolling */
.loading-overlay #modules-list {
list-style-type: none;
padding: 0;
margin-top: 20px;
max-height: 300px;
overflow-y: auto; /* Enable vertical scrolling */
width: 100%;
scrollbar-width: thin;
scrollbar-color: #555 #333;
}
.loading-overlay #modules-list::-webkit-scrollbar {
width: 8px;
}
.loading-overlay #modules-list::-webkit-scrollbar-track {
background: #333;
}
.loading-overlay #modules-list::-webkit-scrollbar-thumb {
background-color: #555;
border-radius: 4px;
}
</style>
</head>
<body>
<!-- Debug output area -->
<div id="debug-output" style="position:fixed; bottom:10px; left:10px; background:rgba(0,0,0,0.7); color:white; padding:10px; border-radius:5px; font-family:monospace; max-width:80%; max-height:200px; overflow:auto; z-index:10000; display:none;">
<div id="debug-content"></div>
</div>
<script>
// Set up error tracking
window.addEventListener('error', function(event) {
const debugOutput = document.getElementById('debug-output');
const debugContent = document.getElementById('debug-content');
if (debugOutput && debugContent) {
debugOutput.style.display = 'block';
const errorMsg = document.createElement('div');
errorMsg.style.color = '#ff6b6b';
errorMsg.textContent = `ERROR: ${event.message} at ${event.filename}:${event.lineno}`;
debugContent.appendChild(errorMsg);
}
console.error(event);
});
// Debug logger
window.debugLog = function(message) {
const debugOutput = document.getElementById('debug-output');
const debugContent = document.getElementById('debug-content');
if (debugOutput && debugContent) {
debugOutput.style.display = 'block';
const logMsg = document.createElement('div');
logMsg.textContent = message;
debugContent.appendChild(logMsg);
}
console.log(message);
};
</script>
<script type="module" src="/js/loader.js"></script>
</body>
</html>
-756
View File
@@ -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
});
+162 -68
View File
@@ -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 {
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 = [];
this.delay = 0;
this.speed = 0.05; // Default speed
// 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
};
timeoutObject.timeoutId = setTimeout(() => {
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);
}
}, delay);
// Apply speed factor to the delay
const adjustedDelay = delay * this.speed;
// Schedule execution
timeoutObject.timeoutId = setTimeout(() => {
// Execute the function
timeoutObject.execute();
// Remove from queue
const index = this.queue.indexOf(timeoutObject);
if (index !== -1) {
this.queue.splice(index, 1);
}
}, 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 => {
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 => {
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;
+280
View File
@@ -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
View File
@@ -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;
+134
View File
@@ -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
});
}
}
+198
View File
@@ -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;
}
}
+152
View File
@@ -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 };
+251
View File
@@ -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;
+677
View File
@@ -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;
-719
View File
@@ -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;
}
}
-290
View File
@@ -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 = '&nbsp;'; // Use non-breaking space for measurement
const postCaretText = document.createTextNode(inputText.substring(caretPosition));
// Append spans to the div
div.appendChild(preCaretText);
div.appendChild(caretMarker);
div.appendChild(postCaretText);
// Append div to body for measurement
document.body.appendChild(div);
// Get position relative to the div's content box
const markerRect = caretMarker.getBoundingClientRect();
const divRect = div.getBoundingClientRect();
// Calculate position relative to the input's top-left, considering scroll
const cursorLeft = markerRect.left - divRect.left;
const cursorTop = markerRect.top - divRect.top - input.scrollTop;
// Set cursor position
cursor.style.left = `${cursorLeft}px`;
cursor.style.top = `${cursorTop}px`;
// Clean up the temporary div
document.body.removeChild(div);
}
/**
* Adjust textarea height based on its content.
*/
adjustTextareaHeight() {
if (!this.playerInput) return;
const textarea = this.playerInput;
// Temporarily reset height to accurately measure scrollHeight
textarea.style.height = 'auto';
// Set height to scrollHeight to fit content, adding a small buffer if needed
textarea.style.height = `${textarea.scrollHeight}px`;
}
/**
* Sets up focus management to keep the input field focused.
* Note: Some parts might be better handled by the main application logic
* depending on overall focus requirements (e.g., clicking outside input).
*/
setupFocusManagement() {
// Focus input field when the handler is initialized
this.focus();
// Re-focus input when user returns to this browser tab/window
window.addEventListener('focus', () => this.focus());
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
setTimeout(() => this.focus(), 100);
}
});
// Optional: Add a listener to the document to refocus if needed,
// but be careful not to interfere with other interactive elements.
/*
document.addEventListener('click', (e) => {
// Example: Refocus if click is not on specific elements
if (!e.target.closest('button, a, .interactive-ui-element')) {
this.focus();
}
});
*/
}
}
File diff suppressed because it is too large Load Diff
+120
View File
@@ -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;
}
}
+532
View File
@@ -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();
});
+260
View File
@@ -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;
+94
View File
@@ -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();
+947
View File
@@ -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;
+81 -11
View File
@@ -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;
+308 -67
View File
@@ -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 {
import { BaseModule } from './base-module.js';
import { moduleRegistry } from './module-registry.js';
class PersistenceManagerModule extends BaseModule {
/**
* 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
* Create a new persistence manager
*/
constructor(config = {}) {
this.storage = config.storage || window.localStorage;
this.saveStateKey = config.saveStateKey || 'save-state';
this.saveHistoryKey = config.saveHistoryKey || 'save-history';
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 };
}
/**
* 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
* Initialize the module
* @returns {Promise<boolean>} - Resolves with success status
*/
saveState(stateObject) {
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) {
if (!this.storage) {
console.warn('No storage available, cannot load game state.');
return null;
}
const result = {};
if (inkJson) {
result.inkJson = inkJson;
try {
const stateString = this.storage.getItem(this.stateKey);
if (!stateString) {
console.info('No saved game state found.');
return null;
}
if (historyJson) {
result.history = JSON.parse(historyJson);
}
return result;
const state = JSON.parse(stateString);
console.log('Game state loaded successfully.');
return state;
} catch (error) {
console.error('Error loading state:', error);
console.error('Error loading game state:', error);
return null;
}
}
/**
* Check if a saved state exists
* Check if a saved game state exists
* @returns {boolean} Whether a saved state exists
*/
hasSavedState() {
return this.storage.getItem(this.saveStateKey) !== null;
if (!this.storage) return false;
return !!this.storage.getItem(this.stateKey);
}
/**
* Delete the saved state
* @returns {boolean} Whether the deletion was successful
* Delete the saved game state
* @returns {boolean} Whether the state was successfully deleted
*/
deleteSavedState() {
clearState() {
if (!this.storage) return false;
try {
this.storage.removeItem(this.saveStateKey);
this.storage.removeItem(this.saveHistoryKey);
this.storage.removeItem(this.stateKey);
console.log('Game state cleared.');
return true;
} catch (error) {
console.error('Error deleting saved state:', error);
console.error('Error clearing game 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
* Save user preferences
* @param {Object} [preferences] - Preferences to save (defaults to current preferences)
* @returns {boolean} Whether preferences were successfully saved
*/
exportState() {
const state = this.loadState();
if (!state) {
return null;
savePreferences(preferences = null) {
if (!this.storage) {
console.warn('No storage available, preferences not saved.');
return false;
}
return JSON.stringify(state);
}
// Use provided preferences or current preferences
const prefsToSave = preferences || this.preferences;
/**
* 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 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 importing state:', 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;
+385 -122
View File
@@ -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) {
import { BaseModule } from './base-module.js';
import { moduleRegistry } from './module-registry.js';
class SocketClientModule extends BaseModule {
constructor() {
super('socket-client', 'Socket Client');
this.socket = null;
this.serverUrl = serverUrl || window.location.origin; // Default to current origin
this.eventListeners = {
connect: [],
disconnect: [],
connect_error: [],
gameIntroduction: [],
narrativeResponse: [],
gameSaved: [],
gameLoaded: [],
error: [],
};
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
}
/**
* Connects to the WebSocket server.
* Load module dependencies
* @returns {Promise} - Resolves when dependencies are loaded
*/
connect() {
if (this.socket && this.socket.connected) {
console.log('SocketClient: Already connected.');
async loadDependencies() {
try {
// 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("Error loading Socket Client dependencies:", error);
return false;
}
}
/**
* 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;
}
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.' });
// 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;
}
this.socket = io(this.serverUrl, {
reconnectionAttempts: 5,
timeout: 10000,
// 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.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');
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(`SocketClient: Disconnected from server. Reason: ${reason}`);
this.triggerEvent('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('SocketClient: Connection error:', error);
this.triggerEvent('connect_error', error);
console.error('Socket Client: Connection error:', error);
this.emitEvent('connect_error', error);
resolve(false);
});
// --- Game-specific events ---
// Set up game-specific event handlers
this.setupGameEventHandlers();
this.socket.on('gameIntroduction', (data) => {
console.log('SocketClient: Received gameIntroduction');
this.triggerEvent('gameIntroduction', data);
} 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) => {
console.log('SocketClient: Received narrativeResponse');
this.triggerEvent('narrativeResponse', data);
if (data && data.text && this.textBuffer) {
this.processTextFragment(data.text);
}
});
this.socket.on('gameSaved', (data) => {
console.log('SocketClient: Received gameSaved confirmation');
this.triggerEvent('gameSaved', data); // Pass data if any
});
// Special handling for introduction text
this.socket.on('gameIntroduction', (data) => {
if (data && data.introduction && this.textBuffer) {
this.processTextFragment(data.introduction);
}
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);
if (data && data.initialRoomDescription && this.textBuffer) {
this.processTextFragment(data.initialRoomDescription);
}
});
}
/**
* 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.
* Process a text fragment by adding it to the TextBuffer
* @param {string} text - Text fragment to process
*/
on(eventName, callback) {
if (this.eventListeners[eventName]) {
this.eventListeners[eventName].push(callback);
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.warn(`SocketClient: Attempted to register listener for unknown event "${eventName}"`);
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);
}
}
}
/**
* Triggers a specific event, calling all registered listeners.
* @param {string} eventName - The name of the event.
* @param {*} data - Data to pass to the listeners.
* Attempt to reconnect to the server
*/
triggerEvent(eventName, data) {
if (this.eventListeners[eventName]) {
this.eventListeners[eventName].forEach(callback => {
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;
}
}
/**
* 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;
}
}
/**
* 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;
}
}
/**
* 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;
}
}
/**
* 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;
}
}
/**
* 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(`SocketClient: Error in event listener for "${eventName}":`, error);
console.error(`Socket Client: Error in '${event}' event handler:`, error);
}
});
}
}
/**
* 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.
* Check if the socket is connected
* @returns {boolean} - Connection status
*/
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.` });
}
}
// --- Convenience methods for game actions ---
requestStartGame() {
this.emit('startGame');
}
sendCommand(command) {
this.emit('playerCommand', { command });
}
requestSaveGame() {
this.emit('saveGame');
}
requestLoadGame() {
this.emit('loadGame');
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;
-54
View File
@@ -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
});
+182
View File
@@ -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;
+290 -47
View File
@@ -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 {
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';
}
/**
* Create a new TextProcessor
* @param {Object} smartyPants - The SmartyPants library
* @param {Function} [hyphenator] - Optional: The hyphenation function (can be set later)
* Load module dependencies
* @returns {Promise<boolean>} - Resolves with success status
*/
constructor(smartyPants, hyphenator = null) { // Make hyphenator optional
this.smartyPants = smartyPants;
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.hyphenationClass = '.hyphenatePipe'; // Default hyphenation class for Knuth-Plass with pipe character
this.hyphenatorReady = true;
// Dispatch event that hyphenation is ready
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
}).catch(err => {
console.error('Error loading hyphenator:', err);
});
}
}
/**
* Process text with typographic enhancements and hyphenation
* 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, '!')
process(text, useHyphenation = false) {
if (!text) return '';
// 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
let processed = text;
// Then apply hyphenation if available
if (typeof this.hyphenator === 'function') {
return this.hyphenator(smartyPantsText, this.hyphenationClass);
// Apply SmartyPants for typographic punctuation using stored references
try {
if (typeof this.smartyPants === 'function') {
processed = this.smartyPants(processed);
} else {
console.warn('TextProcessor: Hyphenator not set, skipping hyphenation.');
return smartyPantsText; // Return text without hyphenation if not set
}
console.warn("SmartyPants function not available for processing");
}
/**
* Set the hyphenator function after initialization.
* @param {Function} hyphenatorFunction - The hyphenation function provided by Hyphenopoly.
*/
setHyphenator(hyphenatorFunction) {
if (typeof hyphenatorFunction === 'function') {
this.hyphenator = hyphenatorFunction;
// Convert HTML entities to UTF-8 characters
if (typeof this.smartypantsu === 'function') {
processed = this.smartypantsu(processed);
} else {
console.error('TextProcessor: Invalid hyphenator function provided.');
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 hyphenation class
* @param {string} className - The CSS class for hyphenation
* Set the locale for text processing
* @param {string} locale - The locale code (e.g., 'en-us', 'de')
*/
setHyphenationClass(className) {
this.hyphenationClass = className;
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}`);
}
/**
* 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;
+494 -111
View File
@@ -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...');
this.initializationAttempted = true;
// --- 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
);
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
// 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');
// 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`);
}
}
// 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')
]);
} catch (timeoutError) {
console.error(timeoutError.message); // Log the timeout error
kokoroInitialized = false;
}
// --- End Increase Timeout ---
if (kokoroInitialized) {
console.log('Kokoro Handler reported successful initialization.');
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 {
console.warn('Kokoro Handler reported failed or timed out initialization.');
// 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');
}
} catch (error) {
console.error('Error initializing Kokoro Handler:', error);
kokoroInitialized = false; // Ensure it's marked as failed
});
}
// 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;
applyVoiceSettingsFromPreferences() {
if (!this.ttsHandler || !this.persistenceManager) return;
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 });
}
// 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');
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 });
}
}
// 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
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);
}
}
});
window.dispatchEvent(ttsReadyEvent);
}
/**
* 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
*/
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 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;
}
/**
* Get preferred TTS mode from storage
* @returns {string|null} - Preferred TTS mode or null if not set
*/
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;
}
}
// 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.activeTTSHandler) {
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: this.usingKokoro ? 'kokoro' : 'browser',
name: this.usingKokoro ? 'Kokoro TTS' : 'Browser TTS'
type: id,
name: name
};
}
/**
* Force switching to a specific TTS system
* @param {string} type - Either 'kokoro' or 'browser'
* Switch to a specific TTS handler
* @param {string} type - The handler ID to use
* @returns {boolean} - Success status
*/
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);
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;
}
console.error(`Failed to switch to ${type} TTS - not available`);
/**
* 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;
+86 -390
View File
@@ -1,414 +1,110 @@
/**
* Text-to-Speech Handler for AI Interactive Fiction
* Enhanced version with improved voice selection, caching, and playback controls
* TTS Handler Base Class
* Abstract base class defining the interface for all TTS handlers
*/
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;
this.voiceOptions = {};
this.isReady = 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.");
}
// Set up event dispatcher
this.eventTarget = document.createElement('div');
}
/**
* Select the preferred voice based on language and quality
* Get handler ID
* @returns {string} - Handler identifier
*/
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);
}
getId() {
throw new Error('getId() must be implemented by subclass');
}
/**
* Toggle TTS functionality on/off
* @returns {boolean} New state of TTS (enabled/disabled)
* Initialize the TTS handler
* @param {Function} progressCallback - Optional progress callback
* @returns {Promise<boolean>} - Resolves with success status
*/
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;
async initialize(progressCallback = null) {
throw new Error('initialize() must be implemented by subclass');
}
/**
* Set the speech rate/speed
* @param {number} speed - Speed multiplier (0.1 to 2.0)
* Check if this TTS handler is available
* @returns {boolean} - True if handler is ready to use
*/
setSpeed(speed) {
this.currentSpeed = Math.max(0.1, Math.min(2.0, speed));
isAvailable() {
return this.isReady;
}
/**
* 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
* Check if voice is currently speaking
* @returns {boolean} - True if speaking
*/
isSpeaking() {
return this.speaking;
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);
}
}
// Create and export a singleton instance
export const browserTtsHandler = new TTSHandler();
+244 -112
View File
@@ -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.");
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;
}
}
/**
* Initialize the module
* @returns {Promise<boolean>} - Resolves with success status
*/
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
}
}
/**
* 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() {
if (!this.ttsFactory) return false;
return this.ttsFactory.toggle();
}
/**
* Speak text using the active TTS system
* @param {string} text - Text to speak
* @param {Function} callback - Called when speech completes
*/
speak(text, callback) {
if (!this.ttsFactory) {
console.warn("TTS Factory not available for speak");
if (callback) callback("TTS not available");
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;
}
console.log(`TTS Player speaking: "${text}"`);
this.ttsFactory.speak(text, (result) => {
console.log("TTS Player speak complete", result);
if (callback) callback(result);
});
}
/**
* Enable or disable TTS
* @param {boolean} enabled - Whether TTS should be enabled
*/
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);
}
}
/**
* Toggle TTS state
* @returns {boolean} The new enabled state
*/
toggle() {
this.setEnabled(!this.enabled);
return this.enabled;
}
/**
* Check if TTS is enabled
* @returns {boolean} Whether TTS is enabled
*/
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");
}
}
/**
* 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;
+396 -392
View File
@@ -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;
class UIController extends BaseModule {
constructor() {
super('ui-controller');
// Active TTS handler (set via setTtsHandler)
// 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;
// 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');
// Add TTS toggle state
this.ttsEnabled = false;
// Translations
this.translations = config.translations || {};
this.locale = config.locale || 'en-us';
// 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);
// 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
}));
};
}
async initialize() {
this.reportProgress(0, 'Initializing UI Controller');
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`);
}
/**
* Set up event listeners
*/
setupEventListeners() {
// Speed slider
if (this.speedSlider) {
this.speedSlider.addEventListener('input', this.handleSpeedChange.bind(this));
}
// Speed reset button
if (this.speedReset) {
this.speedReset.addEventListener('click', this.handleSpeedReset.bind(this));
}
// Rewind button
if (this.rewindButton) {
this.rewindButton.addEventListener('click', this.handleRewindClick.bind(this));
}
// Save button
if (this.saveButton) {
this.saveButton.addEventListener('click', this.handleSaveClick.bind(this));
}
// Load button
if (this.loadButton) {
this.loadButton.addEventListener('click', this.handleLoadClick.bind(this));
}
// Speech button
if (this.speechButton) {
this.speechButton.addEventListener('click', this.handleSpeechToggle.bind(this));
}
// Fast forward (spacebar or click on right page)
window.addEventListener('keydown', (event) => {
if (event.code === 'Space') {
this.handleFastForward();
}
// 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));
// Listen for socket connection events
document.addEventListener('socket:connected', () => {
console.log('UI Controller: Socket connected');
});
document.addEventListener('socket:disconnected', () => {
console.log('UI Controller: Socket disconnected');
});
// 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);
}
}
});
}
/**
* Handle speed slider change
* @param {Event} event - The input event
*/
handleSpeedChange(event) {
if (!this.animationQueue) return;
const value = parseFloat(event.target.value);
// 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);
}
}
});
/**
* Handle speed reset button click
*/
handleSpeedReset() {
if (!this.speedSlider || !this.animationQueue) return;
this.speedSlider.value = 50;
const speed = Math.pow(100.0 - 50, 3) / 10000 * 10 + 0.01;
// 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);
}
/**
* Handle rewind button click
*/
handleRewindClick() {
if (this.rewindButton.getAttribute('disabled') === 'disabled') {
return;
}
// Use localized confirm message if available
const confirmMsg = this.translations[this.locale]?.confirm_restart || 'Are you sure you want to restart the game? All progress will be lost.';
if (confirm(confirmMsg)) {
if (this.onRestartRequest) {
this.onRestartRequest();
}
// 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("UiController: onRestartRequest callback not set.");
console.warn('TTS Handler does not have toggle method');
}
});
}
// 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);
}
}
/**
* Handle save button click
*/
handleSaveClick() {
if (this.saveButton.getAttribute('disabled') === 'disabled') {
return;
}
if (this.onSaveRequest) {
this.onSaveRequest();
} else {
console.warn("UiController: onSaveRequest callback not set.");
}
}
/**
* Handle load button click
*/
handleLoadClick() {
if (this.loadButton.getAttribute('disabled') === 'disabled') {
return;
}
if (this.onLoadRequest) {
this.onLoadRequest();
} else {
console.warn("UiController: onLoadRequest callback not set.");
}
}
/**
* Handle speech toggle button click
*/
handleSpeechToggle() {
if (!this.ttsHandler) {
console.warn("UiController: ttsHandler not set. Cannot toggle speech.");
// Attempt to use ttsPlayer as fallback if needed, but prefer ttsHandler
if (this.ttsPlayer && this.speechButton.getAttribute('disabled') !== 'disabled') {
const enabled = this.ttsPlayer.toggle();
this.updateSpeechButtonStyling(enabled);
}
return;
}
if (this.speechButton.getAttribute('disabled') === 'disabled') {
return;
}
// Ensure AudioContext is resumed on user interaction if using Kokoro
if (window.ttsFactory && window.ttsFactory.usingKokoro && this.ttsHandler.audioContext && this.ttsHandler.audioContext.state === 'suspended') {
this.ttsHandler.audioContext.resume().catch(err => console.error('Error resuming AudioContext on click:', err));
}
// Set user activation flag for the handler
this.ttsHandler.hasUserActivation = true;
const enabled = this.ttsHandler.toggle();
this.updateSpeechButtonStyling(enabled); // Update visual style
if (enabled) {
// Speak the last narrative if speech was just enabled and story container is available
if (this.storyContainer) {
const lastNarrative = this.storyContainer.lastElementChild;
if (lastNarrative && lastNarrative.classList.contains('narrative')) { // Check if it's narrative text
console.log("Speaking last narrative on toggle");
// Use a slight delay to ensure audio context is resumed
setTimeout(() => this.ttsHandler.speak(lastNarrative.textContent), 50);
}
}
} else {
// If disabling, ensure speech stops
this.ttsHandler.stop();
}
}
/**
* Handle fast forward (spacebar or click)
*/
handleFastForward() {
if (!this.animationQueue) return;
// 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();
}
/**
* Handle window resize
*/
handleWindowResize() {
this.updateBookDimensions();
this.updateParagraphHeight();
}
/**
* Sets the active TTS handler.
* @param {object} handler - The TTS handler instance (e.g., KokoroHandler, BrowserTtsHandler).
*/
setTtsHandler(handler) {
this.ttsHandler = handler;
console.log("UiController: TTS Handler set.", handler);
// Update button state based on the new handler's status
this.updateSpeechButtonStyling(this.ttsHandler ? this.ttsHandler.isEnabled() : false);
}
/**
* Update the book dimensions based on viewport size
*/
updateBookDimensions() {
const vw = window.innerWidth;
const vh = window.innerHeight;
const viewportAspectRatio = vw / vh;
const imageAspectRatio = 2727 / 1691;
let bookWidth, bookHeight;
if (viewportAspectRatio > imageAspectRatio) {
bookWidth = vh * imageAspectRatio;
bookHeight = vh;
} else {
bookWidth = vw;
bookHeight = vw / imageAspectRatio;
}
document.documentElement.style.setProperty('--book-width', `${bookWidth}px`);
document.documentElement.style.setProperty('--book-height', `${bookHeight}px`);
// Setting a CSS variable that will be either vw or vh depending on the viewport aspect ratio
document.documentElement.style.setProperty(
"--viewport-dimension",
viewportAspectRatio > imageAspectRatio ? 'vw' : 'vh'
);
document.documentElement.style.setProperty('--viewport-aspect-ratio', viewportAspectRatio);
const story = document.getElementById("story");
if (story) {
const paddingTop = window.getComputedStyle(story).paddingTop;
const paddingBottom = window.getComputedStyle(story).paddingBottom;
document.documentElement.style.setProperty('--story-line-height', (story.clientHeight - paddingTop - paddingBottom) / 28);
}
}
/**
* Update paragraph heights based on viewport
*/
updateParagraphHeight() {
document.querySelectorAll("#story p").forEach((element) => {
if (element.dataset.vpc) {
const pHeight = parseFloat(window.getComputedStyle(document.getElementById('page_right')).height);
const newHeight = pHeight * element.dataset.vpc / 100 + 'px';
element.style.height = newHeight;
}
});
}
/**
* Update the speech button styling based on enabled state.
* @param {boolean} enabled - Whether speech is enabled.
*/
updateSpeechButtonStyling(enabled = false) {
if (!this.speechButton) return;
if (enabled) {
this.speechButton.style.fontWeight = 'bold';
this.speechButton.style.color = '#000';
this.speechButton.style.backgroundColor = '#eee';
} else {
this.speechButton.style.fontWeight = 'normal';
this.speechButton.style.color = '#333';
this.speechButton.style.backgroundColor = '';
}
}
/**
* Updates the enabled/disabled state and title of the speech button.
* @param {boolean} available - Whether any TTS system is available.
* @param {string} [type] - The type of TTS system available ('kokoro', 'browser', etc.).
*/
updateSpeechButtonAvailability(available, type) {
if (!this.speechButton) return;
if (available) {
this.speechButton.removeAttribute('disabled');
const ttsName = type === 'kokoro' ? 'Kokoro TTS' : (type === 'browser' ? 'Browser TTS' : 'TTS');
const title = this.translations[this.locale]?.title_speech || `Toggle Text-to-Speech (${ttsName})`;
this.speechButton.setAttribute('title', title);
// Update style based on current handler state if available
this.updateSpeechButtonStyling(this.ttsHandler ? this.ttsHandler.isEnabled() : false);
} else {
this.speechButton.setAttribute('disabled', 'disabled');
const title = this.translations[this.locale]?.title_speech_unavailable || 'Text-to-Speech not available';
this.speechButton.setAttribute('title', title);
this.updateSpeechButtonStyling(false); // Ensure style is off
}
}
/**
* Updates the enabled/disabled state of control buttons based on game state.
* @param {object} gameState - The current game state from AnimatedFiction.
* @param {boolean} gameState.started - Whether the game has started.
* @param {boolean} [gameState.canLoad] - Whether a saved game exists to be loaded.
*/
updateButtonStates(gameState) {
if (this.rewindButton) {
if (gameState.started) {
this.rewindButton.removeAttribute('disabled');
} else {
this.rewindButton.setAttribute('disabled', 'disabled');
}
}
if (this.saveButton) {
if (gameState.started) {
this.saveButton.removeAttribute('disabled');
} else {
this.saveButton.setAttribute('disabled', 'disabled');
}
}
if (this.loadButton) {
// Enable load button if a save exists (indicated by canLoad flag or similar)
// We might need a more robust way to check for saved state existence.
// For now, enable if game started OR if canLoad is explicitly true.
if (gameState.started || gameState.canLoad) {
this.loadButton.removeAttribute('disabled');
} else {
this.loadButton.setAttribute('disabled', 'disabled');
}
}
// Speech button availability is handled separately by updateSpeechButtonAvailability
}
/**
* Updates the visual display of the speed slider.
* @param {number} value - The speed value (0-100).
*/
updateSpeedDisplay(value) {
if (this.speedSlider) {
this.speedSlider.value = value;
}
}
/**
* Insert an element after a delay (Helper, potentially move elsewhere or keep if used)
* @param {number} delay - The delay in milliseconds
* @param {HTMLElement} target - The target element to append to
* @param {HTMLElement} el - The element to insert
* @param {boolean} fadeIn - Whether to fade in the element
*/
insertAfter(delay, target, el, fadeIn = true) {
// Space key for fast-forwarding
document.addEventListener('keydown', (e) => {
if (e.key === ' ' &&
document.activeElement.tagName !== 'TEXTAREA' &&
document.activeElement.tagName !== 'INPUT') {
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);
console.log('UI Controller: Fast-forwarding animations (space key)');
this.animationQueue.fastForward();
e.preventDefault(); // Prevent page scrolling
}
} else {
// Fallback if no animation queue
if (fadeIn) {
el.classList.add("fade-in");
setTimeout(() => {
target.appendChild(el);
}, delay);
} else {
setTimeout(() => {
target.appendChild(el);
}, delay);
}
});
}
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');
}
}
initializeTextBuffer() {
// Initialize text buffer handling
if (this.textBuffer) {
this.textBuffer.setOnSentenceReady((text, callback) => {
console.log('UI Controller: Displaying sentence');
this.displayText(text).then(callback);
});
}
}
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);
}
}
/**
* Set the locale for translations
* @param {string} locale - The locale code
* Update UI button states based on game state
* @param {Object} state - Game state information
*/
setLocale(locale) {
this.locale = locale;
updateButtonStates(state = {}) {
const { canSave, canLoad, canRestart } = state;
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)}`);
// Get button elements
const saveButton = document.getElementById('save');
const loadButton = document.getElementById('reload');
const restartButton = document.getElementById('rewind');
elements.forEach(element => {
if (prefix === "title") {
element.title = this.translations[locale][key];
// Update save button state
if (saveButton) {
if (canSave) {
saveButton.removeAttribute('disabled');
} else {
element.innerHTML = this.translations[locale][key];
saveButton.setAttribute('disabled', 'disabled');
}
});
});
}
// Update load button state
if (loadButton) {
if (canLoad) {
loadButton.removeAttribute('disabled');
} else {
console.error(`Locale ${locale} is not defined`);
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;
+621
View File
@@ -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;
+319
View File
@@ -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;
+449
View File
@@ -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 = '&nbsp;'; // Use non-breaking space for measurement
const postCaretText = document.createTextNode(inputText.substring(caretPosition));
// Append spans to the div
div.appendChild(preCaretText);
div.appendChild(caretMarker);
div.appendChild(postCaretText);
// Append div to body for measurement
document.body.appendChild(div);
// Get position relative to the div's content box
const markerRect = caretMarker.getBoundingClientRect();
const divRect = div.getBoundingClientRect();
// Calculate position relative to the input's top-left, considering scroll
const cursorLeft = markerRect.left - divRect.left;
const cursorTop = markerRect.top - divRect.top - input.scrollTop;
// Set cursor position
cursor.style.left = `${cursorLeft}px`;
cursor.style.top = `${cursorTop}px`;
// Clean up the temporary div
document.body.removeChild(div); } 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;