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 .

-
-
-
- -

AI Interactive Fiction

-

An open-world text adventure

-
-
-
- speech - speed* - restart - save - load -
-
-
- -
- -
-
- - -
-
-
-
*click on page or press spacebar to fast forward text animation
-
- -
-
-
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 .

+
+
+
+ +

AI Interactive Fiction

+

An open-world text adventure

+
+
+
+ speech + speed* + restart + save + load +
+
+
+ +
+ +
+
+ + +
+
+
+
*click on page or press spacebar to fast forward text animation
+
+ +
+
+
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