Files
ai.interactive.fiction/references/ai-fiction.js
2025-04-04 00:02:28 +00:00

1011 lines
43 KiB
JavaScript

/**
* 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';
import { LoadingOverlay } from './loading-overlay.js';
import { TextBuffer } from './text-buffer.js';
import { OptionsUI } from './options-ui.js';
export class AnimatedFiction {
/**
* Create a new AnimatedFiction application
* @param {Object} config - Configuration options
*/
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
// Use the pre-existing loading overlay if available, or create a new one
if (window.initialOverlay) {
console.log("Using pre-existing loading overlay");
// Create a wrapper for the initial overlay to match our LoadingOverlay API
this.loadingOverlay = {
updateProgressItem: (id, progress, complete) => {
// Map progress items to overall percentages
let totalProgress = 0;
if (id === 'components') totalProgress = 30;
else if (id === 'tts') totalProgress = complete ? 80 : 40 + (progress * 0.4);
else if (id === 'ui') totalProgress = 90;
else if (id === 'socket') totalProgress = 95;
let message = 'Loading...';
if (id === 'components') message = 'Loading core components...';
else if (id === 'tts') message = complete ? 'TTS system ready' : 'Initializing Text-to-Speech...';
else if (id === 'ui') message = 'Preparing user interface...';
else if (id === 'socket') message = 'Connecting to server...';
window.initialOverlay.updateProgress(totalProgress, message);
},
hide: (callback) => {
window.initialOverlay.hide();
if (callback) setTimeout(callback, 500);
},
show: () => {} // No-op as it's already showing
};
} else {
// Create a fallback LoadingOverlay if initialOverlay is not available
this.loadingOverlay = new LoadingOverlay({
title: 'Initializing AI Fiction',
fadeOutDuration: 800
});
this.loadingOverlay.show();
}
// Add additional progress tracking items for hyphenation
this.loadingOverlay.updateProgressItem('components', 10, false);
this.loadingOverlay.updateProgressItem('hyphenation', 0, false);
this.loadingOverlay.updateProgressItem('tts', 0, false);
this.loadingOverlay.updateProgressItem('ui', 0, false);
this.loadingOverlay.updateProgressItem('socket', 0, false);
// Track when modules are ready to hide the loading overlay
this.componentsReady = false;
this.hyphenationReady = false;
this.ttsReady = false;
this.uiReady = false;
// Initialize core components
// (This only creates the component instances but doesn't start processing yet)
this.initializeComponents();
this.bindGlobalEvents();
}
/**
* 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,
persistenceManager: this.persistenceManager,
audioManager: this.audioManager,
ttsFactory: window.ttsFactory
});
this.persistenceManager = new PersistenceManager({
storage: localStorage // Note: Persistence might need rework for socket state
});
// Initialize the TextBuffer for sentence collection
this.textBuffer = new TextBuffer({
ttsPlayer: this.ttsPlayer,
onSentenceReady: (sentence) => this.handleSentenceReady(sentence)
});
// Mark components as initialized for loading progress
this.loadingOverlay.updateProgressItem('components', 60, false);
// 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();
}
/**
* 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() {
// Add a shorter timeout specifically for TTS initialization (8 seconds)
const ttsTimeout = setTimeout(() => {
console.warn("AnimatedFiction: TTS initialization timeout reached, continuing without it");
this.loadingOverlay.updateProgressItem('tts', 100, true, 'TTS not available, continuing anyway');
this.ttsReady = true;
this.checkInitializationComplete();
}, 8000); // 8 second timeout is enough since the UI needs to become responsive
// Listen for the normal TTS ready event
window.addEventListener('tts-ready', (event) => {
// Clear the timeout since we got the event
clearTimeout(ttsTimeout);
console.log('AnimatedFiction received tts-ready event:', event.detail);
const { available, type, handler, systems } = event.detail;
// Update loading progress for TTS initialization
this.loadingOverlay.updateProgressItem('tts', 100, true,
available ? `${type} TTS ready` : 'TTS not available');
this.ttsReady = true;
// Check if we can hide the loading overlay
this.checkInitializationComplete();
// Rest of TTS ready handling
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);
}
// Initialize the options UI with available TTS systems
if (systems) {
this.initializeOptionsUI(systems);
} else {
// If no systems info provided, pass an empty object
this.initializeOptionsUI({});
}
// 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");
const layout = this.paragraphLayout.calculateLayout(processed, measures.slice().reverse(), true);
// 5. Render paragraph using the LayoutRenderer
console.log("AnimatedFiction: Rendering paragraph with layout data");
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. Add text to the TextBuffer for TTS processing
// This buffers text, extracts complete sentences, and processes them for TTS
if (this.textBuffer) {
console.log("AnimatedFiction: Adding text to buffer for sentence processing");
this.textBuffer.addText(text);
}
// Direct TTS fallback if TextBuffer isn't available
else if (this.ttsPlayer && this.ttsPlayer.isEnabled()) {
console.log("AnimatedFiction: Speaking text directly with TTS (no buffer)");
this.ttsPlayer.speak(text);
}
else if (window.ttsHandler && typeof window.ttsHandler.isEnabled === 'function' && window.ttsHandler.isEnabled()) {
console.log("AnimatedFiction: Speaking text with global TTS handler (no buffer)");
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
// Since rendering failed, don't use buffer, just speak directly
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: Load all modules, then connect socket and set up UI
*/
async start() {
try {
// 1. Initialize UI components first (non-linguistic)
this.uiController.setupEventListeners();
this.uiController.updateBookDimensions();
this.uiController.updateParagraphHeight();
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);
this.loadingOverlay.updateProgressItem('components', 60, false);
this.componentsReady = true;
// 2. Load and initialize the hyphenation engine
this.loadingOverlay.updateProgressItem('hyphenation', 20, false);
await this.initializeHyphenation();
this.loadingOverlay.updateProgressItem('hyphenation', 100, true);
this.hyphenationReady = true;
// 3. The TTS system is initialized separately through events
// We're already listening for the 'tts-ready' event in listenForTTSReady()
// 4. Mark UI as ready
this.loadingOverlay.updateProgressItem('ui', 80, false);
this.uiReady = true;
// 5. Connect to the socket server and wait for connection
this.loadingOverlay.updateProgressItem('socket', 50, false);
await this.connectToServer();
// Everything is now loaded, we can check initialization state
// This will hide the loading overlay when all components are ready
this.checkInitializationComplete();
// UI resize may be needed after everything is loaded
this.uiController.handleWindowResize();
} catch (error) {
console.error("Error during application startup:", error);
// Display error message to user
if (this.storyContainer) {
const errorElement = document.createElement('div');
errorElement.className = 'system-error';
errorElement.textContent = `Failed to start application: ${error.message}`;
this.storyContainer.appendChild(errorElement);
}
// Hide loading overlay anyway
this.loadingOverlay.hide();
}
}
/**
* Initialize and load hyphenation engine
* @returns {Promise} Resolves when hyphenation is ready
*/
async initializeHyphenation() {
try {
this.loadingOverlay.updateProgressItem('hyphenation', 30, false, 'Loading hyphenation engine...');
// Import the hyphenopoly module
const hyphenopoly = await import('./hyphenopoly.module.js').then(module => module.default);
this.loadingOverlay.updateProgressItem('hyphenation', 50, false, 'Configuring hyphenation patterns...');
// Configure the hyphenator with proper exceptions to demonstrate it's working
const hyphenatorConfig = hyphenopoly.config({
"require": ["en-us"],
"hyphen": "|",
"minWordLength": 4,
"exceptions": {
"en-us": "aban|doned, vic|to|ri|an, man|sion, stand|ing, im|pos|ing, ex|am|ine"
},
"loader": async (file, patDir) => {
console.log(`Loading pattern file: ${patDir}${file}`);
try {
const response = await fetch(`${patDir}${file}`);
if (!response.ok) {
throw new Error(`Failed to fetch ${file}: ${response.status}`);
}
return response.arrayBuffer();
} catch (error) {
console.error(`Error loading pattern file ${file}:`, error);
throw error;
}
}
});
this.loadingOverlay.updateProgressItem('hyphenation', 80, false, 'Getting hyphenation function...');
// Get the hyphenateText function for en-us and await it
const hyphenateFunction = await hyphenatorConfig.get("en-us");
// Test the hyphenator to verify it works
const testWord = "abandoned";
const hyphenatedTest = hyphenateFunction(testWord);
console.log(`Hyphenation test: "${testWord}" → "${hyphenatedTest}"`);
// Create a wrapper function for our text processor that properly handles errors
const hyphenateWrapper = (text) => {
try {
return hyphenateFunction(text);
} catch (error) {
console.error("Hyphenation error:", error);
return text; // Return original text on error
}
};
// Set the hyphenator on the text processor
this.textProcessor.setHyphenator(hyphenateWrapper);
console.log("Hyphenator successfully set on text processor");
this.loadingOverlay.updateProgressItem('hyphenation', 100, true, 'Hyphenation ready');
return true;
} catch (error) {
console.error("Failed to initialize hyphenation:", error);
this.loadingOverlay.updateProgressItem('hyphenation', 100, true, 'Hyphenation failed, continuing anyway');
// We'll continue without hyphenation rather than blocking the app
return false;
}
}
/**
* Connect to the socket server
* @returns {Promise} Resolves when socket is connected
*/
connectToServer() {
return new Promise((resolve) => {
console.log("AnimatedFiction: Connecting to server...");
this.loadingOverlay.updateProgressItem('socket', 50, false, 'Connecting to server...');
// Set up a connection handler that resolves the promise
const connectionHandler = () => {
console.log("AnimatedFiction: Socket connected successfully.");
this.loadingOverlay.updateProgressItem('socket', 100, true, 'Connected to server');
this.socketClient.off('connect', connectionHandler);
resolve();
};
// Set up an error handler
const errorHandler = (error) => {
console.error("AnimatedFiction: Socket connection failed:", error);
this.loadingOverlay.updateProgressItem('socket', 100, true, 'Connection failed, will retry');
// We resolve anyway to not block the app
this.socketClient.off('connect_error', errorHandler);
resolve();
};
// Connect to socket server
this.socketClient.on('connect', connectionHandler);
this.socketClient.on('connect_error', errorHandler);
this.socketClient.connect();
// Set a timeout to resolve anyway after 5 seconds
setTimeout(() => {
this.socketClient.off('connect', connectionHandler);
this.socketClient.off('connect_error', errorHandler);
console.warn("AnimatedFiction: Socket connection timeout, continuing anyway");
this.loadingOverlay.updateProgressItem('socket', 100, true, 'Connection timeout, will retry later');
resolve();
}, 5000);
});
}
/**
* Check if all initialization tasks are complete and hide loading overlay if so
*/
checkInitializationComplete() {
// We require core components, hyphenation and UI to be ready
// TTS is optional and can continue loading in the background
if (this.componentsReady && this.hyphenationReady && this.uiReady) {
console.log("AnimatedFiction: All required initialization tasks complete, hiding loading overlay");
// Update UI progress item and mark as complete
this.loadingOverlay.updateProgressItem('ui', 100, true);
// Hide the loading overlay with fade-out animation
this.loadingOverlay.hide(() => {
console.log("AnimatedFiction: Loading overlay hidden, starting game");
// Focus the input handler once the overlay is gone
if (this.inputHandler) {
this.inputHandler.focus();
}
});
} else {
console.log("AnimatedFiction: Waiting for initialization to complete",
"Components ready:", this.componentsReady,
"Hyphenation ready:", this.hyphenationReady,
"UI ready:", this.uiReady,
"TTS ready:", this.ttsReady);
}
}
/**
* Initialize the options UI with available TTS systems
* @param {Object} availableSystems - Object containing available TTS systems
*/
initializeOptionsUI(availableSystems) {
// Create options button if it doesn't exist
if (!document.getElementById('options')) {
const optionsButton = document.createElement('button');
optionsButton.id = 'options';
optionsButton.className = 'l10n-options';
optionsButton.title = 'Open options menu';
optionsButton.innerText = 'options';
optionsButton.style.order = '5'; // Position it in the control bar
// Add to control bar
const controlBar = document.querySelector('.control-bar');
if (controlBar) {
controlBar.appendChild(optionsButton);
}
}
// Create and configure the options UI
this.optionsUI = new OptionsUI({
persistenceManager: this.persistenceManager,
ttsPlayer: this.ttsPlayer,
audioManager: this.audioManager,
ttsFactory: window.ttsFactory,
onClose: () => {
// Handle options UI closed - refresh any UI elements if needed
if (this.uiController) {
this.uiController.refreshFromPreferences();
}
}
});
// Link the options button to the options UI
this.setupOptionsButton();
// Update available system information
if (availableSystems && this.optionsUI) {
// Update the provider selection in the UI with available systems
console.log("Available TTS systems:", availableSystems);
// Disable unavailable systems in the UI
for (const system in availableSystems) {
if (!availableSystems[system]) {
console.log(`TTS system ${system} is not available, will be disabled in options`);
// The OptionsUI handles this internally
}
}
}
}
/**
* Link the options button to the options UI
*/
setupOptionsButton() {
// Get the options button or create it if needed
let optionsButton = document.getElementById('options');
if (optionsButton) {
// Connect options button to the UI
optionsButton.addEventListener('click', () => {
if (this.optionsUI) {
this.optionsUI.open();
} else {
console.warn("Options UI not initialized yet");
// Create options UI if it doesn't exist yet
this.initializeOptionsUI();
}
});
console.log("Options button connected to options UI");
} else {
console.warn("Options button not found in DOM");
}
}
}
/**
* Initialize the application when the window loads
*/
window.addEventListener('load', async () => {
// Define translations
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..."
}
};
// Create and initialize the application
window.app = new AnimatedFiction({
storyContainerId: 'story',
commandHistoryContainerId: 'command_history',
initialSpeed: 50,
locale: window.locale || 'en-us',
translations: translations
});
// Start the application with proper async initialization
await window.app.start();
});