Minor cleanup.

This commit is contained in:
2025-04-04 00:02:28 +00:00
parent aa29a6fd93
commit 02c7b9ef28
9 changed files with 2202 additions and 91 deletions
+441
View File
@@ -0,0 +1,441 @@
/**
* UiController Module
* Manages user interface interactions and updates UI elements.
*/
export class UiController {
/**
* Create a new UiController
* @param {Object} config - Configuration options
* @param {Object} config.animationQueue - The AnimationQueue instance
* @param {Object} config.ttsPlayer - The TtsPlayer instance
* @param {Object} config.inputHandler - The InputHandler instance
* @param {Object} config.socketClient - The SocketClient instance (or rely on callbacks)
* @param {HTMLElement} config.commandHistoryContainerElement - The command history container
* @param {HTMLElement} config.storyContainerElement - The story container
* @param {HTMLElement} config.speedSliderElement - The speed slider element
* @param {HTMLElement} config.rewindButtonElement - The rewind button element
* @param {HTMLElement} config.saveButtonElement - The save button element
* @param {HTMLElement} config.loadButtonElement - The load button element
* @param {HTMLElement} config.speechButtonElement - The speech button element
* @param {HTMLElement} config.speedResetElement - The speed reset button element
* @param {Object} config.translations - Translations object
* @param {string} config.locale - Locale string
*/
constructor(config = {}) {
// Store dependencies
this.animationQueue = config.animationQueue;
this.ttsPlayer = config.ttsPlayer; // Handles enabling/disabling TTS via its own logic
this.inputHandler = config.inputHandler; // Needed for focus, suggestions?
this.socketClient = config.socketClient; // Direct access or use callbacks
// Callbacks for actions (to be set by AnimatedFiction)
this.onRestartRequest = null;
this.onSaveRequest = null;
this.onLoadRequest = null;
// Active TTS handler (set via setTtsHandler)
this.ttsHandler = null;
// UI elements
this.speedSlider = config.speedSliderElement || document.getElementById('speed');
this.commandHistoryContainer = config.commandHistoryContainerElement; // Added
this.storyContainer = config.storyContainerElement; // Added
this.rewindButton = config.rewindButtonElement || document.getElementById('rewind');
this.saveButton = config.saveButtonElement || document.getElementById('save');
this.loadButton = config.loadButtonElement || document.getElementById('reload');
this.speechButton = config.speechButtonElement || document.getElementById('speech');
this.speedReset = config.speedResetElement || document.getElementById('speed_reset');
// Translations
this.translations = config.translations || {};
this.locale = config.locale || 'en-us';
// Initial UI state
this.updateButtonStates({ started: false, canLoad: false }); // Start with buttons disabled
this.updateSpeechButtonAvailability(false); // Start with speech disabled
}
/**
* Set up event listeners
*/
setupEventListeners() {
// Speed slider
if (this.speedSlider) {
this.speedSlider.addEventListener('input', this.handleSpeedChange.bind(this));
}
// Speed reset button
if (this.speedReset) {
this.speedReset.addEventListener('click', this.handleSpeedReset.bind(this));
}
// Rewind button
if (this.rewindButton) {
this.rewindButton.addEventListener('click', this.handleRewindClick.bind(this));
}
// Save button
if (this.saveButton) {
this.saveButton.addEventListener('click', this.handleSaveClick.bind(this));
}
// Load button
if (this.loadButton) {
this.loadButton.addEventListener('click', this.handleLoadClick.bind(this));
}
// Speech button
if (this.speechButton) {
this.speechButton.addEventListener('click', this.handleSpeechToggle.bind(this));
}
// Fast forward (spacebar or click on right page)
window.addEventListener('keydown', (event) => {
if (event.code === 'Space') {
this.handleFastForward();
}
});
document.getElementById('page_right')?.addEventListener('click', this.handleFastForward.bind(this));
// Window resize
window.addEventListener('resize', this.handleWindowResize.bind(this));
}
/**
* Handle speed slider change
* @param {Event} event - The input event
*/
handleSpeedChange(event) {
if (!this.animationQueue) return;
const value = parseFloat(event.target.value);
const speed = Math.pow(100.0 - value, 3) / 10000 * 10 + 0.01;
this.animationQueue.setSpeed(speed);
}
/**
* Handle speed reset button click
*/
handleSpeedReset() {
if (!this.speedSlider || !this.animationQueue) return;
this.speedSlider.value = 50;
const speed = Math.pow(100.0 - 50, 3) / 10000 * 10 + 0.01;
this.animationQueue.setSpeed(speed);
}
/**
* Handle rewind button click
*/
handleRewindClick() {
if (this.rewindButton.getAttribute('disabled') === 'disabled') {
return;
}
// Use localized confirm message if available
const confirmMsg = this.translations[this.locale]?.confirm_restart || 'Are you sure you want to restart the game? All progress will be lost.';
if (confirm(confirmMsg)) {
if (this.onRestartRequest) {
this.onRestartRequest();
} else {
console.warn("UiController: onRestartRequest callback not set.");
}
}
}
/**
* Handle save button click
*/
handleSaveClick() {
if (this.saveButton.getAttribute('disabled') === 'disabled') {
return;
}
if (this.onSaveRequest) {
this.onSaveRequest();
} else {
console.warn("UiController: onSaveRequest callback not set.");
}
}
/**
* Handle load button click
*/
handleLoadClick() {
if (this.loadButton.getAttribute('disabled') === 'disabled') {
return;
}
if (this.onLoadRequest) {
this.onLoadRequest();
} else {
console.warn("UiController: onLoadRequest callback not set.");
}
}
/**
* Handle speech toggle button click
*/
handleSpeechToggle() {
if (!this.ttsHandler) {
console.warn("UiController: ttsHandler not set. Cannot toggle speech.");
// Attempt to use ttsPlayer as fallback if needed, but prefer ttsHandler
if (this.ttsPlayer && this.speechButton.getAttribute('disabled') !== 'disabled') {
const enabled = this.ttsPlayer.toggle();
this.updateSpeechButtonStyling(enabled);
}
return;
}
if (this.speechButton.getAttribute('disabled') === 'disabled') {
return;
}
// Ensure AudioContext is resumed on user interaction if using Kokoro
if (window.ttsFactory && window.ttsFactory.usingKokoro && this.ttsHandler.audioContext && this.ttsHandler.audioContext.state === 'suspended') {
this.ttsHandler.audioContext.resume().catch(err => console.error('Error resuming AudioContext on click:', err));
}
// Set user activation flag for the handler
this.ttsHandler.hasUserActivation = true;
const enabled = this.ttsHandler.toggle();
this.updateSpeechButtonStyling(enabled); // Update visual style
if (enabled) {
// Speak the last narrative if speech was just enabled and story container is available
if (this.storyContainer) {
const lastNarrative = this.storyContainer.lastElementChild;
if (lastNarrative && lastNarrative.classList.contains('narrative')) { // Check if it's narrative text
console.log("Speaking last narrative on toggle");
// Use a slight delay to ensure audio context is resumed
setTimeout(() => this.ttsHandler.speak(lastNarrative.textContent), 50);
}
}
} else {
// If disabling, ensure speech stops
this.ttsHandler.stop();
}
}
/**
* Handle fast forward (spacebar or click)
*/
handleFastForward() {
if (!this.animationQueue) return;
this.animationQueue.fastForward();
}
/**
* Handle window resize
*/
handleWindowResize() {
this.updateBookDimensions();
this.updateParagraphHeight();
}
/**
* Sets the active TTS handler.
* @param {object} handler - The TTS handler instance (e.g., KokoroHandler, BrowserTtsHandler).
*/
setTtsHandler(handler) {
this.ttsHandler = handler;
console.log("UiController: TTS Handler set.", handler);
// Update button state based on the new handler's status
this.updateSpeechButtonStyling(this.ttsHandler ? this.ttsHandler.isEnabled() : false);
}
/**
* Update the book dimensions based on viewport size
*/
updateBookDimensions() {
const vw = window.innerWidth;
const vh = window.innerHeight;
const viewportAspectRatio = vw / vh;
const imageAspectRatio = 2727 / 1691;
let bookWidth, bookHeight;
if (viewportAspectRatio > imageAspectRatio) {
bookWidth = vh * imageAspectRatio;
bookHeight = vh;
} else {
bookWidth = vw;
bookHeight = vw / imageAspectRatio;
}
document.documentElement.style.setProperty('--book-width', `${bookWidth}px`);
document.documentElement.style.setProperty('--book-height', `${bookHeight}px`);
// Setting a CSS variable that will be either vw or vh depending on the viewport aspect ratio
document.documentElement.style.setProperty(
"--viewport-dimension",
viewportAspectRatio > imageAspectRatio ? 'vw' : 'vh'
);
document.documentElement.style.setProperty('--viewport-aspect-ratio', viewportAspectRatio);
const story = document.getElementById("story");
if (story) {
const paddingTop = window.getComputedStyle(story).paddingTop;
const paddingBottom = window.getComputedStyle(story).paddingBottom;
document.documentElement.style.setProperty('--story-line-height', (story.clientHeight - paddingTop - paddingBottom) / 28);
}
}
/**
* Update paragraph heights based on viewport
*/
updateParagraphHeight() {
document.querySelectorAll("#story p").forEach((element) => {
if (element.dataset.vpc) {
const pHeight = parseFloat(window.getComputedStyle(document.getElementById('page_right')).height);
const newHeight = pHeight * element.dataset.vpc / 100 + 'px';
element.style.height = newHeight;
}
});
}
/**
* Update the speech button styling based on enabled state.
* @param {boolean} enabled - Whether speech is enabled.
*/
updateSpeechButtonStyling(enabled = false) {
if (!this.speechButton) return;
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 = '';
}
}
/**
* Updates the enabled/disabled state and title of the speech button.
* @param {boolean} available - Whether any TTS system is available.
* @param {string} [type] - The type of TTS system available ('kokoro', 'browser', etc.).
*/
updateSpeechButtonAvailability(available, type) {
if (!this.speechButton) return;
if (available) {
this.speechButton.removeAttribute('disabled');
const ttsName = type === 'kokoro' ? 'Kokoro TTS' : (type === 'browser' ? 'Browser TTS' : 'TTS');
const title = this.translations[this.locale]?.title_speech || `Toggle Text-to-Speech (${ttsName})`;
this.speechButton.setAttribute('title', title);
// Update style based on current handler state if available
this.updateSpeechButtonStyling(this.ttsHandler ? this.ttsHandler.isEnabled() : false);
} else {
this.speechButton.setAttribute('disabled', 'disabled');
const title = this.translations[this.locale]?.title_speech_unavailable || 'Text-to-Speech not available';
this.speechButton.setAttribute('title', title);
this.updateSpeechButtonStyling(false); // Ensure style is off
}
}
/**
* Updates the enabled/disabled state of control buttons based on game state.
* @param {object} gameState - The current game state from AnimatedFiction.
* @param {boolean} gameState.started - Whether the game has started.
* @param {boolean} [gameState.canLoad] - Whether a saved game exists to be loaded.
*/
updateButtonStates(gameState) {
if (this.rewindButton) {
if (gameState.started) {
this.rewindButton.removeAttribute('disabled');
} else {
this.rewindButton.setAttribute('disabled', 'disabled');
}
}
if (this.saveButton) {
if (gameState.started) {
this.saveButton.removeAttribute('disabled');
} else {
this.saveButton.setAttribute('disabled', 'disabled');
}
}
if (this.loadButton) {
// Enable load button if a save exists (indicated by canLoad flag or similar)
// We might need a more robust way to check for saved state existence.
// For now, enable if game started OR if canLoad is explicitly true.
if (gameState.started || gameState.canLoad) {
this.loadButton.removeAttribute('disabled');
} else {
this.loadButton.setAttribute('disabled', 'disabled');
}
}
// Speech button availability is handled separately by updateSpeechButtonAvailability
}
/**
* Updates the visual display of the speed slider.
* @param {number} value - The speed value (0-100).
*/
updateSpeedDisplay(value) {
if (this.speedSlider) {
this.speedSlider.value = value;
}
}
/**
* Insert an element after a delay (Helper, potentially move elsewhere or keep if used)
* @param {number} delay - The delay in milliseconds
* @param {HTMLElement} target - The target element to append to
* @param {HTMLElement} el - The element to insert
* @param {boolean} fadeIn - Whether to fade in the element
*/
insertAfter(delay, target, el, fadeIn = true) {
if (this.animationQueue) {
if (fadeIn) {
el.classList.add("fade-in");
this.animationQueue.schedule(function() {
target.appendChild(el);
}, delay);
} else {
this.animationQueue.schedule(function() {
target.appendChild(el);
}, delay);
}
} else {
// Fallback if no animation queue
if (fadeIn) {
el.classList.add("fade-in");
setTimeout(() => {
target.appendChild(el);
}, delay);
} else {
setTimeout(() => {
target.appendChild(el);
}, delay);
}
}
}
/**
* Set the locale for translations
* @param {string} locale - The locale code
*/
setLocale(locale) {
this.locale = locale;
if (this.translations[locale]) {
Object.keys(this.translations[locale]).forEach(key => {
const prefix = key.substring(0, 5);
const postfix = key.substring(6, key.length);
const elements = document.querySelectorAll(`.l10n-${(prefix === 'title' ? postfix : key)}`);
elements.forEach(element => {
if (prefix === "title") {
element.title = this.translations[locale][key];
} else {
element.innerHTML = this.translations[locale][key];
}
});
});
} else {
console.error(`Locale ${locale} is not defined`);
}
}
}