Refactored app to include all the ink.js typography.

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