diff --git a/public/index.html b/public/index.html
index cfac093..eaf0d85 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,97 +1,115 @@
-
-
-
-
-
-
-
- ai-fiction Book Runtime
-
-
-
- We are using Node.js ,
- Chromium ,
- and Electron .
-
-
- What do you want to do next?
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+ ai-fiction Book Runtime (Modular Version)
+
+
+
+ We are using Node.js ,
+ Chromium ,
+ and Electron .
+
+
+ What do you want to do next?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/js/ai-fiction.js b/public/js/ai-fiction.js
index 3b02605..015441e 100644
--- a/public/js/ai-fiction.js
+++ b/public/js/ai-fiction.js
@@ -1,761 +1,763 @@
-/**
- * AI Interactive Fiction
- * Main client-side logic for web interface
- */
-class AIFiction {
- constructor() {
- // DOM elements
- this.storyContainer = document.getElementById('story');
- this.commandHistoryContainer = document.getElementById('command_history');
- this.playerInput = document.getElementById('player_input');
- this.speechButton = document.getElementById('speech');
- this.rewindButton = document.getElementById('rewind');
- this.saveButton = document.getElementById('save');
- this.loadButton = document.getElementById('reload');
- this.speedSlider = document.getElementById('speed');
- this.speedReset = document.getElementById('speed_reset');
-
- // Game state
- this.gameState = {
- started: false,
- currentRoomId: '',
- textSpeed: 50
- };
-
- // Socket connection - ensure we're connecting to the right URL
- this.socket = io(window.location.origin, {
- reconnectionAttempts: 5,
- timeout: 10000
- });
-
- // Typing effect configuration
- this.typingSpeed = 30; // Default value, will be adjusted by slider
- this.typingTimeout = null;
-
- // Bind event handlers
- this.bindEvents();
-
- // Initialize socket communication
- this.initializeSocket();
-
- // Initialize UI (TTS part will be updated by event)
- this.initializeUI();
-
- // Listen for TTS readiness
- this.listenForTTSReady();
-
- // Set up focus management
- this.setupFocusManagement();
- }
-
- /**
- * Check if kokoro-js is loaded
- */
- checkForKokoroJs() {
- try {
- // With our TTS factory in place, we don't need to manually check for kokoro
- // as the factory will handle loading and fallback automatically
- console.log("TTS Factory will handle initialization of speech systems");
- } catch (e) {
- console.warn("Error checking for TTS systems:", e);
- }
- }
-
- /**
- * Initialize the UI (Initial state, TTS updated later)
- */
- initializeUI() {
- this.updateTypingSpeed();
- // Start with speech button disabled, will be enabled by tts-ready event
- this.speechButton.setAttribute('disabled', 'disabled');
- this.speechButton.setAttribute('title', 'Initializing Text-to-Speech...');
- this.updateSpeechButton(false);
-
- // Disable other buttons initially
- this.rewindButton.setAttribute('disabled', 'disabled');
- this.loadButton.setAttribute('disabled', 'disabled');
-
- // Start the game (if socket is ready)
- if (this.socket && this.socket.connected) {
- this.startGame();
- } else {
- console.log("Waiting for socket connection to start game...");
- }
- }
-
- /**
- * Listen for the tts-ready event from the factory
- */
- listenForTTSReady() {
- window.addEventListener('tts-ready', (event) => {
- console.log('Received tts-ready event:', event.detail);
- const { available, type, handler } = event.detail;
-
- if (available) {
- console.log(`TTS System active: ${type}`);
- this.speechButton.removeAttribute('disabled');
- const ttsName = type === 'kokoro' ? 'Kokoro TTS' : 'Browser TTS';
- this.speechButton.setAttribute('title', `Text-to-Speech (${ttsName})`);
- // Ensure the button style reflects the initial state (off)
- this.updateSpeechButton(window.ttsHandler ? window.ttsHandler.isEnabled() : false);
- } else {
- console.warn("No TTS system available after initialization.");
- this.speechButton.setAttribute('disabled', 'disabled');
- this.speechButton.setAttribute('title', 'Text-to-Speech not available');
- this.updateSpeechButton(false);
- }
- });
- }
-
- /**
- * Helper function to get precise caret coordinates in a textarea
- * @param {HTMLTextAreaElement} element - The textarea element
- * @param {number} position - The caret position
- * @return {Object} Object with top and left coordinates
- */
- getCaretCoordinates(element, position) {
- // Create a range to represent the caret
- const range = document.createRange();
- const textNode = document.createTextNode(element.value.substring(0, position));
- const span = document.createElement('span');
- span.appendChild(textNode);
-
- // Create a temporary div
- const div = document.createElement('div');
- div.style.position = 'absolute';
- div.style.top = '-9999px';
- div.style.left = '-9999px';
- div.style.width = element.offsetWidth + 'px';
- div.style.whiteSpace = 'pre-wrap';
- div.style.wordWrap = 'break-word';
- div.style.fontFamily = window.getComputedStyle(element).fontFamily;
- div.style.fontSize = window.getComputedStyle(element).fontSize;
- div.style.lineHeight = window.getComputedStyle(element).lineHeight;
- div.style.padding = window.getComputedStyle(element).padding;
-
- // Append everything to the DOM
- div.appendChild(span);
- document.body.appendChild(div);
-
- // Measure the position
- const coordinates = {
- top: span.offsetTop,
- left: span.offsetWidth
- };
-
- // Clean up
- document.body.removeChild(div);
-
- return coordinates;
- }
-
- /**
- * Update the custom cursor position based on input text and caret position
- * Enhanced version with simpler, more reliable positioning
- */
- updateCursorPosition() {
- const input = this.playerInput;
- const cursor = document.getElementById('cursor');
-
- if (!cursor || !input) return;
-
- // Get the current caret position
- const caretPosition = input.selectionStart || 0;
- const inputText = input.value;
-
- if (inputText.length === 0) {
- // If no text, position cursor at the beginning (placeholder visible)
- cursor.style.left = '0px';
- cursor.style.top = '6px'; // Default top position
- return;
- }
-
- // Auto-adjust textarea height based on content
- this.adjustTextareaHeight();
-
- // Use a more reliable method to get cursor position:
- // Create a temporary element that exactly duplicates the input's content and styling
- const div = document.createElement('div');
- div.style.position = 'absolute';
- div.style.top = '-9999px';
- div.style.left = '-9999px';
- div.style.width = getComputedStyle(input).width;
- div.style.height = 'auto';
- div.style.padding = getComputedStyle(input).padding;
- div.style.border = getComputedStyle(input).border;
- div.style.fontFamily = getComputedStyle(input).fontFamily;
- div.style.fontSize = getComputedStyle(input).fontSize;
- div.style.fontWeight = getComputedStyle(input).fontWeight;
- div.style.lineHeight = getComputedStyle(input).lineHeight;
- div.style.whiteSpace = 'pre-wrap';
- div.style.wordWrap = 'break-word';
- div.style.boxSizing = getComputedStyle(input).boxSizing;
-
- // Create three spans to help us position the cursor
- const preCaretText = document.createElement('span');
- preCaretText.textContent = inputText.substring(0, caretPosition);
-
- const caretChar = document.createElement('span');
- caretChar.textContent = '|'; // Visible cursor marker for measurement
- caretChar.style.display = 'inline-block';
- caretChar.style.width = '0';
-
- const postCaretText = document.createElement('span');
- postCaretText.textContent = inputText.substring(caretPosition);
-
- // Add all elements to the DOM
- div.appendChild(preCaretText);
- div.appendChild(caretChar);
- div.appendChild(postCaretText);
- document.body.appendChild(div);
-
- // Get position of the caret marker
- const caretRect = caretChar.getBoundingClientRect();
- const inputRect = input.getBoundingClientRect();
-
- // Set cursor position
- // We need to account for the input's scroll position
- cursor.style.left = (caretRect.left - div.getBoundingClientRect().left) + 'px';
- cursor.style.top = (caretRect.top - div.getBoundingClientRect().top - input.scrollTop) + 'px';
-
- // Clean up
- document.body.removeChild(div);
- }
-
- /**
- * Adjust textarea height based on its content
- */
- adjustTextareaHeight() {
- const textarea = this.playerInput;
- if (!textarea) return;
-
- // Reset height to auto to get the correct scrollHeight
- textarea.style.height = 'auto';
-
- // Set height to scrollHeight to fit content
- textarea.style.height = textarea.scrollHeight + 'px';
- }
-
- /**
- * Bind event handlers to DOM elements
- */
- 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();
- } else if (e.key === 'Enter' && e.shiftKey) {
- // Allow Shift+Enter to create a new line
- // Default behavior happens, no need to do anything
- }
- });
-
- // Auto-resize textarea 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));
- this.playerInput.addEventListener('focus', () => {
- document.getElementById('cursor').style.opacity = '1';
- this.updateCursorPosition();
- });
-
- this.playerInput.addEventListener('blur', () => {
- document.getElementById('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();
- });
-
- // Toggle speech
- this.speechButton.addEventListener('click', () => {
- // Check if the handler is available (it should be if button is enabled)
- if (window.ttsHandler) {
- // Ensure AudioContext is resumed on user interaction if using Kokoro
- if (window.ttsFactory && window.ttsFactory.usingKokoro && window.ttsHandler.audioContext && window.ttsHandler.audioContext.state === 'suspended') {
- window.ttsHandler.audioContext.resume().catch(err => console.error('Error resuming AudioContext on click:', err));
- }
-
- // Set user activation flag for the handler
- window.ttsHandler.hasUserActivation = true;
- const enabled = window.ttsHandler.toggle();
- this.updateSpeechButton(enabled);
-
- if (enabled) {
- // Speak the last narrative if speech was just enabled
- const lastNarrative = this.storyContainer.lastElementChild;
- if (lastNarrative && lastNarrative.classList.contains('narrative')) {
- console.log("Speaking last narrative on toggle");
- // Use a slight delay to ensure audio context is resumed
- setTimeout(() => window.ttsHandler.speak(lastNarrative.textContent), 50);
- }
-
- // Update the tooltip with active TTS system info
- if (window.ttsFactory) {
- const ttsInfo = window.ttsFactory.getActiveTTSInfo();
- this.speechButton.setAttribute('title', `Text-to-Speech (${ttsInfo.name})`);
- }
- } else {
- // If disabling, ensure speech stops
- window.ttsHandler.stop();
- }
- } else {
- console.log('TTS handler not available when speech button clicked.');
- // Optionally show an alert or keep button disabled
- }
- });
-
- // Restart game
- this.rewindButton.addEventListener('click', () => {
- if (confirm('Are you sure you want to restart the game? All progress will be lost.')) {
- this.startGame();
- }
- });
-
- // Save game
- this.saveButton.addEventListener('click', () => {
- this.socket.emit('saveGame');
- });
-
- // Load game
- this.loadButton.addEventListener('click', () => {
- this.socket.emit('loadGame');
- });
-
- // Adjust typing speed
- this.speedSlider.addEventListener('input', () => {
- this.updateTypingSpeed();
- });
-
- // Reset speed to default
- this.speedReset.addEventListener('click', () => {
- this.speedSlider.value = 50;
- this.updateTypingSpeed();
- });
- }
-
- /**
- * Initialize socket event handlers
- */
- initializeSocket() {
- // Connection established
- this.socket.on('connect', () => {
- console.log('Connected to server');
- // Automatically start the game once connected
- if (!this.gameState.started) {
- this.startGame();
- }
- });
-
- // Connection error
- this.socket.on('connect_error', (error) => {
- console.error('Connection error:', error);
- this.addSystemMessage('Connection error. Please check your network connection and try again.');
- });
-
- // Game introduction received
- this.socket.on('gameIntroduction', (data) => {
- this.clearStory();
- this.addNarrative(data.introduction);
- this.addNarrative(data.initialRoomDescription);
-
- this.gameState.started = true;
- this.gameState.currentRoomId = data.currentRoomId;
-
- // Enable buttons
- this.rewindButton.removeAttribute('disabled');
-
- // Focus on input field
- this.playerInput.focus();
- });
-
- // Narrative response received
- this.socket.on('narrativeResponse', (data) => {
- // Clear any pending "thinking" indicators
- if (this.currentCommandTimeout) {
- clearTimeout(this.currentCommandTimeout);
- this.currentCommandTimeout = null;
-
- // Remove any existing thinking indicators
- document.querySelectorAll('.thinking').forEach(el => el.remove());
- }
-
- this.addNarrative(data.text);
-
- if (data.suggestions && data.suggestions.length > 0) {
- this.addSuggestions(data.suggestions);
- }
-
- // Update game state
- if (data.gameState) {
- this.gameState.currentRoomId = data.gameState.currentRoomId;
- }
-
- // Scroll to bottom and focus input
- this.scrollToBottom();
- this.playerInput.focus();
-
- // Re-enable input (failsafe)
- this.playerInput.disabled = false;
- });
-
- // Game saved confirmation
- this.socket.on('gameSaved', () => {
- this.addSystemMessage('Game saved successfully.');
- // Enable load button
- this.loadButton.removeAttribute('disabled');
- });
-
- // Game loaded confirmation
- this.socket.on('gameLoaded', (data) => {
- this.clearStory();
- this.addSystemMessage('Game loaded successfully.');
- this.addNarrative(data.currentRoomDescription);
-
- // Update game state
- this.gameState.currentRoomId = data.currentRoomId;
- });
-
- // Error messages
- this.socket.on('error', (data) => {
- this.addSystemMessage(`Error: ${data.message}`);
- });
- }
-
- /**
- * Start a new game
- */
- startGame() {
- this.clearStory();
- this.addSystemMessage('Starting a new game...');
- this.socket.emit('startGame');
- }
-
- /**
- * Submit a player command
- */
- submitCommand() {
- const command = this.playerInput.value.trim();
-
- if (command === '') return;
-
- // Fade out the input field
- const commandInput = document.getElementById('command_input');
- commandInput.classList.add('fading');
-
- // Disable input temporarily
- this.playerInput.disabled = true;
-
- // Add command to history
- this.addUserCommand(command);
-
- // Add a temporary "thinking" message
- const thinkingId = this.addThinking();
-
- // Send command to server
- this.socket.emit('playerCommand', { command });
-
- // Clear input
- this.playerInput.value = '';
-
- // Reset cursor position to the start
- const cursor = document.getElementById('cursor');
- if (cursor) {
- cursor.style.left = '0px';
- cursor.style.top = '6px';
- }
-
- // Reset textarea height
- this.adjustTextareaHeight();
-
- // Re-enable input field after a short delay (or after 8 seconds as failsafe)
- const timeout = setTimeout(() => {
- // Remove fading class and add fade-in animation
- commandInput.classList.remove('fading');
- commandInput.classList.add('fade-in-input');
-
- // Remove animation class after it completes
- setTimeout(() => {
- commandInput.classList.remove('fade-in-input');
- }, 500);
-
- this.playerInput.disabled = false;
- this.playerInput.focus();
-
- // Remove thinking indicator
- const thinkingElement = document.getElementById(thinkingId);
- if (thinkingElement) {
- thinkingElement.remove();
- }
-
- // Add system message if no response was received (likely timeout)
- if (document.getElementById(thinkingId)) {
- this.addSystemMessage('The server is taking too long to respond. Please try again.');
- }
- }, 8000);
-
- // Store the timeout so it can be cleared if we get a response
- this.currentCommandTimeout = timeout;
- }
-
- /**
- * Add a user command to the story
- */
- addUserCommand(command) {
- const element = document.createElement('p');
- element.className = 'user-input';
- element.textContent = `> ${command}`;
- this.storyContainer.appendChild(element);
- this.scrollToBottom();
- }
-
- /**
- * Add a narrative response with typing effect
- */
- addNarrative(text) {
- const element = document.createElement('p');
- element.className = 'narrative hide';
- this.storyContainer.appendChild(element);
-
- // Apply SmartyPants transformations for better typography if available
- const processedText = window.SmartyPants && typeof window.SmartyPants.smartypantsu === 'function'
- ? window.SmartyPants.smartypantsu(text, 1)
- : text;
-
- // Clear any existing typing timeouts
- if (this.typingTimeout) {
- clearTimeout(this.typingTimeout);
- }
-
- // Add the text with a typing effect
- this.typeText(element, processedText, 0);
-
- // Read text aloud if speech is enabled
- if (window.ttsHandler && window.ttsHandler.isEnabled()) {
- console.log("Speaking narrative text with TTS");
- window.ttsHandler.speak(text);
- }
- }
-
- /**
- * Add suggestions to the story
- */
- addSuggestions(suggestions) {
- const element = document.createElement('div');
- element.className = 'suggestions';
-
- const heading = document.createElement('p');
- heading.textContent = 'Suggestions:';
- heading.style.fontStyle = 'italic';
- heading.style.marginTop = '1rem';
- element.appendChild(heading);
-
- const list = document.createElement('ul');
- suggestions.forEach(suggestion => {
- const item = document.createElement('li');
- item.textContent = suggestion;
-
- // Make suggestions clickable
- item.style.cursor = 'pointer';
- item.addEventListener('click', () => {
- this.playerInput.value = suggestion;
- this.submitCommand();
- });
-
- list.appendChild(item);
- });
- element.appendChild(list);
-
- this.storyContainer.appendChild(element);
- this.scrollToBottom();
- }
-
- /**
- * Add a system message
- */
- addSystemMessage(message) {
- const element = document.createElement('p');
- element.className = 'system-message';
- element.textContent = message;
- element.style.fontStyle = 'italic';
- element.style.color = '#555';
- this.storyContainer.appendChild(element);
- this.scrollToBottom();
- }
-
- /**
- * Add a thinking indicator
- */
- addThinking() {
- const id = 'thinking-' + Date.now();
- const element = document.createElement('div');
- element.id = id;
- element.className = 'thinking';
- element.innerHTML = 'Thinking...
';
- element.style.fontStyle = 'italic';
- element.style.color = '#777';
- this.storyContainer.appendChild(element);
- this.scrollToBottom();
- return id;
- }
-
- /**
- * Clear the story container
- */
- clearStory() {
- while (this.storyContainer.firstChild) {
- this.storyContainer.removeChild(this.storyContainer.firstChild);
- }
- }
-
- /**
- * Type text into an element character by character
- */
- typeText(element, text, index) {
- // Show the element if it was hidden
- if (index === 0) {
- element.classList.remove('hide');
- }
-
- // Set the current text
- element.textContent = text.substring(0, index);
-
- // If we haven't reached the end of the text
- if (index < text.length) {
- // Calculate delay (randomize slightly for more natural effect)
- const delay = Math.max(10, 100 - this.gameState.textSpeed) / 5;
- const randomDelay = delay * (0.8 + Math.random() * 0.4);
-
- // Schedule the next character
- this.typingTimeout = setTimeout(() => {
- this.typeText(element, text, index + 1);
- }, randomDelay);
- } else {
- // Finished typing
- this.scrollToBottom();
- }
- }
-
- /**
- * Update the typing speed based on the slider value
- */
- updateTypingSpeed() {
- this.gameState.textSpeed = parseInt(this.speedSlider.value, 10);
- }
-
- /**
- * Update the speech button styling
- */
- updateSpeechButton(enabled = false) {
- 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 = '';
- }
- }
-
- /**
- * Scroll the story container to the bottom
- */
- scrollToBottom() {
- const container = document.getElementById('page_right');
- if (container) {
- container.scrollTop = container.scrollHeight;
- }
- }
-
- /**
- * Set up focus management to ensure input field is always focused
- */
- setupFocusManagement() {
- // Focus input field when the page loads
- window.addEventListener('load', () => {
- // Force immediate focus on load
- this.playerInput.focus();
-
- // Some browsers might need a slight delay
- setTimeout(() => this.playerInput.focus(), 100);
-
- // Also adjust textarea height and update cursor position
- this.adjustTextareaHeight();
- this.updateCursorPosition();
- });
-
- // Focus input when user clicks anywhere in the document
- document.addEventListener('click', (e) => {
- // Don't steal focus if user is clicking on a button or link
- if (
- e.target.tagName !== 'BUTTON' &&
- e.target.tagName !== 'A' &&
- !e.target.classList.contains('suggestions') &&
- !e.target.closest('.suggestions')
- ) {
- this.playerInput.focus();
- }
- });
-
- // Re-focus input when user returns to this browser tab
- window.addEventListener('focus', () => {
- this.playerInput.focus();
- });
-
- // Re-focus input when user returns to the window
- window.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'visible') {
- setTimeout(() => this.playerInput.focus(), 100);
- }
- });
-
- // Focus on input after narrative is added
- const originalAddNarrative = this.addNarrative.bind(this);
- this.addNarrative = (text) => {
- originalAddNarrative(text);
- // Short timeout to ensure rendering completes
- setTimeout(() => this.playerInput.focus(), 10);
- };
- }
-}
-
-// Create the application when the DOM is fully loaded
-document.addEventListener('DOMContentLoaded', () => {
- // Set custom CSS variables based on viewport
- const updateViewportVariables = () => {
- const vw = window.innerWidth;
- const vh = window.innerHeight;
- document.documentElement.style.setProperty('--viewport-aspect-ratio', `${vw / vh}`);
-
- // Adjust book size based on viewport
- const bookWidth = Math.min(vw * 0.9, vh * 1.4);
- const bookHeight = bookWidth / 1.613;
- document.documentElement.style.setProperty('--book-width', `${bookWidth}px`);
- document.documentElement.style.setProperty('--book-height', `${bookHeight}px`);
- };
-
- // Update variables initially and on resize
- updateViewportVariables();
- window.addEventListener('resize', updateViewportVariables);
-
- // Initialize the application
- window.app = new AIFiction();
-});
\ No newline at end of file
+/**
+ * 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