Refactored app to include all the ink.js typography.
This commit is contained in:
+115
-97
@@ -1,97 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<!-- meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'blob'; style-src 'self' 'unsafe-inline'" -->
|
||||
<title>ai-fiction Book Runtime</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<p id="versions">We are using Node.js <span id="node-version"></span>,
|
||||
Chromium <span id="chrome-version"></span>,
|
||||
and Electron <span id="electron-version"></span>.</p>
|
||||
<div id="book">
|
||||
<div id="page_left">
|
||||
<div class="header">
|
||||
<h2 class="byline l10n-by">powered by Generative AI</h2>
|
||||
<h1 class="title">AI Interactive Fiction</h1>
|
||||
<h3 class="subtitle">An open-world text adventure</h3>
|
||||
<div class="separator"><double>❦</double></div>
|
||||
</div>
|
||||
<div id="controls" class="buttons">
|
||||
<a class="l10n-speech" id="speech" title="Toggle text to speech" disabled="disabled">speech</a>
|
||||
<span><a id="speed_reset"><span class="l10n-speed">speed<sup>*</sup></span></a><input type="range" min="0" max="100" value="50" id="speed" name="speed" /></span>
|
||||
<a class="l10n-restart" id="rewind" title="Restart story from beginning" disabled="disabled">restart</a>
|
||||
<a class="l10n-save" id="save" title="Save progress">save</a>
|
||||
<a class="l10n-load" id="reload" title="Reload from save point" disabled="disabled">load</a>
|
||||
</div>
|
||||
<div id="choices" class="container">
|
||||
<div id="command_history">
|
||||
<!-- Previous commands and responses will be displayed here -->
|
||||
</div>
|
||||
|
||||
<div id="command_input">
|
||||
<div class="input-wrapper">
|
||||
<textarea id="player_input" placeholder="Enter your command..." rows="1" autofocus></textarea>
|
||||
<span id="cursor"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="l10n-remark" id="remark"><i><sup>*</sup>click on page or press spacebar to fast forward text animation</i></div>
|
||||
</div>
|
||||
<div id="page_right">
|
||||
<div id="story" class="container">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ruler"></div>
|
||||
<div class="l10n-prompt" id="indent">What do you want to do next?</div>
|
||||
<div id="lighting" />
|
||||
|
||||
<!-- Socket.io library for client-server communication -->
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<!-- You can also require other files to run in this process -->
|
||||
<script src="js/smartypants.js"></script>
|
||||
<script src="js/linked-list.js"></script>
|
||||
<script src="js/linebreak.js"></script>
|
||||
<script src="js/knuth-and-plass.js"></script>
|
||||
<script src="js/Hyphenopoly_Loader.js"></script>
|
||||
<script>
|
||||
var locale = "en";
|
||||
</script>
|
||||
|
||||
<!-- TTS implementation scripts - order matters! -->
|
||||
<!-- 1. Kokoro TTS library - load as module -->
|
||||
<script type="module">
|
||||
try {
|
||||
// Import KokoroTTS class from the module
|
||||
const kokoroModule = await import('./js/kokoro-js.js');
|
||||
|
||||
// Expose the KokoroTTS class globally
|
||||
window.KokoroTTS = kokoroModule.KokoroTTS;
|
||||
console.log('KokoroTTS class loaded and exposed to window');
|
||||
|
||||
// Dispatch an event to signal that the class is ready
|
||||
const event = new CustomEvent('kokoro-class-loaded');
|
||||
window.dispatchEvent(event);
|
||||
} catch (error) {
|
||||
console.error('Failed to load KokoroTTS module:', error);
|
||||
// Dispatch an event even on failure so handlers don't wait forever
|
||||
const event = new CustomEvent('kokoro-class-load-failed');
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 2. TTS handlers (kokoro-handler needs to wait for KokoroTTS) -->
|
||||
<script src="js/kokoro-handler.js"></script>
|
||||
<script src="js/tts-handler.js"></script>
|
||||
|
||||
<!-- 3. TTS Factory for automatic selection -->
|
||||
<script src="js/tts-factory.js"></script>
|
||||
|
||||
<!-- Main application script -->
|
||||
<script src="js/ai-fiction.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<!-- meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'blob'; style-src 'self' 'unsafe-inline'" -->
|
||||
<title>ai-fiction Book Runtime (Modular Version)</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<p id="versions">We are using Node.js <span id="node-version"></span>,
|
||||
Chromium <span id="chrome-version"></span>,
|
||||
and Electron <span id="electron-version"></span>.</p>
|
||||
<div id="book">
|
||||
<div id="page_left">
|
||||
<div class="header">
|
||||
<h2 class="byline l10n-by">powered by Generative AI</h2>
|
||||
<h1 class="title">AI Interactive Fiction</h1>
|
||||
<h3 class="subtitle">An open-world text adventure</h3>
|
||||
<div class="separator"><double>❦</double></div>
|
||||
</div>
|
||||
<div id="controls" class="buttons">
|
||||
<a class="l10n-speech" id="speech" title="Toggle text to speech" disabled="disabled">speech</a>
|
||||
<span><a id="speed_reset"><span class="l10n-speed">speed<sup>*</sup></span></a><input type="range" min="0" max="100" value="50" id="speed" name="speed" /></span>
|
||||
<a class="l10n-restart" id="rewind" title="Restart story from beginning" disabled="disabled">restart</a>
|
||||
<a class="l10n-save" id="save" title="Save progress">save</a>
|
||||
<a class="l10n-load" id="reload" title="Reload from save point" disabled="disabled">load</a>
|
||||
</div>
|
||||
<div id="choices" class="container">
|
||||
<div id="command_history">
|
||||
<!-- Previous commands and responses will be displayed here -->
|
||||
</div>
|
||||
|
||||
<div id="command_input">
|
||||
<div class="input-wrapper">
|
||||
<textarea id="player_input" placeholder="Enter your command..." rows="1" autofocus></textarea>
|
||||
<span id="cursor"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="l10n-remark" id="remark"><i><sup>*</sup>click on page or press spacebar to fast forward text animation</i></div>
|
||||
</div>
|
||||
<div id="page_right">
|
||||
<div id="story" class="container">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ruler"></div>
|
||||
<div class="l10n-prompt" id="indent">What do you want to do next?</div>
|
||||
<div id="lighting" />
|
||||
|
||||
<!-- Socket.io library for client-server communication -->
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
|
||||
<!-- Core libraries -->
|
||||
<script src="js/smartypants.js"></script>
|
||||
<script src="js/linked-list.js"></script>
|
||||
<script src="js/linebreak.js"></script>
|
||||
<script src="js/knuth-and-plass.js"></script>
|
||||
<script src="js/Hyphenopoly_Loader.js"></script>
|
||||
|
||||
<script>
|
||||
var locale = "en";
|
||||
// Create global variables needed by the modules
|
||||
window.rstack = [];
|
||||
</script>
|
||||
|
||||
<!-- TTS implementation scripts - order matters! -->
|
||||
<!-- 1. Kokoro TTS library - load as module -->
|
||||
<script type="module">
|
||||
try {
|
||||
// Import KokoroTTS class from the module
|
||||
const kokoroModule = await import('./js/kokoro-js.js');
|
||||
|
||||
// Expose the KokoroTTS class globally
|
||||
window.KokoroTTS = kokoroModule.KokoroTTS;
|
||||
console.log('KokoroTTS class loaded and exposed to window');
|
||||
|
||||
// Dispatch an event to signal that the class is ready
|
||||
const event = new CustomEvent('kokoro-class-loaded');
|
||||
window.dispatchEvent(event);
|
||||
} catch (error) {
|
||||
console.error('Failed to load KokoroTTS module:', error);
|
||||
// Dispatch an event even on failure so handlers don't wait forever
|
||||
const event = new CustomEvent('kokoro-class-load-failed');
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 2. TTS handlers (kokoro-handler needs to wait for KokoroTTS) -->
|
||||
<script src="js/kokoro-handler.js"></script>
|
||||
<script src="js/tts-handler.js"></script>
|
||||
|
||||
<!-- 3. TTS Factory for automatic selection -->
|
||||
<script src="js/tts-factory.js"></script>
|
||||
|
||||
<!-- New Modules for Socket-based Interaction -->
|
||||
<script src="js/input-handler.js"></script>
|
||||
<script src="js/socket-client.js"></script>
|
||||
|
||||
<!-- Modular layout and animation components -->
|
||||
<script type="module" src="js/animation-queue.js"></script>
|
||||
<script type="module" src="js/text-processor.js"></script>
|
||||
<script type="module" src="js/paragraph-layout.js"></script>
|
||||
<script type="module" src="js/layout-renderer.js"></script>
|
||||
<script type="module" src="js/persistence-manager.js"></script>
|
||||
<script type="module" src="js/ui-controller.js"></script>
|
||||
<script type="module" src="js/audio-manager.js"></script>
|
||||
<script type="module" src="js/tts-player.js"></script>
|
||||
|
||||
<!-- Main application script - using ES modules -->
|
||||
<script type="module" src="js/ai-fiction.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+763
-761
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* AnimationQueue Module
|
||||
* Manages the timing and execution queue for all scheduled animations (primarily text reveal).
|
||||
*/
|
||||
export class AnimationQueue {
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.delay = 0;
|
||||
this.speed = 0.05; // Default speed
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a function to be executed after a delay
|
||||
* @param {Function} func - The function to execute
|
||||
* @param {number} delay - The delay in milliseconds
|
||||
* @param {...any} args - Arguments to pass to the function
|
||||
* @returns {number} The timeout ID
|
||||
*/
|
||||
schedule(func, delay, ...args) {
|
||||
const timeoutObject = {
|
||||
execute: () => func(...args),
|
||||
timeoutId: null
|
||||
};
|
||||
|
||||
timeoutObject.timeoutId = setTimeout(() => {
|
||||
timeoutObject.execute();
|
||||
this.queue = this.queue.filter(t => t !== timeoutObject);
|
||||
if (this.queue.length <= 0) {
|
||||
let event = new CustomEvent("allWordsSetEvent", {
|
||||
detail: { messages: "All scheduled word fade in animations were played." },
|
||||
bubbles: true,
|
||||
cancelable: false
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
}, delay);
|
||||
|
||||
this.queue.push(timeoutObject);
|
||||
return timeoutObject.timeoutId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast forward all scheduled animations
|
||||
*/
|
||||
fastForward() {
|
||||
this.delay = 0.0;
|
||||
// Sort the queue based on timeoutId (assuming that smaller ids are scheduled earlier)
|
||||
this.queue.sort((a, b) => a.timeoutId - b.timeoutId);
|
||||
// Clear and execute all timeouts
|
||||
this.queue.forEach(timeoutObject => {
|
||||
clearTimeout(timeoutObject.timeoutId);
|
||||
timeoutObject.execute();
|
||||
});
|
||||
this.queue = [];
|
||||
let event = new CustomEvent("allWordsSetEvent", {
|
||||
detail: { messages: "All scheduled word fade in animations were played." },
|
||||
bubbles: true,
|
||||
cancelable: false
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
document.getElementById("page_right").scrollTo({
|
||||
top: document.getElementById("page_right").scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all scheduled animations
|
||||
*/
|
||||
stop() {
|
||||
this.queue.forEach(timeoutObject => {
|
||||
clearTimeout(timeoutObject.timeoutId);
|
||||
});
|
||||
this.queue = [];
|
||||
this.delay = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the animation speed
|
||||
* @param {number} value - The speed value
|
||||
*/
|
||||
setSpeed(value) {
|
||||
this.speed = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current animation speed
|
||||
* @returns {number} The current speed
|
||||
*/
|
||||
getSpeed() {
|
||||
return this.speed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current accumulated delay
|
||||
* @returns {number} The current delay
|
||||
*/
|
||||
getDelay() {
|
||||
return this.delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the accumulated delay
|
||||
* @param {number} value - The delay value
|
||||
*/
|
||||
setDelay(value) {
|
||||
this.delay = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the accumulated delay
|
||||
* @param {number} value - The amount to increment
|
||||
*/
|
||||
incrementDelay(value) {
|
||||
this.delay += value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* AudioManager Module
|
||||
* Manages loading and playback of non-TTS audio effects triggered by tags.
|
||||
*/
|
||||
export class AudioManager {
|
||||
constructor() {
|
||||
this.sounds = new Map();
|
||||
this.currentAudio = null;
|
||||
this.currentLoop = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a sound file
|
||||
* @param {string} id - The identifier for the sound
|
||||
* @param {string} url - The URL of the sound file
|
||||
* @returns {Promise} A promise that resolves when the sound is loaded
|
||||
*/
|
||||
loadSound(id, url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio(url);
|
||||
audio.addEventListener('canplaythrough', () => {
|
||||
this.sounds.set(id, audio);
|
||||
resolve(audio);
|
||||
}, { once: true });
|
||||
audio.addEventListener('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
audio.load();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a sound
|
||||
* @param {string} id - The identifier for the sound
|
||||
* @param {boolean} loop - Whether to loop the sound
|
||||
* @returns {HTMLAudioElement|null} The audio element or null if not found
|
||||
*/
|
||||
playSound(id, loop = false) {
|
||||
const audio = this.sounds.get(id);
|
||||
if (!audio) {
|
||||
console.warn(`Sound with id "${id}" not found.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loop) {
|
||||
if (this.currentLoop) {
|
||||
this.currentLoop.pause();
|
||||
this.currentLoop.currentTime = 0;
|
||||
}
|
||||
audio.loop = true;
|
||||
this.currentLoop = audio;
|
||||
} else {
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.pause();
|
||||
this.currentAudio.currentTime = 0;
|
||||
}
|
||||
this.currentAudio = audio;
|
||||
}
|
||||
|
||||
audio.play().catch(error => {
|
||||
console.error('Error playing audio:', error);
|
||||
});
|
||||
|
||||
return audio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a sound from a URL directly (without preloading)
|
||||
* @param {string} url - The URL of the sound file
|
||||
* @param {boolean} loop - Whether to loop the sound
|
||||
* @returns {HTMLAudioElement} The audio element
|
||||
*/
|
||||
playSoundFromUrl(url, loop = false) {
|
||||
if (loop) {
|
||||
if (this.currentLoop) {
|
||||
this.currentLoop.pause();
|
||||
this.currentLoop.removeAttribute('src');
|
||||
this.currentLoop.load();
|
||||
}
|
||||
this.currentLoop = new Audio(url);
|
||||
this.currentLoop.loop = true;
|
||||
this.currentLoop.play().catch(error => {
|
||||
console.error('Error playing audio loop:', error);
|
||||
});
|
||||
return this.currentLoop;
|
||||
} else {
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.pause();
|
||||
this.currentAudio.removeAttribute('src');
|
||||
this.currentAudio.load();
|
||||
}
|
||||
this.currentAudio = new Audio(url);
|
||||
this.currentAudio.play().catch(error => {
|
||||
console.error('Error playing audio:', error);
|
||||
});
|
||||
return this.currentAudio;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a specific sound
|
||||
* @param {string} id - The identifier for the sound
|
||||
*/
|
||||
stopSound(id) {
|
||||
const audio = this.sounds.get(id);
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all sounds
|
||||
*/
|
||||
stopAllSounds() {
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.pause();
|
||||
this.currentAudio.currentTime = 0;
|
||||
this.currentAudio = null;
|
||||
}
|
||||
|
||||
if (this.currentLoop) {
|
||||
this.currentLoop.pause();
|
||||
this.currentLoop.currentTime = 0;
|
||||
this.currentLoop = null;
|
||||
}
|
||||
|
||||
this.sounds.forEach(audio => {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the volume for all sounds
|
||||
* @param {number} volume - The volume level (0.0 to 1.0)
|
||||
*/
|
||||
setVolume(volume) {
|
||||
this.sounds.forEach(audio => {
|
||||
audio.volume = volume;
|
||||
});
|
||||
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.volume = volume;
|
||||
}
|
||||
|
||||
if (this.currentLoop) {
|
||||
this.currentLoop.volume = volume;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a sound is currently playing
|
||||
* @param {string} id - The identifier for the sound
|
||||
* @returns {boolean} Whether the sound is playing
|
||||
*/
|
||||
isPlaying(id) {
|
||||
const audio = this.sounds.get(id);
|
||||
return audio ? !audio.paused : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fade out the current audio
|
||||
* @param {number} duration - The duration of the fade in milliseconds
|
||||
* @returns {Promise} A promise that resolves when the fade is complete
|
||||
*/
|
||||
fadeOutCurrentAudio(duration = 1000) {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.currentAudio || this.currentAudio.paused) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = this.currentAudio;
|
||||
const initialVolume = audio.volume;
|
||||
const volumeStep = initialVolume / (duration / 50);
|
||||
let currentVolume = initialVolume;
|
||||
|
||||
const fadeInterval = setInterval(() => {
|
||||
currentVolume -= volumeStep;
|
||||
if (currentVolume <= 0) {
|
||||
clearInterval(fadeInterval);
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
audio.volume = initialVolume; // Reset volume for future use
|
||||
resolve();
|
||||
} else {
|
||||
audio.volume = currentVolume;
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
/**
|
||||
* InkStoryPlayer Module
|
||||
* Orchestrates the narrative flow specific to the Ink story.
|
||||
*/
|
||||
export class InkStoryPlayer {
|
||||
/**
|
||||
* Create a new InkStoryPlayer
|
||||
* @param {Object} config - Configuration options
|
||||
* @param {Function} config.InkStory - The inkjs.Story constructor
|
||||
* @param {Object} config.textProcessor - The TextProcessor instance
|
||||
* @param {Object} config.paragraphLayout - The ParagraphLayout instance
|
||||
* @param {Object} config.layoutRenderer - The LayoutRenderer instance
|
||||
* @param {Object} config.audioManager - The AudioManager instance
|
||||
* @param {Object} config.ttsPlayer - The TtsPlayer instance
|
||||
* @param {Object} config.persistenceManager - The PersistenceManager instance
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
this.InkStory = config.InkStory;
|
||||
this.textProcessor = config.textProcessor;
|
||||
this.paragraphLayout = config.paragraphLayout;
|
||||
this.layoutRenderer = config.layoutRenderer;
|
||||
this.audioManager = config.audioManager;
|
||||
this.ttsPlayer = config.ttsPlayer;
|
||||
this.persistenceManager = config.persistenceManager;
|
||||
|
||||
this.story = null;
|
||||
this.storyContainer = document.getElementById('story');
|
||||
this.choiceContainer = document.getElementById('choices');
|
||||
this.savePoint = "";
|
||||
this.running = false;
|
||||
this.keyEventListener = null;
|
||||
this.indentedParagraphs = 0;
|
||||
this.chapterBegin = false;
|
||||
this.measure = [];
|
||||
this.locale = 'en-us';
|
||||
this.translations = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a story from JSON content
|
||||
* @param {Object} storyContent - The compiled Ink story JSON
|
||||
* @param {string} initialState - Optional initial state to load
|
||||
*/
|
||||
loadStory(storyContent, initialState = null) {
|
||||
this.story = new this.InkStory(storyContent);
|
||||
|
||||
if (initialState) {
|
||||
this.story.state.LoadJson(initialState);
|
||||
}
|
||||
|
||||
this.savePoint = this.story.state.toJson();
|
||||
|
||||
// Process global tags
|
||||
this.processGlobalTags();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process global tags from the story
|
||||
*/
|
||||
processGlobalTags() {
|
||||
if (!this.story || !this.story.globalTags) return;
|
||||
|
||||
for (let i = 0; i < this.story.globalTags.length; i++) {
|
||||
const globalTag = this.story.globalTags[i];
|
||||
const splitTag = this.splitPropertyTag(globalTag);
|
||||
|
||||
if (splitTag && splitTag.property == "title") {
|
||||
const title = document.querySelector('.title');
|
||||
if (title) title.innerHTML = splitTag.val;
|
||||
} else if (splitTag && splitTag.property == "author") {
|
||||
const byline = document.querySelector('.byline');
|
||||
if (byline) byline.textContent += splitTag.val;
|
||||
} else if (splitTag && splitTag.property == "subtitle") {
|
||||
const subtitle = document.querySelector('.subtitle');
|
||||
if (subtitle) subtitle.textContent += splitTag.val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue the story
|
||||
* @param {boolean} firstTime - Whether this is the first time continuing
|
||||
*/
|
||||
async continueStory(firstTime = true) {
|
||||
if (!this.story) {
|
||||
console.error('No story loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
this.layoutRenderer.setFastForwardingAll(false);
|
||||
let chapterBegin = false;
|
||||
|
||||
if (this.keyEventListener) {
|
||||
window.removeEventListener('keypress', this.keyEventListener);
|
||||
this.keyEventListener = null;
|
||||
}
|
||||
|
||||
document.querySelectorAll('#story p').forEach((p) => {
|
||||
p.classList.remove("latest-paragraph");
|
||||
});
|
||||
|
||||
// Generate story text - loop through available content
|
||||
while (this.story.canContinue) {
|
||||
if (this.layoutRenderer.getFastForwardingAll()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.layoutRenderer.animationQueue) {
|
||||
this.layoutRenderer.animationQueue.setDelay(0.0);
|
||||
}
|
||||
|
||||
// Get ink to generate the next paragraph
|
||||
const paragraphText = this.story.Continue();
|
||||
const tags = this.story.currentTags;
|
||||
|
||||
// Process tags and get custom classes
|
||||
const customClasses = this.processTags(tags);
|
||||
|
||||
// Skip empty paragraphs
|
||||
if (paragraphText.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process paragraph text and layout
|
||||
await this.processParagraph(paragraphText, customClasses, chapterBegin);
|
||||
|
||||
chapterBegin = false;
|
||||
}
|
||||
|
||||
if (this.layoutRenderer.animationQueue) {
|
||||
this.layoutRenderer.animationQueue.setDelay(0.0);
|
||||
}
|
||||
|
||||
// Process choices
|
||||
await this.processChoices();
|
||||
|
||||
// Dispatch turn complete event
|
||||
const tce = new CustomEvent("turnCompleteEvent", {
|
||||
detail: { messages: "All text and choices have been set up." },
|
||||
bubbles: true,
|
||||
cancelable: false
|
||||
});
|
||||
document.dispatchEvent(tce);
|
||||
|
||||
// Check for end of story
|
||||
if (this.story.canContinue === false && this.story.currentChoices.length === 0) {
|
||||
const end = document.createElement("p");
|
||||
end.style.textTransform = "uppercase";
|
||||
end.style.textAlign = "center";
|
||||
end.classList.add("fade-in");
|
||||
end.classList.add("choice");
|
||||
|
||||
const endText = this.translations[this.locale] && this.translations[this.locale]['end']
|
||||
? this.translations[this.locale]['end']
|
||||
: "The End";
|
||||
|
||||
end.appendChild(document.createTextNode(endText));
|
||||
this.choiceContainer.appendChild(end);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tags for a paragraph
|
||||
* @param {Array<string>} tags - The tags for the paragraph
|
||||
* @returns {Array<string>} Custom CSS classes to apply
|
||||
*/
|
||||
processTags(tags) {
|
||||
if (!tags || tags.length === 0) return [];
|
||||
|
||||
const customClasses = [];
|
||||
let tagDebug = "";
|
||||
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
const tag = tags[i];
|
||||
tagDebug += tag + ";";
|
||||
|
||||
// Detect tags of the form "X: Y"
|
||||
const splitTag = this.splitPropertyTag(tag);
|
||||
|
||||
// AUDIO: src
|
||||
if (splitTag && splitTag.property == "AUDIO") {
|
||||
if (this.audioManager) {
|
||||
this.audioManager.playSoundFromUrl(splitTag.val);
|
||||
}
|
||||
}
|
||||
|
||||
// AUDIOLOOP: src
|
||||
else if (splitTag && splitTag.property == "AUDIOLOOP") {
|
||||
if (this.audioManager) {
|
||||
this.audioManager.playSoundFromUrl(splitTag.val, true);
|
||||
}
|
||||
}
|
||||
|
||||
// IMAGE: src
|
||||
else if (splitTag && splitTag.property == "IMAGE") {
|
||||
if (this.layoutRenderer) {
|
||||
const imageElement = this.layoutRenderer.renderVisualTag("IMAGE", splitTag.val, this.storyContainer);
|
||||
if (imageElement && this.layoutRenderer.animationQueue) {
|
||||
this.layoutRenderer.showAfter(this.layoutRenderer.animationQueue.getDelay(), imageElement);
|
||||
this.layoutRenderer.animationQueue.incrementDelay(this.layoutRenderer.animationQueue.getSpeed());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LINK: url
|
||||
else if (splitTag && splitTag.property == "LINK") {
|
||||
window.location.href = splitTag.val;
|
||||
}
|
||||
|
||||
// LINKOPEN: url
|
||||
else if (splitTag && splitTag.property == "LINKOPEN") {
|
||||
window.open(splitTag.val);
|
||||
}
|
||||
|
||||
// BACKGROUND: src
|
||||
else if (splitTag && splitTag.property == "BACKGROUND") {
|
||||
if (this.layoutRenderer) {
|
||||
this.layoutRenderer.renderVisualTag("BACKGROUND", splitTag.val);
|
||||
}
|
||||
}
|
||||
|
||||
// CLASS: className
|
||||
else if (splitTag && splitTag.property == "CLASS") {
|
||||
customClasses.push(splitTag.val);
|
||||
}
|
||||
|
||||
// CLEAR - removes all existing content
|
||||
else if (tag == "CLEAR") {
|
||||
this.removeAll("p");
|
||||
this.removeAll("img");
|
||||
}
|
||||
|
||||
// RESTART - clears everything and restarts the story from the beginning
|
||||
else if (tag == "RESTART") {
|
||||
this.removeAll("p");
|
||||
this.removeAll("img");
|
||||
this.restart();
|
||||
return [];
|
||||
}
|
||||
|
||||
// CHAPTER: Chapter Heading
|
||||
else if (splitTag && splitTag.property == "CHAPTER") {
|
||||
if (this.layoutRenderer) {
|
||||
this.layoutRenderer.renderVisualTag("CHAPTER", splitTag.val, this.storyContainer);
|
||||
}
|
||||
this.chapterBegin = true;
|
||||
this.indentedParagraphs = 2;
|
||||
}
|
||||
|
||||
// SEPARATOR
|
||||
else if (tag == "SEPARATOR") {
|
||||
if (this.layoutRenderer) {
|
||||
this.layoutRenderer.renderVisualTag("SEPARATOR", splitTag.val, this.storyContainer);
|
||||
}
|
||||
this.chapterBegin = true;
|
||||
}
|
||||
}
|
||||
|
||||
return customClasses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a paragraph of text
|
||||
* @param {string} paragraphText - The paragraph text
|
||||
* @param {Array<string>} customClasses - Custom CSS classes to apply
|
||||
* @param {boolean} chapterBegin - Whether this is the beginning of a chapter
|
||||
*/
|
||||
async processParagraph(paragraphText, customClasses, chapterBegin) {
|
||||
const indentWidth = 2 * parseFloat(window.getComputedStyle(document.querySelector("#indent")).lineHeight);
|
||||
let text = paragraphText;
|
||||
let dropCap = null;
|
||||
let dropQuote = null;
|
||||
|
||||
if (this.chapterBegin) {
|
||||
this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width));
|
||||
this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width) - indentWidth);
|
||||
this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width) - indentWidth * 0.9);
|
||||
|
||||
const words = paragraphText.split(" ");
|
||||
let firstWord = words[0].substr(1, words[0].length);
|
||||
let openingQuote = "";
|
||||
let firstLetter = words[0].substr(0, 1);
|
||||
|
||||
if (firstLetter == "\"" || firstLetter == "'") {
|
||||
openingQuote = window.SmartyPants ? window.SmartyPants.smartypantsu(firstLetter, 1) : firstLetter;
|
||||
firstLetter = words[0].substr(1, 1);
|
||||
firstWord = words[0].substr(2, words[0].length);
|
||||
}
|
||||
|
||||
if (firstWord.length < 5 && words.length > 1) {
|
||||
firstWord += ' ' + words[1];
|
||||
}
|
||||
|
||||
text = '<cap>' + firstWord + '</cap> ' + paragraphText.substr(firstWord.length + 2 + openingQuote.length, paragraphText.length);
|
||||
|
||||
dropCap = document.createElement("span");
|
||||
dropCap.classList.add("drop-cap");
|
||||
dropCap.appendChild(document.createTextNode(firstLetter));
|
||||
dropCap.style.left = '0%';
|
||||
dropCap.style.top = '0%';
|
||||
dropCap.style.position = 'absolute';
|
||||
|
||||
if (openingQuote) {
|
||||
dropQuote = document.createElement("span");
|
||||
dropQuote.classList.add("drop-quote");
|
||||
dropQuote.appendChild(document.createTextNode(openingQuote));
|
||||
dropQuote.style.left = '-4.45%';
|
||||
dropQuote.style.top = '-16%';
|
||||
dropQuote.style.position = 'absolute';
|
||||
}
|
||||
} else {
|
||||
if (this.measure.length < 1) {
|
||||
this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width));
|
||||
this.measure.push(parseFloat(window.getComputedStyle(document.getElementById("story")).width) - indentWidth * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Process text and calculate layout
|
||||
const processedText = this.textProcessor.process(text);
|
||||
const paragraphData = this.paragraphLayout.calculateLayout(processedText, [...this.measure].reverse(), true);
|
||||
|
||||
// Render paragraph
|
||||
const [p, d] = this.layoutRenderer.renderParagraph(paragraphData, this.layoutRenderer.animationQueue.getDelay(), [...this.measure].reverse());
|
||||
|
||||
// Update measures and indented paragraphs
|
||||
for (let k = 0; k < parseInt(p.dataset.numberOfLines); k++) {
|
||||
this.measure.pop();
|
||||
}
|
||||
|
||||
this.indentedParagraphs -= p.dataset.numberOfLines;
|
||||
|
||||
// Add drop cap and quote if needed
|
||||
if (dropQuote) {
|
||||
this.layoutRenderer.insertAfter(0, p, dropQuote, true);
|
||||
}
|
||||
|
||||
if (dropCap) {
|
||||
this.layoutRenderer.insertAfter(0, p, dropCap, true);
|
||||
}
|
||||
|
||||
// Add custom classes
|
||||
for (let i = 0; i < customClasses.length; i++) {
|
||||
p.classList.add(customClasses[i]);
|
||||
}
|
||||
|
||||
p.lang = this.locale;
|
||||
this.storyContainer.appendChild(p);
|
||||
|
||||
// Smooth scroll to the paragraph
|
||||
this.layoutRenderer.smoothScroll(p, this.layoutRenderer.animationQueue.getSpeed() * 10 * p.dataset.numberOfLines);
|
||||
|
||||
// Wait for animations and speech
|
||||
await Promise.all([
|
||||
new Promise(resolve => {
|
||||
document.addEventListener('allWordsSetEvent', resolve, { once: true });
|
||||
}),
|
||||
new Promise(async resolve => {
|
||||
if (!this.ttsPlayer || !this.ttsPlayer.isEnabled()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ttsPlayer.speak(paragraphText);
|
||||
resolve();
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process choices from the story
|
||||
*/
|
||||
async processChoices() {
|
||||
const categoryContainers = { default: null };
|
||||
const categoryNumbers = { default: 0, categorized: 0 };
|
||||
|
||||
this.story.currentChoices.forEach(choice => {
|
||||
if (this.layoutRenderer.getFastForwardingAll()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tagDebug = "";
|
||||
let action = "default";
|
||||
|
||||
choice.tags.forEach(tag => {
|
||||
tagDebug += tag + ";";
|
||||
const splitTag = this.splitPropertyTag(tag);
|
||||
|
||||
if (splitTag && splitTag.property === "ACTION") {
|
||||
action = splitTag.val;
|
||||
}
|
||||
});
|
||||
|
||||
const prompt = this.translations[this.locale] && this.translations[this.locale][`action_${action}`]
|
||||
? this.translations[this.locale][`action_${action}`]
|
||||
: (action === "default" ? "What do you want to do?" : action);
|
||||
|
||||
if (action != "default") {
|
||||
this.createChoiceContainer(categoryContainers, categoryNumbers, action, prompt, choice, tagDebug);
|
||||
} else {
|
||||
const defaultPrompt = this.translations[this.locale] && this.translations[this.locale]['prompt']
|
||||
? this.translations[this.locale]['prompt']
|
||||
: "What do you want to do?";
|
||||
|
||||
this.createChoiceContainer(categoryContainers, categoryNumbers, "default", defaultPrompt, choice, tagDebug, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Set up key event listener
|
||||
this.keyEventListener = this.setupKeyEventListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a choice container
|
||||
* @param {Object} categoryContainers - Map of category containers
|
||||
* @param {Object} categoryNumbers - Map of category numbers
|
||||
* @param {string} action - The action category
|
||||
* @param {string} prompt - The prompt text
|
||||
* @param {Object} choice - The choice object
|
||||
* @param {string} tagDebug - Debug information for tags
|
||||
* @param {boolean} registerKeys - Whether to register keyboard shortcuts
|
||||
* @returns {HTMLElement} The choice container element
|
||||
*/
|
||||
createChoiceContainer(categoryContainers, categoryNumbers, action, prompt, choice, tagDebug, registerKeys = false) {
|
||||
let choiceCategoryContainer = categoryContainers[action];
|
||||
|
||||
if (!choiceCategoryContainer) {
|
||||
choiceCategoryContainer = document.createElement('ol');
|
||||
const p = document.createElement('p');
|
||||
p.innerHTML = prompt;
|
||||
choiceCategoryContainer.appendChild(p);
|
||||
choiceCategoryContainer.classList.add("choice");
|
||||
choiceCategoryContainer.classList.add("fade-in");
|
||||
|
||||
if (!registerKeys) {
|
||||
choiceCategoryContainer.classList.add("categorized");
|
||||
}
|
||||
|
||||
if (this.story.currentChoices.length && !this.layoutRenderer.getFastForwardingAll()) {
|
||||
this.choiceContainer.appendChild(choiceCategoryContainer);
|
||||
}
|
||||
}
|
||||
|
||||
categoryContainers[action] = choiceCategoryContainer;
|
||||
|
||||
let choiceNumber = categoryNumbers[action];
|
||||
if (choiceNumber === undefined) {
|
||||
choiceNumber = 0;
|
||||
}
|
||||
|
||||
choiceNumber++;
|
||||
|
||||
const choiceParagraphElement = document.createElement('li');
|
||||
choiceParagraphElement.classList.add("choice");
|
||||
choiceParagraphElement.lang = this.locale;
|
||||
choiceParagraphElement.title = tagDebug;
|
||||
|
||||
// Use SmartyPants if available
|
||||
const choiceText = window.SmartyPants && typeof window.SmartyPants.smartypantsu === 'function'
|
||||
? window.SmartyPants.smartypantsu(choice.text, 1)
|
||||
: choice.text;
|
||||
|
||||
choiceParagraphElement.innerHTML = `<a href='#'>${choiceText}</a>`;
|
||||
|
||||
if (!this.layoutRenderer.getFastForwardingAll()) {
|
||||
this.layoutRenderer.insertAfter(this.layoutRenderer.animationQueue.getDelay(), choiceCategoryContainer, choiceParagraphElement, true);
|
||||
}
|
||||
|
||||
this.layoutRenderer.animationQueue.incrementDelay(this.layoutRenderer.animationQueue.getSpeed());
|
||||
|
||||
// Register key shortcuts
|
||||
if (registerKeys) {
|
||||
choiceParagraphElement.value = choiceNumber;
|
||||
this.registerKey('Digit' + choiceNumber, choice.index);
|
||||
} else {
|
||||
let categorizedNumber = categoryNumbers['categorized'];
|
||||
categorizedNumber++;
|
||||
const keyLetter = String.fromCharCode(64 + categorizedNumber);
|
||||
choiceParagraphElement.value = categorizedNumber;
|
||||
this.registerKey('Key' + keyLetter, choice.index);
|
||||
categoryNumbers['categorized'] = categorizedNumber;
|
||||
}
|
||||
|
||||
// Click on choice
|
||||
const choiceAnchorEl = choiceParagraphElement.querySelectorAll("a")[0];
|
||||
choiceAnchorEl.addEventListener("click", (event) => {
|
||||
// Don't follow <a> link
|
||||
event.preventDefault();
|
||||
this.chooseChoice(choice.index);
|
||||
});
|
||||
|
||||
categoryNumbers[action] = choiceNumber;
|
||||
|
||||
return choiceCategoryContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose a choice by index
|
||||
* @param {number} index - The choice index
|
||||
*/
|
||||
chooseChoice(index) {
|
||||
// Remove all existing choices
|
||||
this.removeAll(".choice", true);
|
||||
this.clearKeyRegistry();
|
||||
|
||||
// Tell the story where to go next
|
||||
this.story.ChooseChoiceIndex(index);
|
||||
|
||||
// This is where the save button will save from
|
||||
this.savePoint = this.story.state.toJson();
|
||||
|
||||
// Continue the story
|
||||
this.continueStory(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a key for keyboard shortcuts
|
||||
* @param {string} key - The key code
|
||||
* @param {number} choice - The choice index
|
||||
*/
|
||||
registerKey(key, choice) {
|
||||
this.keyRegistry[key] = choice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the key registry
|
||||
*/
|
||||
clearKeyRegistry() {
|
||||
this.keyRegistry = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the key event listener for choices
|
||||
* @returns {Function} The key event listener
|
||||
*/
|
||||
setupKeyEventListener() {
|
||||
const keyEventListener = (event) => {
|
||||
for (const key in this.keyRegistry) {
|
||||
if (event.code === key) {
|
||||
window.removeEventListener('keypress', keyEventListener);
|
||||
this.chooseChoice(this.keyRegistry[key]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keypress', keyEventListener);
|
||||
|
||||
return keyEventListener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the story
|
||||
*/
|
||||
restart() {
|
||||
if (!this.story) return;
|
||||
|
||||
if (this.layoutRenderer.animationQueue) {
|
||||
this.layoutRenderer.animationQueue.setDelay(0.0);
|
||||
}
|
||||
|
||||
this.story.ResetState();
|
||||
this.layoutRenderer.setFastForwardingAll(true);
|
||||
this.layoutRenderer.animationQueue.fastForward();
|
||||
|
||||
this.removeAll("p");
|
||||
this.removeAll("img");
|
||||
this.removeAll("h2");
|
||||
this.removeAll("double");
|
||||
this.removeAll(".choice", true);
|
||||
|
||||
if (this.keyEventListener) {
|
||||
window.removeEventListener('keypress', this.keyEventListener);
|
||||
this.keyEventListener = null;
|
||||
}
|
||||
|
||||
// Set save point to here
|
||||
this.savePoint = this.story.state.toJson();
|
||||
|
||||
// Continue the story
|
||||
this.continueStory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current state
|
||||
* @returns {boolean} Whether the save was successful
|
||||
*/
|
||||
saveState() {
|
||||
if (!this.persistenceManager || !this.story) return false;
|
||||
|
||||
try {
|
||||
const history = Array.from(document.querySelectorAll("#story p:not(.latest-paragraph)")).map(p => p.outerHTML);
|
||||
|
||||
return this.persistenceManager.saveState({
|
||||
inkJson: this.savePoint,
|
||||
history: history
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Couldn't save state:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a saved state
|
||||
* @returns {boolean} Whether the load was successful
|
||||
*/
|
||||
loadState() {
|
||||
if (!this.persistenceManager || !this.story) return false;
|
||||
|
||||
try {
|
||||
const savedState = this.persistenceManager.loadState();
|
||||
|
||||
if (!savedState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeAll("p");
|
||||
this.removeAll("img");
|
||||
this.removeAll("h2");
|
||||
this.removeAll("double");
|
||||
this.removeAll(".choice", true);
|
||||
|
||||
if (savedState.history) {
|
||||
savedState.history.forEach(p => {
|
||||
const d = document.createElement('div');
|
||||
d.innerHTML = p;
|
||||
this.storyContainer.appendChild(d.firstChild);
|
||||
});
|
||||
}
|
||||
|
||||
if (savedState.inkJson) {
|
||||
this.story.state.LoadJson(savedState.inkJson);
|
||||
this.savePoint = savedState.inkJson;
|
||||
}
|
||||
|
||||
// Update paragraph heights
|
||||
this.updateParagraphHeight();
|
||||
|
||||
if (this.keyEventListener) {
|
||||
window.removeEventListener('keypress', this.keyEventListener);
|
||||
this.keyEventListener = null;
|
||||
}
|
||||
|
||||
// Continue the story
|
||||
this.continueStory();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn("Couldn't load state:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all elements that match the given selector
|
||||
* @param {string} selector - The CSS selector
|
||||
* @param {boolean} choices - Whether to remove from the choice container
|
||||
*/
|
||||
removeAll(selector, choices = false) {
|
||||
const container = choices ? this.choiceContainer : this.storyContainer;
|
||||
const allElements = container.querySelectorAll(selector);
|
||||
|
||||
for (let i = 0; i < allElements.length; i++) {
|
||||
const el = allElements[i];
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for parsing out tags of the form: # PROPERTY: value
|
||||
* @param {string} tag - The tag to parse
|
||||
* @returns {Object|null} The parsed property and value
|
||||
*/
|
||||
splitPropertyTag(tag) {
|
||||
const propertySplitIdx = tag.indexOf(":");
|
||||
|
||||
if (propertySplitIdx !== -1) {
|
||||
const property = tag.substr(0, propertySplitIdx).trim();
|
||||
const val = tag.substr(propertySplitIdx + 1).trim();
|
||||
|
||||
return {
|
||||
property: property,
|
||||
val: val
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the locale for translations
|
||||
* @param {string} locale - The locale code
|
||||
*/
|
||||
setLocale(locale) {
|
||||
this.locale = locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set translations
|
||||
* @param {Object} translations - The translations object
|
||||
*/
|
||||
setTranslations(translations) {
|
||||
this.translations = translations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Input Handler Module
|
||||
* Manages the multi-line text input field with a custom cursor.
|
||||
*/
|
||||
class InputHandler {
|
||||
constructor(inputId = 'player_input', cursorId = 'cursor') {
|
||||
this.playerInput = document.getElementById(inputId);
|
||||
this.cursor = document.getElementById(cursorId);
|
||||
this.commandInputContainer = document.getElementById('command_input'); // Assuming this container exists
|
||||
|
||||
if (!this.playerInput || !this.cursor || !this.commandInputContainer) {
|
||||
console.error('InputHandler: Required DOM elements not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.commandSubmitCallback = null; // Callback for when a command is submitted
|
||||
|
||||
this.bindEvents();
|
||||
this.adjustTextareaHeight(); // Initial adjustment
|
||||
this.updateCursorPosition(); // Initial position
|
||||
|
||||
// Setup handler for window load event to ensure proper initialization
|
||||
window.addEventListener('load', () => {
|
||||
console.log('InputHandler: Window loaded, adjusting text area height and cursor position');
|
||||
this.adjustTextareaHeight();
|
||||
this.updateCursorPosition();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback function to be called when a command is submitted.
|
||||
* @param {function(string)} callback - The function to call with the command text.
|
||||
*/
|
||||
onCommandSubmit(callback) {
|
||||
this.commandSubmitCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event handlers to the input element.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
// Allow Shift+Enter for new lines (default behavior)
|
||||
});
|
||||
|
||||
// Auto-resize textarea and update cursor 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));
|
||||
|
||||
// Show/hide cursor on focus/blur
|
||||
this.playerInput.addEventListener('focus', () => {
|
||||
if (this.cursor) this.cursor.style.opacity = '1';
|
||||
this.updateCursorPosition();
|
||||
});
|
||||
this.playerInput.addEventListener('blur', () => {
|
||||
if (this.cursor) this.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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the current command.
|
||||
*/
|
||||
submitCommand() {
|
||||
const command = this.playerInput.value.trim();
|
||||
if (command === '' || !this.commandSubmitCallback) return;
|
||||
|
||||
// Fade out the input field container
|
||||
if (this.commandInputContainer) {
|
||||
this.commandInputContainer.classList.add('fading');
|
||||
}
|
||||
|
||||
// Disable input temporarily
|
||||
this.playerInput.disabled = true;
|
||||
|
||||
// Call the registered callback
|
||||
this.commandSubmitCallback(command);
|
||||
|
||||
// Clear input
|
||||
this.clearInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the input field and resets its state.
|
||||
*/
|
||||
clearInput() {
|
||||
this.playerInput.value = '';
|
||||
this.resetCursorPosition();
|
||||
this.adjustTextareaHeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enables the input field after a command submission or response.
|
||||
*/
|
||||
enableInput() {
|
||||
if (this.commandInputContainer) {
|
||||
// Remove fading class and add fade-in animation
|
||||
this.commandInputContainer.classList.remove('fading');
|
||||
this.commandInputContainer.classList.add('fade-in-input');
|
||||
|
||||
// Remove animation class after it completes
|
||||
setTimeout(() => {
|
||||
if (this.commandInputContainer) {
|
||||
this.commandInputContainer.classList.remove('fade-in-input');
|
||||
}
|
||||
}, 500); // Match CSS animation duration
|
||||
}
|
||||
|
||||
this.playerInput.disabled = false;
|
||||
this.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the input field.
|
||||
*/
|
||||
focus() {
|
||||
this.playerInput.focus();
|
||||
// Ensure cursor is visible and positioned correctly after focus
|
||||
setTimeout(() => {
|
||||
if (this.cursor) this.cursor.style.opacity = '1';
|
||||
this.updateCursorPosition();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current value of the input field.
|
||||
* @returns {string} The input text.
|
||||
*/
|
||||
getValue() {
|
||||
return this.playerInput.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of the input field.
|
||||
* @param {string} value - The text to set.
|
||||
*/
|
||||
setValue(value) {
|
||||
this.playerInput.value = value;
|
||||
this.adjustTextareaHeight();
|
||||
this.updateCursorPosition();
|
||||
this.focus(); // Focus after setting value
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the cursor position to the start.
|
||||
*/
|
||||
resetCursorPosition() {
|
||||
if (this.cursor) {
|
||||
this.cursor.style.left = '0px';
|
||||
// Adjust top based on computed style padding or a default
|
||||
const computedStyle = window.getComputedStyle(this.playerInput);
|
||||
const paddingTop = parseFloat(computedStyle.paddingTop) || 6;
|
||||
this.cursor.style.top = `${paddingTop}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the custom cursor position based on input text and caret position.
|
||||
* Uses a temporary div for accurate measurement.
|
||||
*/
|
||||
updateCursorPosition() {
|
||||
if (!this.cursor || !this.playerInput) return;
|
||||
|
||||
const input = this.playerInput;
|
||||
const cursor = this.cursor;
|
||||
const caretPosition = input.selectionStart || 0;
|
||||
const inputText = input.value;
|
||||
|
||||
// If no text, position cursor at the beginning based on padding
|
||||
if (inputText.length === 0 && caretPosition === 0) {
|
||||
this.resetCursorPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a temporary measurement div
|
||||
const div = document.createElement('div');
|
||||
const style = getComputedStyle(input);
|
||||
|
||||
// Apply relevant styles from the textarea to the div
|
||||
div.style.position = 'absolute';
|
||||
div.style.top = '-9999px';
|
||||
div.style.left = '-9999px';
|
||||
div.style.width = style.width;
|
||||
div.style.height = 'auto';
|
||||
div.style.padding = style.padding;
|
||||
div.style.border = style.border;
|
||||
div.style.fontFamily = style.fontFamily;
|
||||
div.style.fontSize = style.fontSize;
|
||||
div.style.fontWeight = style.fontWeight;
|
||||
div.style.lineHeight = style.lineHeight;
|
||||
div.style.whiteSpace = 'pre-wrap';
|
||||
div.style.wordWrap = 'break-word';
|
||||
div.style.boxSizing = style.boxSizing;
|
||||
|
||||
// Create spans for text before and after the caret, and a marker span
|
||||
const preCaretText = document.createTextNode(inputText.substring(0, caretPosition));
|
||||
const caretMarker = document.createElement('span');
|
||||
caretMarker.innerHTML = ' '; // Use non-breaking space for measurement
|
||||
const postCaretText = document.createTextNode(inputText.substring(caretPosition));
|
||||
|
||||
// Append spans to the div
|
||||
div.appendChild(preCaretText);
|
||||
div.appendChild(caretMarker);
|
||||
div.appendChild(postCaretText);
|
||||
|
||||
// Append div to body for measurement
|
||||
document.body.appendChild(div);
|
||||
|
||||
// Get position relative to the div's content box
|
||||
const markerRect = caretMarker.getBoundingClientRect();
|
||||
const divRect = div.getBoundingClientRect();
|
||||
|
||||
// Calculate position relative to the input's top-left, considering scroll
|
||||
const cursorLeft = markerRect.left - divRect.left;
|
||||
const cursorTop = markerRect.top - divRect.top - input.scrollTop;
|
||||
|
||||
// Set cursor position
|
||||
cursor.style.left = `${cursorLeft}px`;
|
||||
cursor.style.top = `${cursorTop}px`;
|
||||
|
||||
// Clean up the temporary div
|
||||
document.body.removeChild(div);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust textarea height based on its content.
|
||||
*/
|
||||
adjustTextareaHeight() {
|
||||
if (!this.playerInput) return;
|
||||
const textarea = this.playerInput;
|
||||
// Temporarily reset height to accurately measure scrollHeight
|
||||
textarea.style.height = 'auto';
|
||||
// Set height to scrollHeight to fit content, adding a small buffer if needed
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up focus management to keep the input field focused.
|
||||
* Note: Some parts might be better handled by the main application logic
|
||||
* depending on overall focus requirements (e.g., clicking outside input).
|
||||
*/
|
||||
setupFocusManagement() {
|
||||
// Focus input field when the handler is initialized
|
||||
this.focus();
|
||||
|
||||
// Re-focus input when user returns to this browser tab/window
|
||||
window.addEventListener('focus', () => this.focus());
|
||||
window.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
setTimeout(() => this.focus(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Optional: Add a listener to the document to refocus if needed,
|
||||
// but be careful not to interfere with other interactive elements.
|
||||
/*
|
||||
document.addEventListener('click', (e) => {
|
||||
// Example: Refocus if click is not on specific elements
|
||||
if (!e.target.closest('button, a, .interactive-ui-element')) {
|
||||
this.focus();
|
||||
}
|
||||
});
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
// Export the class if using modules (optional, depends on build setup)
|
||||
// export default InputHandler;
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* LayoutRenderer Module
|
||||
* Translates the abstract layout data into concrete visual elements (DOM nodes).
|
||||
*/
|
||||
export class LayoutRenderer {
|
||||
/**
|
||||
* Create a new LayoutRenderer
|
||||
* @param {Object} animationQueue - The AnimationQueue instance
|
||||
*/
|
||||
constructor(animationQueue) {
|
||||
this.animationQueue = animationQueue;
|
||||
this.fastForwardingAll = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a paragraph based on layout data
|
||||
* @param {Object} paragraphData - The layout data from ParagraphLayout
|
||||
* @param {number} delay - Initial delay for animations
|
||||
* @param {Array<number>} measure - Array of line width measurements
|
||||
* @returns {Array} Array containing the paragraph element and the final delay
|
||||
*/
|
||||
renderParagraph(paragraphData, delay = 0, measure = []) {
|
||||
const stack = [];
|
||||
let left = 0;
|
||||
const p = document.createElement("p");
|
||||
p.style.position = 'relative';
|
||||
p.classList.add("latest-paragraph");
|
||||
p.dataset.numberOfLines = paragraphData.breaks.length - 1;
|
||||
|
||||
const lineHeight = parseFloat(window.getComputedStyle(document.querySelector('#ruler')).lineHeight);
|
||||
const lineWidth = parseFloat(window.getComputedStyle(document.getElementById('story')).width);
|
||||
const pageHeight = parseFloat(window.getComputedStyle(document.getElementById('page_right')).height);
|
||||
|
||||
p.style.height = lineHeight * (paragraphData.breaks.length - 1) + 'px';
|
||||
const paragraphHeight = parseFloat(p.style.height);
|
||||
p.dataset.vpc = paragraphHeight * 100 / pageHeight;
|
||||
p.style.marginBlockEnd = 0;
|
||||
stack.push(p);
|
||||
|
||||
for (let i = 1; i < paragraphData.breaks.length; i++) {
|
||||
left = measure[measure.length - 1] - measure[Math.min(i - 1, measure.length - 1)];
|
||||
let lastChild = null;
|
||||
let syllable = "";
|
||||
|
||||
for (let j = paragraphData.breaks[i-1].position; j <= paragraphData.breaks[i].position; j++) {
|
||||
if (paragraphData.nodes[j].type === 'box' && paragraphData.nodes[j].value !== '' && j < paragraphData.breaks[i].position) {
|
||||
if (j > paragraphData.breaks[i-1].position + 1 && paragraphData.nodes[j-1].type === 'penalty' && lastChild) {
|
||||
syllable += '\u200c' + paragraphData.nodes[j].value;
|
||||
lastChild.innerHTML = syllable;
|
||||
left += paragraphData.nodes[j].width;
|
||||
} else {
|
||||
let word = document.createElement("span");
|
||||
word.style.position = 'absolute';
|
||||
word.classList.add("fade-in");
|
||||
word.style.animationDuration = this.animationQueue.getSpeed() * 10 + 'ms';
|
||||
word.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%';
|
||||
word.style.left = left * 100 / lineWidth + '%';
|
||||
syllable = paragraphData.nodes[j].value;
|
||||
word.innerHTML = syllable;
|
||||
lastChild = word;
|
||||
|
||||
if (!this.fastForwardingAll) {
|
||||
this.insertAfter(delay, stack[stack.length-1], word);
|
||||
}
|
||||
|
||||
delay += this.animationQueue.getSpeed();
|
||||
left += paragraphData.nodes[j].width;
|
||||
}
|
||||
} else if (paragraphData.nodes[j].type === 'tag') {
|
||||
if (paragraphData.nodes[j].value.substr(0, 2) == '</') {
|
||||
stack.pop();
|
||||
} else {
|
||||
let tmp = document.createElement('div');
|
||||
tmp.innerHTML = paragraphData.nodes[j].value;
|
||||
const word = tmp.firstChild;
|
||||
word.style.left = left * 100 / lineWidth + '%';
|
||||
stack[stack.length-1].appendChild(word);
|
||||
stack.push(word);
|
||||
}
|
||||
} else if (j > paragraphData.breaks[i-1].position && paragraphData.nodes[j].type === 'glue' && paragraphData.nodes[j].width !== 0 && j <= paragraphData.breaks[i].position) {
|
||||
// Insert space character
|
||||
if (paragraphData.breaks[i].ratio > 0) {
|
||||
left += paragraphData.nodes[j].width + paragraphData.breaks[i].ratio * paragraphData.nodes[j].stretch;
|
||||
} else {
|
||||
left += paragraphData.nodes[j].width + paragraphData.breaks[i].ratio * paragraphData.nodes[j].shrink;
|
||||
}
|
||||
|
||||
let word = document.createElement("span");
|
||||
word.style.position = 'absolute';
|
||||
word.classList.add("fade-in");
|
||||
word.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%';
|
||||
word.style.left = left * 100 / lineWidth + '%';
|
||||
word.innerHTML = " ";
|
||||
|
||||
if (!this.fastForwardingAll) {
|
||||
this.insertAfter(delay, stack[stack.length-1], word);
|
||||
}
|
||||
} else if (paragraphData.nodes[j].type === 'penalty' && paragraphData.nodes[j].penalty === 100 && j === paragraphData.breaks[i].position) {
|
||||
// Create a hyphen at the end of the line if breaking at a hyphenation point
|
||||
let hyphen = document.createElement("span");
|
||||
hyphen.style.position = 'absolute';
|
||||
hyphen.classList.add("fade-in");
|
||||
hyphen.classList.add("hyphen-marker"); // Add a class for easier styling if needed
|
||||
hyphen.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%';
|
||||
hyphen.style.left = left * 100 / lineWidth + '%';
|
||||
hyphen.innerHTML = "-";
|
||||
|
||||
// Ensure hyphen is visible with stronger styling
|
||||
hyphen.style.fontWeight = "normal";
|
||||
hyphen.style.opacity = "1";
|
||||
|
||||
if (!this.fastForwardingAll) {
|
||||
this.insertAfter(delay, stack[stack.length-1], hyphen);
|
||||
// Log for debugging
|
||||
console.log("Inserted hyphen at line break:", i, "position:", left);
|
||||
}
|
||||
|
||||
delay += this.animationQueue.getSpeed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [p, delay];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an element after a delay
|
||||
* @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 (fadeIn) {
|
||||
el.classList.add("fade-in");
|
||||
this.animationQueue.schedule(function() {
|
||||
target.appendChild(el);
|
||||
}, delay);
|
||||
} else {
|
||||
this.animationQueue.schedule(function() {
|
||||
target.appendChild(el);
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an element after a delay
|
||||
* @param {number} delay - The delay in milliseconds
|
||||
* @param {HTMLElement} el - The element to show
|
||||
*/
|
||||
showAfter(delay, el) {
|
||||
el.classList.add("hide");
|
||||
setTimeout(function() {
|
||||
setTimeout(function() { el.classList.remove("hide") }, delay);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a visual tag
|
||||
* @param {string} tagType - The type of tag (IMAGE, BACKGROUND, etc.)
|
||||
* @param {string} tagValue - The value of the tag
|
||||
* @param {HTMLElement} container - The container to append to
|
||||
* @param {number} delay - The delay in milliseconds
|
||||
* @returns {HTMLElement|null} The created element or null
|
||||
*/
|
||||
renderVisualTag(tagType, tagValue, container, delay = 0) {
|
||||
switch (tagType) {
|
||||
case "IMAGE":
|
||||
const imageElement = document.createElement('img');
|
||||
imageElement.src = tagValue;
|
||||
container.appendChild(imageElement);
|
||||
this.showAfter(delay, imageElement);
|
||||
return imageElement;
|
||||
|
||||
case "BACKGROUND":
|
||||
const outerScrollContainer = document.querySelector('#book');
|
||||
outerScrollContainer.style.backgroundImage = 'url(' + tagValue + ')';
|
||||
return null;
|
||||
|
||||
case "CHAPTER":
|
||||
const h = document.createElement('H2');
|
||||
h.appendChild(document.createTextNode(tagValue));
|
||||
h.classList.add("chapter-heading");
|
||||
h.classList.add("fade-in");
|
||||
container.appendChild(h);
|
||||
return h;
|
||||
|
||||
case "SEPARATOR":
|
||||
const d = document.createElement('double');
|
||||
d.appendChild(document.createTextNode('\u2766'));
|
||||
d.classList.add("fade-in");
|
||||
d.classList.add("separator");
|
||||
container.appendChild(d);
|
||||
return d;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the fast forwarding state
|
||||
* @param {boolean} state - The fast forwarding state
|
||||
*/
|
||||
setFastForwardingAll(state) {
|
||||
this.fastForwardingAll = state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fast forwarding state
|
||||
* @returns {boolean} The fast forwarding state
|
||||
*/
|
||||
getFastForwardingAll() {
|
||||
return this.fastForwardingAll;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smooth scroll to an element
|
||||
* @param {HTMLElement} target - The target element to scroll to
|
||||
* @param {number} duration - The duration of the scroll animation
|
||||
*/
|
||||
smoothScroll(target, duration) {
|
||||
const display = document.getElementById('page_right');
|
||||
const targetPosition = target.getBoundingClientRect().top;
|
||||
const startPosition = display.scrollTop;
|
||||
const distance = targetPosition;
|
||||
let startTime = null;
|
||||
|
||||
if (duration < 5) {
|
||||
display.scrollTo(0, targetPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
function animation(currentTime) {
|
||||
if (startTime === null) startTime = currentTime;
|
||||
const timeElapsed = currentTime - startTime;
|
||||
const run = ease(timeElapsed, startPosition, distance, duration);
|
||||
display.scrollTo(0, run);
|
||||
if (timeElapsed < duration) requestAnimationFrame(animation);
|
||||
}
|
||||
|
||||
function ease(t, b, c, d) {
|
||||
t /= d / 2;
|
||||
if (t < 1) return c / 2 * t * t + b;
|
||||
t--;
|
||||
return -c / 2 * (t * (t - 2) - 1) + b;
|
||||
}
|
||||
|
||||
requestAnimationFrame(animation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* ParagraphLayout Module
|
||||
* Interfaces with the Knuth-Plass line breaking algorithm to calculate optimal line breaks.
|
||||
*/
|
||||
export class ParagraphLayout {
|
||||
/**
|
||||
* Create a new ParagraphLayout
|
||||
* @param {Function} kapAlgorithm - The Knuth and Plass algorithm function
|
||||
* @param {Function} measureTextFunc - Function to measure text width
|
||||
*/
|
||||
constructor(kapAlgorithm, measureTextFunc) {
|
||||
this.kapAlgorithm = kapAlgorithm;
|
||||
this.measureText = measureTextFunc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate layout for a paragraph
|
||||
* @param {string} processedText - The pre-processed text (with SmartyPants and hyphenation)
|
||||
* @param {Array<number>} measures - Array of line width measurements
|
||||
* @param {boolean} debug - Whether to enable debug output
|
||||
* @param {Function} [measureFunc] - Optional specific measurement function for this call
|
||||
* @returns {Object} Layout data with nodes and breaks
|
||||
*/
|
||||
calculateLayout(processedText, measures, hyphenate = false, measureFunc = null) {
|
||||
const measure = measureFunc || this.measureText; // Use provided func or fallback to instance default
|
||||
if (typeof measure !== 'function') {
|
||||
console.error("ParagraphLayout: Invalid measure function provided or stored.");
|
||||
// Return a dummy layout or throw an error?
|
||||
return { nodes: [], breaks: [] }; // Return empty layout
|
||||
}
|
||||
return this.kapAlgorithm(processedText, measure, measures, hyphenate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new text measurement function
|
||||
* @param {Function} measureFunc - The new measurement function
|
||||
*/
|
||||
setMeasureFunction(measureFunc) {
|
||||
this.measureText = measureFunc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new Knuth and Plass algorithm implementation
|
||||
* @param {Function} kapFunc - The new KAP algorithm function
|
||||
*/
|
||||
setKapAlgorithm(kapFunc) {
|
||||
this.kapAlgorithm = kapFunc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* PersistenceManager Module
|
||||
* Handles saving and loading the game state.
|
||||
*/
|
||||
export class PersistenceManager {
|
||||
/**
|
||||
* Create a new PersistenceManager
|
||||
* @param {Object} config - Configuration options
|
||||
* @param {Storage} config.storage - The storage backend (e.g., localStorage)
|
||||
* @param {string} config.saveStateKey - Key for saving the state
|
||||
* @param {string} config.saveHistoryKey - Key for saving the history
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
this.storage = config.storage || window.localStorage;
|
||||
this.saveStateKey = config.saveStateKey || 'save-state';
|
||||
this.saveHistoryKey = config.saveHistoryKey || 'save-history';
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current state
|
||||
* @param {Object} stateObject - The state object to save
|
||||
* @param {string} stateObject.inkJson - The serialized Ink state
|
||||
* @param {Array<string>} stateObject.history - Array of HTML strings representing the story history
|
||||
* @returns {boolean} Whether the save was successful
|
||||
*/
|
||||
saveState(stateObject) {
|
||||
try {
|
||||
if (stateObject.inkJson) {
|
||||
this.storage.setItem(this.saveStateKey, stateObject.inkJson);
|
||||
}
|
||||
|
||||
if (stateObject.history) {
|
||||
this.storage.setItem(this.saveHistoryKey, JSON.stringify(stateObject.history));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving state:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the saved state
|
||||
* @returns {Object|null} The loaded state object or null if no save exists
|
||||
*/
|
||||
loadState() {
|
||||
try {
|
||||
const inkJson = this.storage.getItem(this.saveStateKey);
|
||||
const historyJson = this.storage.getItem(this.saveHistoryKey);
|
||||
|
||||
if (!inkJson && !historyJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = {};
|
||||
|
||||
if (inkJson) {
|
||||
result.inkJson = inkJson;
|
||||
}
|
||||
|
||||
if (historyJson) {
|
||||
result.history = JSON.parse(historyJson);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error loading state:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a saved state exists
|
||||
* @returns {boolean} Whether a saved state exists
|
||||
*/
|
||||
hasSavedState() {
|
||||
return this.storage.getItem(this.saveStateKey) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the saved state
|
||||
* @returns {boolean} Whether the deletion was successful
|
||||
*/
|
||||
deleteSavedState() {
|
||||
try {
|
||||
this.storage.removeItem(this.saveStateKey);
|
||||
this.storage.removeItem(this.saveHistoryKey);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting saved state:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the saved state as a JSON string
|
||||
* @returns {string|null} The exported state as a JSON string or null if no save exists
|
||||
*/
|
||||
exportState() {
|
||||
const state = this.loadState();
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a state from a JSON string
|
||||
* @param {string} jsonString - The JSON string to import
|
||||
* @returns {boolean} Whether the import was successful
|
||||
*/
|
||||
importState(jsonString) {
|
||||
try {
|
||||
const state = JSON.parse(jsonString);
|
||||
return this.saveState(state);
|
||||
} catch (error) {
|
||||
console.error('Error importing state:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Socket Client Module
|
||||
* Manages WebSocket communication with the game server.
|
||||
*/
|
||||
class SocketClient {
|
||||
constructor(serverUrl) {
|
||||
this.socket = null;
|
||||
this.serverUrl = serverUrl || window.location.origin; // Default to current origin
|
||||
this.eventListeners = {
|
||||
connect: [],
|
||||
disconnect: [],
|
||||
connect_error: [],
|
||||
gameIntroduction: [],
|
||||
narrativeResponse: [],
|
||||
gameSaved: [],
|
||||
gameLoaded: [],
|
||||
error: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the WebSocket server.
|
||||
*/
|
||||
connect() {
|
||||
if (this.socket && this.socket.connected) {
|
||||
console.log('SocketClient: Already connected.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`SocketClient: Connecting to ${this.serverUrl}...`);
|
||||
// Ensure io is available (it should be loaded globally)
|
||||
if (typeof io === 'undefined') {
|
||||
console.error('Socket.IO client library (io) not found. Make sure it is loaded.');
|
||||
this.triggerEvent('error', { message: 'Socket.IO library not loaded.' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket = io(this.serverUrl, {
|
||||
reconnectionAttempts: 5,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
this.initializeSocketEventHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects from the server.
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
console.log('SocketClient: Disconnecting...');
|
||||
this.socket.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the client is currently connected.
|
||||
* @returns {boolean} True if connected, false otherwise.
|
||||
*/
|
||||
isConnected() {
|
||||
return this.socket && this.socket.connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the listeners for standard socket events.
|
||||
*/
|
||||
initializeSocketEventHandlers() {
|
||||
if (!this.socket) return;
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('SocketClient: Connected to server.');
|
||||
this.triggerEvent('connect');
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log(`SocketClient: Disconnected from server. Reason: ${reason}`);
|
||||
this.triggerEvent('disconnect', reason);
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('SocketClient: Connection error:', error);
|
||||
this.triggerEvent('connect_error', error);
|
||||
});
|
||||
|
||||
// --- Game-specific events ---
|
||||
|
||||
this.socket.on('gameIntroduction', (data) => {
|
||||
console.log('SocketClient: Received gameIntroduction');
|
||||
this.triggerEvent('gameIntroduction', data);
|
||||
});
|
||||
|
||||
this.socket.on('narrativeResponse', (data) => {
|
||||
console.log('SocketClient: Received narrativeResponse');
|
||||
this.triggerEvent('narrativeResponse', data);
|
||||
});
|
||||
|
||||
this.socket.on('gameSaved', (data) => {
|
||||
console.log('SocketClient: Received gameSaved confirmation');
|
||||
this.triggerEvent('gameSaved', data); // Pass data if any
|
||||
});
|
||||
|
||||
this.socket.on('gameLoaded', (data) => {
|
||||
console.log('SocketClient: Received gameLoaded confirmation');
|
||||
this.triggerEvent('gameLoaded', data);
|
||||
});
|
||||
|
||||
this.socket.on('error', (data) => {
|
||||
console.error('SocketClient: Received error from server:', data);
|
||||
this.triggerEvent('error', data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a listener for a specific event.
|
||||
* @param {string} eventName - The name of the event.
|
||||
* @param {function} callback - The function to call when the event occurs.
|
||||
*/
|
||||
on(eventName, callback) {
|
||||
if (this.eventListeners[eventName]) {
|
||||
this.eventListeners[eventName].push(callback);
|
||||
} else {
|
||||
console.warn(`SocketClient: Attempted to register listener for unknown event "${eventName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a specific event, calling all registered listeners.
|
||||
* @param {string} eventName - The name of the event.
|
||||
* @param {*} data - Data to pass to the listeners.
|
||||
*/
|
||||
triggerEvent(eventName, data) {
|
||||
if (this.eventListeners[eventName]) {
|
||||
this.eventListeners[eventName].forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error(`SocketClient: Error in event listener for "${eventName}":`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an event to the server.
|
||||
* @param {string} eventName - The name of the event to emit.
|
||||
* @param {object} data - The data to send with the event.
|
||||
*/
|
||||
emit(eventName, data) {
|
||||
if (this.socket && this.socket.connected) {
|
||||
console.log(`SocketClient: Emitting "${eventName}"`, data || '');
|
||||
this.socket.emit(eventName, data);
|
||||
} else {
|
||||
console.error(`SocketClient: Cannot emit "${eventName}", not connected.`);
|
||||
// Optionally trigger an error event or queue the message
|
||||
this.triggerEvent('error', { message: `Cannot send command "${eventName}", not connected.` });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Convenience methods for game actions ---
|
||||
|
||||
requestStartGame() {
|
||||
this.emit('startGame');
|
||||
}
|
||||
|
||||
sendCommand(command) {
|
||||
this.emit('playerCommand', { command });
|
||||
}
|
||||
|
||||
requestSaveGame() {
|
||||
this.emit('saveGame');
|
||||
}
|
||||
|
||||
requestLoadGame() {
|
||||
this.emit('loadGame');
|
||||
}
|
||||
}
|
||||
|
||||
// Export the class if using modules
|
||||
// export default SocketClient;
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* TextProcessor Module
|
||||
* Encapsulates text pre-processing steps required before layout calculation.
|
||||
*/
|
||||
export class TextProcessor {
|
||||
/**
|
||||
* Create a new TextProcessor
|
||||
* @param {Object} smartyPants - The SmartyPants library
|
||||
* @param {Function} [hyphenator] - Optional: The hyphenation function (can be set later)
|
||||
*/
|
||||
constructor(smartyPants, hyphenator = null) { // Make hyphenator optional
|
||||
this.smartyPants = smartyPants;
|
||||
this.hyphenator = hyphenator;
|
||||
this.hyphenationClass = '.hyphenatePipe'; // Default hyphenation class for Knuth-Plass with pipe character
|
||||
}
|
||||
|
||||
/**
|
||||
* Process text with typographic enhancements and hyphenation
|
||||
* @param {string} text - The text to process
|
||||
* @returns {string} The processed text
|
||||
*/
|
||||
process(text) {
|
||||
// First apply SmartyPants for typographic enhancement
|
||||
const smartyPantsText = this.smartyPants.smartypantsu(text, 1)
|
||||
// Remove these replacements that were causing the spacing issues
|
||||
// .replace(/\.\s*$/g, '.')
|
||||
// .replace(/\?\s*$/g, '?')
|
||||
// .replace(/!\s*$/g, '!')
|
||||
|
||||
// Instead, ensure proper spacing between sentences
|
||||
.replace(/\.\s+/g, '. ') // Normalize spaces after periods
|
||||
.replace(/\?\s+/g, '? ') // Normalize spaces after question marks
|
||||
.replace(/!\s+/g, '! '); // Normalize spaces after exclamation marks
|
||||
|
||||
// Then apply hyphenation if available
|
||||
if (typeof this.hyphenator === 'function') {
|
||||
return this.hyphenator(smartyPantsText, this.hyphenationClass);
|
||||
} else {
|
||||
console.warn('TextProcessor: Hyphenator not set, skipping hyphenation.');
|
||||
return smartyPantsText; // Return text without hyphenation if not set
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the hyphenator function after initialization.
|
||||
* @param {Function} hyphenatorFunction - The hyphenation function provided by Hyphenopoly.
|
||||
*/
|
||||
setHyphenator(hyphenatorFunction) {
|
||||
if (typeof hyphenatorFunction === 'function') {
|
||||
this.hyphenator = hyphenatorFunction;
|
||||
} else {
|
||||
console.error('TextProcessor: Invalid hyphenator function provided.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the hyphenation class
|
||||
* @param {string} className - The CSS class for hyphenation
|
||||
*/
|
||||
setHyphenationClass(className) {
|
||||
this.hyphenationClass = className;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current hyphenation class
|
||||
* @returns {string} The current hyphenation class
|
||||
*/
|
||||
getHyphenationClass() {
|
||||
return this.hyphenationClass;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* TTS Player Module
|
||||
* Manages text-to-speech playback integration with animation queue.
|
||||
*/
|
||||
export class TtsPlayer {
|
||||
/**
|
||||
* Create a new TtsPlayer
|
||||
* @param {Object} config - Configuration options
|
||||
* @param {string} config.apiKey - API key for TTS service (if applicable)
|
||||
* @param {Object} config.animationQueue - AnimationQueue instance for synchronization
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
this.animationQueue = config.animationQueue;
|
||||
this.ttsHandler = null;
|
||||
this.enabled = false; // Start with TTS disabled by default
|
||||
this.currentAudio = null;
|
||||
|
||||
// Bind methods to ensure 'this' context
|
||||
this.speak = this.speak.bind(this);
|
||||
this.stop = this.stop.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the TTS handler
|
||||
* @param {Object} ttsHandler - The TTS handler instance
|
||||
*/
|
||||
setTtsHandler(ttsHandler) {
|
||||
if (!ttsHandler) {
|
||||
console.warn("TtsPlayer: No valid TTS handler provided.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("TtsPlayer: Handler set to", ttsHandler.constructor.name);
|
||||
this.ttsHandler = ttsHandler;
|
||||
|
||||
// Make sure the window.ttsHandler is also set for global access
|
||||
if (!window.ttsHandler) {
|
||||
window.ttsHandler = ttsHandler;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable TTS
|
||||
* @param {boolean} enabled - Whether TTS should be enabled
|
||||
*/
|
||||
setEnabled(enabled = true) {
|
||||
this.enabled = enabled;
|
||||
console.log(`TtsPlayer: TTS ${enabled ? 'enabled' : 'disabled'}`);
|
||||
|
||||
// If disabling while audio is playing, stop it
|
||||
if (!enabled && this.currentAudio) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
// Also set the handler's state if available
|
||||
if (this.ttsHandler && typeof this.ttsHandler.setEnabled === 'function') {
|
||||
this.ttsHandler.setEnabled(enabled);
|
||||
} else if (window.ttsHandler && typeof window.ttsHandler.setEnabled === 'function') {
|
||||
window.ttsHandler.setEnabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle TTS state
|
||||
* @returns {boolean} The new enabled state
|
||||
*/
|
||||
toggle() {
|
||||
this.setEnabled(!this.enabled);
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TTS is enabled
|
||||
* @returns {boolean} Whether TTS is enabled
|
||||
*/
|
||||
isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text
|
||||
* @param {string} text - The text to speak
|
||||
*/
|
||||
speak(text) {
|
||||
if (!this.enabled || !text) return;
|
||||
|
||||
// Stop any current speech
|
||||
this.stop();
|
||||
|
||||
console.log(`TtsPlayer: Speaking - "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// Try to use our handler first
|
||||
if (this.ttsHandler && typeof this.ttsHandler.speak === 'function') {
|
||||
this.ttsHandler.speak(text);
|
||||
}
|
||||
// Fall back to window.ttsHandler if available
|
||||
else if (window.ttsHandler && typeof window.ttsHandler.speak === 'function') {
|
||||
window.ttsHandler.speak(text);
|
||||
}
|
||||
else {
|
||||
console.warn("TtsPlayer: No TTS handler available to speak text");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop current speech
|
||||
*/
|
||||
stop() {
|
||||
// Try to use our handler first
|
||||
if (this.ttsHandler && typeof this.ttsHandler.stop === 'function') {
|
||||
this.ttsHandler.stop();
|
||||
}
|
||||
// Fall back to window.ttsHandler if available
|
||||
else if (window.ttsHandler && typeof window.ttsHandler.stop === 'function') {
|
||||
window.ttsHandler.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast forward current speech (may skip or speed up)
|
||||
*/
|
||||
fastForward() {
|
||||
// Try to use our handler first
|
||||
if (this.ttsHandler && typeof this.ttsHandler.fastForward === 'function') {
|
||||
this.ttsHandler.fastForward();
|
||||
}
|
||||
// Fall back to window.ttsHandler if available
|
||||
else if (window.ttsHandler && typeof window.ttsHandler.fastForward === 'function') {
|
||||
window.ttsHandler.fastForward();
|
||||
}
|
||||
// If no fastForward method, just stop the speech
|
||||
else {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user