Files

841 lines
32 KiB
JavaScript

import { BaseModule } from './base-module.js';
import { ModuleEvent } from './base-module.js';
class UIControllerModule extends BaseModule {
constructor() {
super('ui-controller', 'UI Controller');
// Remove 'tts' from direct dependencies to break circular dependency
// UI Controller will access TTS through the Game Loop instead
this.dependencies = ['animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects', 'text-buffer', 'socket-client', 'sentence-queue', 'playback-coordinator', 'persistence-manager', 'tts-factory', 'options-ui'];
// References to sub-modules
this.displayHandler = null;
this.inputHandler = null;
this.effects = null;
// UI state
this.isReady = false;
this.isVisible = false;
// Book interface elements
this.bookElement = null;
this.leftPage = null;
this.rightPage = null;
this.storyElement = null;
// Additional module references
this.textBuffer = null;
this.ttsHandler = null;
this.socketClient = null;
this.animationQueue = null;
this.currentInputMode = document.documentElement.dataset.inputMode || 'none';
// Add TTS toggle state
this.ttsEnabled = false;
this.ttsAvailable = true; // Add TTS availability state
// Bind methods using the parent class bindMethods utility
this.bindMethods([
'initialize',
'handleCommand',
'setupBookInterface',
'applyBookSizing',
'setupEventListeners',
'setupMainUI',
'bindTopControls',
'syncTopControls',
'getStoredTtsPreference',
'setStoredTtsPreference',
'getStoredAppPreference',
'setStoredAppPreference',
'sliderValueFromSpeed',
'speedFromSliderValue',
'initializeTextBuffer',
'showUI',
'hideUI',
'clearDisplay',
'sendCommand',
'isInteractiveClickTarget',
'isChoiceAwaitingPlayer',
'updateButtonStates'
]);
}
async initialize() {
try {
this.reportProgress(0, 'Initializing UI Controller');
this.reportProgress(20, 'Setting up book interface');
// Set up book interface
this.setupBookInterface();
this.reportProgress(30, 'Getting module dependencies');
// Get module references using parent's getModule method
this.displayHandler = this.getModule('ui-display-handler');
this.inputHandler = this.getModule('ui-input-handler');
this.effects = this.getModule('ui-effects');
this.textBuffer = this.getModule('text-buffer');
this.socketClient = this.getModule('socket-client');
this.animationQueue = this.getModule('animation-queue');
// Check for required UI modules
if (!this.displayHandler) {
console.error('UI Controller: Display handler module not found');
return false;
}
if (!this.inputHandler) {
console.error('UI Controller: Input handler module not found');
return false;
}
if (!this.effects) {
console.error('UI Controller: UI effects module not found');
return false;
}
// Check for other required modules
if (!this.textBuffer) {
console.error('UI Controller: Text buffer module not found');
return false;
}
if (!this.socketClient) {
console.error('UI Controller: Socket client module not found');
return false;
}
if (!this.animationQueue) {
console.error('UI Controller: Animation queue module not found');
return false;
}
this.reportProgress(50, 'Setting up main UI');
// Initialize main UI container
await this.setupMainUI();
this.reportProgress(70, 'Setting up event listeners');
// Set up event listeners after the display handler has created controls
this.setupEventListeners();
this.bindTopControls();
this.syncTopControls();
requestAnimationFrame(() => {
this.bindTopControls();
this.syncTopControls();
});
setTimeout(() => {
this.bindTopControls();
this.syncTopControls();
}, 250);
this.reportProgress(80, 'Initializing text buffer');
// Initialize text buffer handler
this.initializeTextBuffer();
this.reportProgress(100, 'UI Controller ready');
this.isReady = true;
this.isVisible = true;
this.dispatchEvent(new ModuleEvent('ui:ready', { controller: this }));
// Start ambient effects
this.effects.startAmbientEffects();
return true;
} catch (error) {
console.error('Error initializing UI Controller:', error);
this.changeState('ERROR');
return false;
}
}
setupBookInterface() {
// Create or get the book interface elements
this.bookElement = document.getElementById('book');
this.leftPage = document.getElementById('page_left');
this.rightPage = document.getElementById('page_right');
this.storyElement = document.getElementById('story');
// Apply book sizing based on viewport
this.applyBookSizing();
// Set up window resize handler
const handleViewportResize = () => this.applyBookSizing();
window.addEventListener('resize', handleViewportResize);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', handleViewportResize);
}
if (window.ResizeObserver && document.body) {
this.bodyResizeObserver = new ResizeObserver(handleViewportResize);
this.bodyResizeObserver.observe(document.body);
}
}
applyBookSizing() {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const viewportAspectRatio = viewportWidth / viewportHeight;
const bookWidth = 1421;
const bookHeight = 799;
const bookScale = Math.min(viewportWidth / bookWidth, viewportHeight / bookHeight);
document.documentElement.style.setProperty('--book-width', `${bookWidth}px`);
document.documentElement.style.setProperty('--book-height', `${bookHeight}px`);
document.documentElement.style.setProperty('--book-scale', bookScale);
document.documentElement.style.setProperty('--viewport-aspect-ratio', viewportAspectRatio);
document.documentElement.style.setProperty(
'--viewport-dimension',
viewportWidth / viewportHeight > bookWidth / bookHeight ? 'vw' : 'vh'
);
document.dispatchEvent(new CustomEvent('book:scaled', {
detail: {
bookWidth,
bookHeight,
bookScale,
displayWidth: bookWidth * bookScale,
displayHeight: bookHeight * bookScale,
viewportWidth,
viewportHeight
}
}));
}
setupEventListeners() {
// Set up event listeners for menu buttons
const saveButton = document.getElementById('save');
const loadButton = document.getElementById('reload');
const restartButton = document.getElementById('rewind');
const optionsButton = document.getElementById('options');
// Get persistence manager module
const persistenceManager = this.getModule('persistence-manager');
const ttsFactory = this.getModule('tts-factory');
// Set up save button
if (saveButton) {
saveButton.addEventListener('click', (event) => {
event.preventDefault();
if (saveButton.getAttribute('disabled') === 'disabled') return;
document.dispatchEvent(new CustomEvent('ui:game:save'));
});
}
// Set up load button
if (loadButton) {
loadButton.addEventListener('click', (event) => {
event.preventDefault();
if (loadButton.getAttribute('disabled') === 'disabled') return;
document.dispatchEvent(new CustomEvent('ui:game:load'));
});
}
// Set up restart button
if (restartButton) {
restartButton.addEventListener('click', (event) => {
event.preventDefault();
event.__newGameHandled = true;
document.dispatchEvent(new CustomEvent('ui:game:restart'));
});
}
this.addEventListener(document, 'click', (event) => {
if (event.target && event.target.closest && event.target.closest('#rewind')) {
event.preventDefault();
if (event.__newGameHandled) return;
document.dispatchEvent(new CustomEvent('ui:game:restart'));
}
});
// Set up options button
if (optionsButton) {
optionsButton.addEventListener('click', () => {
document.dispatchEvent(new CustomEvent('ui:options:toggle'));
});
}
this.addEventListener(document, 'ui:command', (event) => {
if (!event.detail || event.detail.moduleId === this.id) return;
this.handleCommand(event.detail);
});
this.addEventListener(document, 'story:input-mode', (event) => {
this.currentInputMode = ['text', 'choice', 'end', 'none'].includes(event.detail) ? event.detail : 'none';
});
this.addEventListener(document, 'click', (event) => {
if (this.isInteractiveClickTarget(event.target)) {
return;
}
const playbackCoordinator = this.getModule('playback-coordinator');
const hasSkippablePause = document.documentElement.dataset.skippablePause === 'true';
if (((playbackCoordinator && playbackCoordinator.isPlaying) || hasSkippablePause) && !this.isChoiceAwaitingPlayer()) {
this.handleCommand({ type: 'continue', source: 'book-click' });
}
if (this.inputHandler && typeof this.inputHandler.focusInput === 'function') {
this.inputHandler.focusInput();
}
});
// Listen for book events
document.addEventListener('book:ready', () => {
this.bindTopControls();
this.syncTopControls();
this.updateButtonStates({
canSave: true,
canLoad: true,
canRestart: true
});
});
// Listen for restart events
document.addEventListener('story:restart', () => {
this.updateButtonStates({
canSave: true,
canLoad: false,
canRestart: false
});
});
// Listen for save events
document.addEventListener('story:save', () => {
this.updateButtonStates({
canSave: true,
canLoad: true,
canRestart: true
});
});
// Listen for TTS availability changes
document.addEventListener('tts:availability', (event) => {
if (event.detail && typeof event.detail.available === 'boolean') {
this.ttsAvailable = event.detail.available;
this.updateButtonStates();
}
});
// Listen for TTS state changes (from options UI or TTS player)
document.addEventListener('tts:stateChange', (event) => {
if (event.detail && typeof event.detail.enabled === 'boolean') {
this.ttsEnabled = event.detail.enabled;
this.updateButtonStates();
// Ensure persistence is updated
const currentPersistenceManager = this.getModule('persistence-manager');
if (currentPersistenceManager) {
currentPersistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
}
}
});
// Listen for TTS engine changes
document.addEventListener('tts:engine:change', (event) => {
// Update button states since TTS engine changed
this.updateButtonStates();
});
// Listen for TTS toggle events from other components
document.addEventListener('tts:enabled:change', (event) => {
if (event.detail && typeof event.detail.enabled === 'boolean') {
this.ttsEnabled = event.detail.enabled;
this.updateButtonStates();
// Ensure persistence is updated
const currentPersistenceManager = this.getModule('persistence-manager');
if (currentPersistenceManager) {
currentPersistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
}
}
});
document.addEventListener('preference-updated', (event) => {
const { category, key, value } = event.detail || {};
if (category !== 'tts') {
if (category === 'app' && key === 'autoplay') {
this.syncTopControls();
}
return;
}
if (key === 'enabled') {
this.ttsEnabled = value === true;
this.syncTopControls();
} else if (key === 'speed') {
this.syncTopControls();
}
});
// Listen for speed change events from other components
document.addEventListener('tts:speed:change', (event) => {
if (event.detail && typeof event.detail.speed === 'number') {
// Update the main UI speed slider
const speedSlider = document.getElementById('speed');
if (speedSlider) {
speedSlider.value = this.sliderValueFromSpeed(event.detail.speed);
}
// Save to persistence manager
const currentPersistenceManager = this.getModule('persistence-manager');
if (currentPersistenceManager) {
currentPersistenceManager.updatePreference('tts', 'speed', event.detail.speed);
}
}
});
}
sliderValueFromSpeed(speed) {
const value = Number.isFinite(Number(speed)) ? Number(speed) : 1;
return Math.round(Math.max(0.5, Math.min(2.0, value)) * 100);
}
speedFromSliderValue(value) {
const sliderValue = Number.isFinite(Number(value)) ? Number(value) : 100;
return Math.max(0.5, Math.min(2.0, sliderValue / 100));
}
bindTopControls() {
const speechToggle = document.getElementById('speech');
const autoplayToggle = document.getElementById('autoplay');
const speedSlider = document.getElementById('speed');
const speedReset = document.getElementById('speed_reset');
if (speechToggle && speechToggle.dataset.uiControllerBound !== 'true') {
speechToggle.dataset.uiControllerBound = 'true';
speechToggle.removeAttribute('disabled');
speechToggle.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
const persistenceManager = this.getModule('persistence-manager');
const ttsFactory = this.getModule('tts-factory');
const currentEnabled = this.getStoredTtsPreference('enabled', this.ttsEnabled);
const nextEnabled = !currentEnabled;
this.ttsEnabled = nextEnabled;
console.log(`UIController: Top speech toggle set to ${nextEnabled ? 'enabled' : 'disabled'}`);
this.setStoredTtsPreference('enabled', nextEnabled);
if (ttsFactory) {
if (nextEnabled) {
const preferredHandler = persistenceManager?.getPreference('tts', 'preferred_handler', 'none') || 'none';
if (preferredHandler !== 'none') {
await ttsFactory.setActiveHandler(preferredHandler);
}
} else {
await ttsFactory.disableAfterCurrentPlayback();
}
}
this.syncTopControls();
document.dispatchEvent(new CustomEvent('tts:enabled:change', {
detail: { enabled: nextEnabled, source: 'topbar' }
}));
});
}
if (autoplayToggle && autoplayToggle.dataset.uiControllerBound !== 'true') {
autoplayToggle.dataset.uiControllerBound = 'true';
autoplayToggle.removeAttribute('disabled');
autoplayToggle.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const nextAutoplay = !this.getStoredAppPreference('autoplay', true);
this.setStoredAppPreference('autoplay', nextAutoplay);
console.log(`UIController: Autoplay set to ${nextAutoplay ? 'enabled' : 'disabled'}`);
this.syncTopControls();
document.dispatchEvent(new CustomEvent('app:autoplay:change', {
detail: { enabled: nextAutoplay, source: 'topbar' }
}));
});
}
if (speedSlider && speedSlider.dataset.uiControllerBound !== 'true') {
speedSlider.dataset.uiControllerBound = 'true';
speedSlider.min = '50';
speedSlider.max = '200';
speedSlider.addEventListener('input', (event) => {
const speed = this.speedFromSliderValue(event.target.value);
document.dispatchEvent(new CustomEvent('animation:speed:change', {
detail: { speed }
}));
document.dispatchEvent(new CustomEvent('tts:speed:change', {
detail: { speed }
}));
this.setStoredTtsPreference('speed', speed);
});
}
if (speedReset && speedReset.dataset.uiControllerBound !== 'true') {
speedReset.dataset.uiControllerBound = 'true';
speedReset.addEventListener('click', () => {
const slider = document.getElementById('speed');
if (slider) {
slider.value = this.sliderValueFromSpeed(1);
slider.dispatchEvent(new Event('input'));
}
});
}
}
syncTopControls() {
this.bindTopControls();
this.ttsEnabled = this.getStoredTtsPreference('enabled', this.ttsEnabled) === true;
const speedSlider = document.getElementById('speed');
if (speedSlider) {
const speed = this.getStoredTtsPreference('speed', 1);
const value = String(this.sliderValueFromSpeed(speed));
if (speedSlider.value !== value) {
speedSlider.value = value;
}
}
this.updateButtonStates();
}
getStoredTtsPreference(key, defaultValue) {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
const value = persistenceManager.getPreference('tts', key, undefined);
if (typeof value !== 'undefined' && value !== null) {
return value;
}
}
try {
const raw = localStorage.getItem('ai-interactive-fiction-preferences');
if (raw) {
const prefs = JSON.parse(raw);
if (prefs && prefs.tts && Object.prototype.hasOwnProperty.call(prefs.tts, key)) {
return prefs.tts[key];
}
}
} catch (error) {
console.warn('UIController: Failed to read TTS preference fallback:', error);
}
return defaultValue;
}
setStoredTtsPreference(key, value) {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && typeof persistenceManager.updatePreference === 'function') {
persistenceManager.updatePreference('tts', key, value);
}
try {
const storageKey = 'ai-interactive-fiction-preferences';
const raw = localStorage.getItem(storageKey);
const prefs = raw ? JSON.parse(raw) : {};
prefs.tts = prefs.tts || {};
prefs.tts[key] = value;
localStorage.setItem(storageKey, JSON.stringify(prefs));
} catch (error) {
console.warn('UIController: Failed to write TTS preference fallback:', error);
}
}
getStoredAppPreference(key, defaultValue) {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
const value = persistenceManager.getPreference('app', key, undefined);
if (typeof value !== 'undefined' && value !== null) {
return value;
}
}
try {
const raw = localStorage.getItem('ai-interactive-fiction-preferences');
if (raw) {
const prefs = JSON.parse(raw);
if (prefs && prefs.app && Object.prototype.hasOwnProperty.call(prefs.app, key)) {
return prefs.app[key];
}
}
} catch (error) {
console.warn('UIController: Failed to read app preference fallback:', error);
}
return defaultValue;
}
setStoredAppPreference(key, value) {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && typeof persistenceManager.updatePreference === 'function') {
persistenceManager.updatePreference('app', key, value);
}
try {
const storageKey = 'ai-interactive-fiction-preferences';
const raw = localStorage.getItem(storageKey);
const prefs = raw ? JSON.parse(raw) : {};
prefs.app = prefs.app || {};
prefs.app[key] = value;
localStorage.setItem(storageKey, JSON.stringify(prefs));
} catch (error) {
console.warn('UIController: Failed to write app preference fallback:', error);
}
}
async setupMainUI() {
// Ensure all UI components exist
if (!this.bookElement || !this.leftPage || !this.rightPage || !this.storyElement) {
console.log('UI Controller: Creating missing UI elements');
this.displayHandler.setupBookStructure();
// Re-get elements
this.bookElement = document.getElementById('book');
this.leftPage = document.getElementById('page_left');
this.rightPage = document.getElementById('page_right');
this.storyElement = document.getElementById('story');
}
if (this.inputHandler && typeof this.inputHandler.focusInput === 'function') {
requestAnimationFrame(() => this.inputHandler.focusInput());
}
}
initializeTextBuffer() {
// Connect SentenceQueue to UIDisplayHandler
const sentenceQueue = this.getModule('sentence-queue');
const displayHandler = this.getModule('ui-display-handler');
if (!sentenceQueue || !displayHandler) {
console.error('UIController: Required modules not found (sentence-queue or ui-display-handler)');
return;
}
console.log('UIController: Setting up SentenceQueue → UIDisplayHandler pipeline');
// Set up callback for when sentences are ready to display
sentenceQueue.setOnSentenceReady(async (sentence, callback) => {
try {
console.log(`UIController: Rendering sentence ${sentence.id}`);
await displayHandler.renderSentence(sentence);
console.log(`UIController: Sentence ${sentence.id} rendered successfully`);
// Signal completion to process next sentence
if (typeof callback === 'function') {
callback();
}
} catch (error) {
console.error('UIController: Error rendering sentence:', error);
// Still proceed to prevent blocking
if (typeof callback === 'function') {
callback();
}
}
});
console.log('UIController: SentenceQueue pipeline configured');
}
isInteractiveClickTarget(target) {
if (!target || typeof target.closest !== 'function') {
return false;
}
return Boolean(target.closest([
'a',
'button',
'input',
'textarea',
'select',
'label',
'[role="button"]',
'[role="link"]',
'[data-control]',
'#controls',
'#player_input',
'#command_input',
'#story_scrollbar',
'#story_choices',
'#options-modal',
'.modal',
'.modal-content',
'.credits-modal',
'.credits-dialog',
'.story-popup-modal',
'.story-popup-dialog',
'.choice-button',
'.volume-toggle'
].join(',')));
}
isChoiceAwaitingPlayer() {
if (this.currentInputMode !== 'choice') {
return false;
}
const choicePanel = document.getElementById('story_choices');
return Boolean(choicePanel && !choicePanel.hidden && choicePanel.dataset.choiceReady === 'true');
}
handleCommand(command) {
// Route commands to appropriate handlers
switch (command.type) {
case 'display':
this.displayHandler.processCommand(command);
break;
case 'effect':
this.effects.processCommand(command);
break;
case 'continue':
{
if (this.isChoiceAwaitingPlayer()) {
return;
}
document.dispatchEvent(new CustomEvent('ui:command', {
detail: { moduleId: this.id, type: 'continue', source: command.source || 'ui-controller-forward' }
}));
const playbackCoordinator = this.getModule('playback-coordinator');
if (playbackCoordinator && playbackCoordinator.isPlaying) {
playbackCoordinator.fastForward();
} else if (this.animationQueue) {
this.animationQueue.fastForward();
}
}
break;
case 'input':
if (this.socketClient) {
console.log(`UI Controller: Sending command to socket: "${command.text}"`);
const success = this.socketClient.sendCommand(command.text);
if (success) {
console.log('UI Controller: Command sent successfully');
} else {
console.error('UI Controller: Failed to send command to socket');
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'command-send-failed' }
}));
}
} else {
console.error('UI Controller: Socket client not available for sending commands');
}
break;
case 'menu':
// Toggle options menu
const optionsUI = this.getModule('options-ui');
if (optionsUI) {
optionsUI.toggle();
}
break;
default:
// Handle general UI commands or pass to game logic
this.dispatchEvent(new ModuleEvent('ui:command', command));
}
}
/**
* Update UI button states based on game state
*/
updateButtonStates(state = {}) {
const { canSave, canLoad, canRestart } = state;
// Get button elements
const saveButton = document.getElementById('save');
const loadButton = document.getElementById('reload');
const restartButton = document.getElementById('rewind');
const speechToggle = document.getElementById('speech');
const autoplayToggle = document.getElementById('autoplay');
// Update save button state
if (saveButton && typeof canSave === 'boolean') {
if (canSave) {
saveButton.removeAttribute('disabled');
} else {
saveButton.setAttribute('disabled', 'disabled');
}
}
// Update load button state
if (loadButton && typeof canLoad === 'boolean') {
if (canLoad) {
loadButton.removeAttribute('disabled');
} else {
loadButton.setAttribute('disabled', 'disabled');
}
}
// Update restart button state
if (restartButton && typeof canRestart === 'boolean') {
if (canRestart) {
restartButton.removeAttribute('disabled');
} else {
restartButton.setAttribute('disabled', 'disabled');
}
}
if (typeof state.gameStarted === 'boolean') {
document.body.dataset.gameRunning = state.gameStarted ? 'true' : 'false';
}
// Update speech toggle button state
if (speechToggle) {
speechToggle.removeAttribute('disabled');
speechToggle.setAttribute('aria-pressed', this.ttsEnabled ? 'true' : 'false');
speechToggle.classList.toggle('is-active', this.ttsEnabled);
speechToggle.classList.toggle('is-inactive', !this.ttsEnabled);
if (this.ttsEnabled) {
speechToggle.title = this.ttsAvailable ? 'Disable speech' : 'Speech enabled, selected provider is not ready';
} else {
speechToggle.title = 'Enable speech';
}
}
if (autoplayToggle) {
const autoplay = this.getStoredAppPreference('autoplay', true) !== false;
autoplayToggle.removeAttribute('disabled');
autoplayToggle.setAttribute('aria-pressed', autoplay ? 'true' : 'false');
autoplayToggle.classList.toggle('is-active', autoplay);
autoplayToggle.classList.toggle('is-inactive', !autoplay);
}
}
// Public API methods
showUI() {
if (!this.isVisible) {
this.isVisible = true;
this.displayHandler.show();
this.effects.startAmbientEffects();
}
}
hideUI() {
if (this.isVisible) {
this.isVisible = false;
this.displayHandler.hide();
this.effects.stopAmbientEffects();
}
}
clearDisplay() {
this.displayHandler.clear();
}
sendCommand(command) {
if (this.socketClient) {
return this.socketClient.sendCommand(command);
}
return false;
}
}
// Create the singleton instance
const uiController = new UIControllerModule();
// Export the module
export { uiController as UIController };