Files
ai.interactive.fiction/public/js/ai-fiction.js
T

764 lines
33 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 { InkStoryPlayer } from './ink-story-player.js'; // Replaced by SocketClient logic
import { UiController } from './ui-controller.js';
// Assuming InputHandler and SocketClient are loaded globally via <script> tags in HTML
// If using modules properly, they would need imports:
// import { InputHandler } from './input-handler.js';
// import { SocketClient } from './socket-client.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
// Ensure InputHandler and SocketClient classes are available globally
if (typeof InputHandler === 'undefined' || typeof SocketClient === 'undefined') {
console.error("InputHandler or SocketClient class not found. Ensure scripts are loaded correctly.");
return; // Stop initialization if core components are missing
}
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
});