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
+22 -4
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> <!-- 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'" --> <!-- 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> <title>ai-fiction Book Runtime (Modular Version)</title>
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
</head> </head>
<body> <body>
@@ -52,14 +52,18 @@
<!-- Socket.io library for client-server communication --> <!-- Socket.io library for client-server communication -->
<script src="/socket.io/socket.io.js"></script> <script src="/socket.io/socket.io.js"></script>
<!-- You can also require other files to run in this process -->
<!-- Core libraries -->
<script src="js/smartypants.js"></script> <script src="js/smartypants.js"></script>
<script src="js/linked-list.js"></script> <script src="js/linked-list.js"></script>
<script src="js/linebreak.js"></script> <script src="js/linebreak.js"></script>
<script src="js/knuth-and-plass.js"></script> <script src="js/knuth-and-plass.js"></script>
<script src="js/Hyphenopoly_Loader.js"></script> <script src="js/Hyphenopoly_Loader.js"></script>
<script> <script>
var locale = "en"; var locale = "en";
// Create global variables needed by the modules
window.rstack = [];
</script> </script>
<!-- TTS implementation scripts - order matters! --> <!-- TTS implementation scripts - order matters! -->
@@ -91,7 +95,21 @@
<!-- 3. TTS Factory for automatic selection --> <!-- 3. TTS Factory for automatic selection -->
<script src="js/tts-factory.js"></script> <script src="js/tts-factory.js"></script>
<!-- Main application script --> <!-- New Modules for Socket-based Interaction -->
<script src="js/ai-fiction.js"></script> <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> </body>
</html> </html>
+698 -696
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`);
}
}
}
+441
View File
@@ -0,0 +1,441 @@
# AI Interactive Fiction Engine - Technical Documentation
## 1. Overview
This document provides a comprehensive technical explanation of the AI Interactive Fiction Engine implemented in `game.js`. The engine leverages inkjs for narrative management and incorporates advanced text rendering features, including custom animations, sophisticated typography using SmartyPants and Hyphenopoly, and optimal line breaking via the Knuth and Plass algorithm, to create rich, dynamic interactive fiction experiences.
## 2. Core Technologies
The engine is built upon several key technologies:
* **inkjs**: Manages the core interactive fiction logic, including story state, branching narrative, choices, and variables.
* **Knuth and Plass Line Breaking Algorithm**: Adapted implementation for optimal paragraph justification and line breaking, ensuring high-quality text layout. (Based on Bram Stein's "typeset" library).
* **Hyphenopoly**: Provides high-quality, language-aware text hyphenation, crucial for justified text layout.
* **SmartyPants**: Enhances typography by converting plain text elements (quotes, dashes) into their typographically correct equivalents.
* **Custom Animation System**: A queue-based system handles the precise timing and visual reveal of text elements.
* **JavaScript (ES6+)**: The primary implementation language.
* **HTML/CSS**: Used for structuring the UI and styling the visual presentation.
## 3. Engine Architecture and Flow
The engine follows a well-defined process from initialization to interactive playback.
### 3.1. Initialization (`window.onload`)
Execution begins when the window loads:
1. **Initial State Setup**: Global state variables (`running`, `fastForwardingAll`, `speech`, `speed`, `delay`) are initialized.
2. **Background Effects**: Lighting animations for background visuals are configured.
3. **Text Libraries**: Hyphenopoly is initialized for hyphenation support.
4. **Story Loading**: The compiled Ink story (JSON format) is fetched and loaded.
5. **Event Listeners**: Listeners for user interactions (keyboard input, clicks, slider changes) are attached.
6. **Playback Start**: The `continueStory()` function is called to begin rendering the narrative.
### 3.2. The Main Playback Loop (`continueStory()`)
The `continueStory()` function is the engine's core, orchestrating the display of narrative content and handling user choices.
**Step-by-step Process:**
1. **Setup**: Animation state variables (`fade_in`, `running`, `fastForwardingAll`) are reset for the current turn.
2. **Story Content Iteration**:
```javascript
while(story.canContinue) {
// Fetch next paragraph & associated tags from inkjs
var paragraphText = story.Continue();
var tags = story.currentTags;
// Process tags (Section 3.3)
// ...
// Process and render text (Section 4)
// ...
}
```
The loop continues as long as inkjs indicates more content is available for the current narrative sequence.
3. **Choice Handling**: After exhausting the `story.canContinue` content, the engine checks for available choices:
```javascript
story.currentChoices.forEach(function(choice) {
// Create interactive DOM elements for each choice
// Attach event listeners to handle choice selection
// Apply appropriate styling
});
```
When a user selects a choice, an event handler calls `story.ChooseChoiceIndex(choice.index)` and then triggers `continueStory()` again to display the subsequent narrative branch.
4. **End of Story**: If `story.canContinue` is false and there are no choices, the story concludes or reaches a waiting point.
### 3.3. Tag Processing
Ink tags are used to control various non-textual aspects of the presentation:
* `AUDIO <url>`: Triggers playback of an audio file.
* `IMAGE <url>`: Displays an image.
* `BACKGROUND <url/color>`: Changes the background style.
* `CHAPTER`: Applies special formatting for chapter headings (often including drop caps).
* `SEPARATOR`: Inserts a decorative visual separator element.
* Custom CSS Classes: Tags can directly add CSS classes to paragraph elements for specific styling.
Tags are processed within the `continueStory` loop before the associated paragraph text is rendered.
## 4. Advanced Text Rendering Pipeline
A key feature of this engine is its sophisticated text rendering pipeline, designed to produce professional-quality typography and layout.
### 4.1. Overview
Text rendering involves several stages for each paragraph:
1. **Preprocessing**: Apply SmartyPants for typographic correctness and Hyphenopoly for hyphenation points.
2. **Line Breaking Calculation**: Use the Knuth and Plass algorithm (`kap` function) to determine optimal line breaks for the entire paragraph.
3. **Typesetting and DOM Creation**: Use the `typesetParagraph` function to generate and position DOM elements based on the line break data.
4. **Animation Scheduling**: Schedule the appearance of each text fragment using the custom animation system.
### 4.2. Knuth and Plass Line Breaking
The engine employs an adaptation of the Knuth and Plass line breaking algorithm to achieve superior text justification compared to standard browser methods.
#### 4.2.1. Origin and Adaptation
* The core logic is based on [Bram Stein's "typeset" library](https://github.com/bramstein/typeset).
* **Key Adaptations**:
* **HTML Tag Support**: Introduced `linebreak.tag()` nodes to preserve inline HTML formatting (e.g., `<b>`, `<i>`, custom spans) during line breaking.
* **Punctuation Handling**: Implemented custom logic for refined spacing around punctuation marks (`.,:;!?`), splitting width into symbolic (25%) and spacing (75%) components.
* **Hyphenation Integration**: Tightly integrated with Hyphenopoly, treating hyphenation points as potential breaks with a specific, reduced penalty (25% of normal hyphen width).
#### 4.2.2. Algorithm Principles
* **Optimization Problem**: Treats line breaking as minimizing the overall "badness" (demerits) of a paragraph's layout.
* **Global View**: Considers the entire paragraph at once, unlike greedy algorithms.
* **Box, Glue, Penalty Model**: Represents text as:
* **Boxes**: Unbreakable units (words, tagged elements).
* **Glue**: Flexible spaces that can stretch or shrink.
* **Penalties**: Opportunities for line breaks (e.g., after spaces, hyphens), with associated costs (demerits).
* **Demerits System**: Assigns penalties based on line tightness, hyphenation, and relationships between consecutive lines (fitness classes).
* **Optimal Path**: Finds the sequence of line breaks with the lowest total demerit score.
#### 4.2.3. Implementation (`knuth-and-plass.js`, `linebreak.js`)
* **`knuth-and-plass.js` (Adapter Layer)**:
* Provides the `kap(text, measureText, measure, hyphenation)` function.
* Parses the input text (already processed by SmartyPants/Hyphenopoly) into Box, Glue, Penalty, and Tag nodes.
* Handles special spacing rules for punctuation.
* Interfaces with the core `linebreak.js` module.
* **`linebreak.js` (Core Algorithm)**:
* Contains the `linebreak(nodes, lines, settings)` function.
* Implements the main algorithm logic: ratio calculation, demerit calculation, fitness class tracking, active node management (using the LinkedList), and path finding.
* Defines node types: `linebreak.box()`, `linebreak.glue()`, `linebreak.penalty()`, `linebreak.tag()`.
* **Customization Parameters**:
* `tolerance`: Controls acceptable line stretch/shrink (default: 2).
* `demerits`: Configurable penalties for lines, flagged breaks (e.g., consecutive hyphens), and fitness class mismatches.
#### 4.2.4. Supporting Data Structure: LinkedList (`linked-list.js`)
* A custom, modern ES6 implementation of a doubly linked list, derived from the `typeset` library.
* **Purpose**: Efficiently manages the "active nodes" (potential breakpoints being considered) within the `linebreak.js` algorithm. Critical for performance.
* **Features**: ES6 class syntax, getter methods (`size`, `first`), method chaining.
* **Known Bug**: The `get last()` method incorrectly references `this.last` instead of `this.tail`, potentially causing infinite recursion if called. **This should be corrected to `return this.tail;`**.
#### 4.2.5. Advantages
* Superior justification and word spacing.
* Reduced and more aesthetically placed hyphenation.
* Avoids jarringly tight or loose lines.
* Produces professional, book-like typography.
* Preserves rich text formatting during layout optimization.
### 4.3. Typography Enhancements (SmartyPants)
* Before line breaking, text is processed by `SmartyPants.smartypantsu(text, 1)`.
* This converts:
* Straight quotes (`"`, `'`) to curly quotes (“ ”, ).
* Double hyphens (`--`) to em-dashes (—).
* Triple hyphens (`---`) to em-dashes (—) (configurable).
* Three periods (`...`) to ellipses (…).
### 4.4. Hyphenation (Hyphenopoly)
* Applied after SmartyPants using `hyphenator_en(text, '.hyphenatePipe')`.
* Inserts soft hyphen characters (`&shy;` or similar markers) at valid break points within words, based on linguistic rules.
* These potential hyphenation points are then treated as `Penalty` nodes by the line-breaking algorithm, allowing for more flexible justification.
### 4.5. Typesetting and DOM Rendering (`typesetParagraph()`)
This function translates the calculated layout data from `kap` into visible, animated DOM elements.
```javascript
function typesetParagraph(paragraph_data, delay = 0, measure = []) {
// ... setup paragraph element <p> ...
// Iterate through lines defined by paragraph_data.breaks
for(let i = 1; i < paragraph_data.breaks.length; i++) {
// Iterate through nodes (words, spaces, hyphens, tags) within the line
for(let j = paragraph_data.breaks[i-1].position; j <= paragraph_data.breaks[i].position; j++) {
const node = paragraph_data.nodes[j];
// Create appropriate DOM element (e.g., <span> for words/tags)
// Set absolute position (left, top) based on calculated widths and line height
// Apply styles (opacity: 0 initially)
// Schedule fade-in animation using scheduleTimeout() (Section 5)
// Update the running 'delay' total for the next element
}
// Handle line breaks, adjusting vertical position
}
return [p, delay]; // Return the populated <p> element and the final delay value
}
```
* Each word, space, or preserved tag becomes a separate, absolutely positioned DOM element (typically a `<span>`).
* Positions are determined precisely based on the widths calculated during the line-breaking phase.
* Initial opacity is set to 0, and fade-in animations are scheduled sequentially.
## 5. Animation System
The engine features a sophisticated animation system for revealing text dynamically.
### 5.1. Animation Queue (`timeoutQueue`)
* A central array `timeoutQueue = []` tracks all pending `setTimeout` calls.
* Each entry is an object storing the function to execute, its arguments, and the `timeoutId`.
### 5.2. Scheduling Animations (`scheduleTimeout`)
```javascript
function scheduleTimeout(func, delay, ...args) {
// Creates timeoutObject with execute method and null timeoutId
// Uses setTimeout to schedule execution after 'delay'
// Inside setTimeout callback: executes func, removes object from timeoutQueue
// Pushes timeoutObject onto timeoutQueue
// Returns the timeoutId
}
```
* Provides a managed way to schedule functions, ensuring they are tracked.
* Enables batch operations like fast-forwarding or cancellation.
### 5.3. Timing and Delay (`window.delay`, `window.speed`)
* `window.delay`: Accumulates the total delay time as elements are scheduled. Each new element's animation starts after the previous one finishes.
* `window.speed`: Controls the duration of each word's fade-in, typically adjusted by a UI slider. The delay increment for a word is often calculated like `delay += window.speed * word.length;`.
* Speed Adjustment: The UI slider uses a non-linear function (`Math.pow(100.0 - value, 3) / 10000 * 10 + 0.01`) for finer control at slower speeds.
### 5.4. Fast Forwarding (`fastForward`, `fastForwardingAll`)
* The `fastForward()` function:
1. Iterates through `timeoutQueue`.
2. Calls `clearTimeout()` for each pending animation.
3. Immediately executes the scheduled function (`timeoutObject.execute()`).
4. Clears the `timeoutQueue`.
5. Resets `window.delay` to 0.
* Triggered by user actions (spacebar, click) or programmatically.
* `window.fastForwardingAll` flag indicates if fast-forward is continuously active (e.g., holding space).
* Visual Feedback: The page border changes color (e.g., red) when `fastForwardingAll` is true.
### 5.5. Animation Interruption (`stopAllTimeouts`)
* The `stopAllTimeouts()` function:
1. Iterates through `timeoutQueue`.
2. Calls `clearTimeout()` for each pending animation.
3. Clears the `timeoutQueue`.
* Used during navigation, loading saves, or story resets to prevent outdated animations from playing.
## 6. Additional Features
### 6.1. Text-to-Speech (TTS)
* Integrates with an external TTS service (e.g., ElevenLabs API).
* A UI button toggles speech playback.
* Attempts to synchronize audio playback with text animation reveal (implementation details not fully specified in the source document).
* Playback can be interrupted by user interactions or fast-forwarding.
### 6.2. State Management and Persistence
* **Ink State**: The core narrative state (current position, variable values, etc.) is managed by inkjs and can be serialized using `story.state.toJson()`.
* **Local Storage**: Used to persist the serialized Ink state and potentially other UI/game states (like rendered history) across browser sessions.
* **Save/Load**: Functionality allows users to explicitly save the current state and reload it later. This involves restoring the Ink state via `story.state.loadJson()` and potentially re-rendering the story history up to that point.
## 7. External Libraries & Credits
The engine relies on the following external libraries:
* **inkjs**
* **Author**: Yannick Lohse (y-lohse)
* **Source**: [https://github.com/y-lohse/inkjs](https://github.com/y-lohse/inkjs)
* **Description**: JavaScript port of Inkle's Ink narrative scripting language.
* **Hyphenopoly**
* **Author**: Hermann Monnich (mnater)
* **Source**: [https://github.com/mnater/Hyphenopoly](https://github.com/mnater/Hyphenopoly)
* **Website**: [https://mnater.github.io/Hyphenopoly/](https://mnater.github.io/Hyphenopoly/)
* **Description**: High-quality JavaScript hyphenation library.
* **SmartyPants**
* **Original Author**: John Gruber
* **JavaScript Port Author**: Example: Othree (othree)
* **Source (Example Port)**: [https://github.com/othree/smartypants.js](https://github.com/othree/smartypants.js)
* **Description**: Typography prettifier for quotes, dashes, etc.
* **typeset (Basis for Knuth & Plass / LinkedList)**
* **Author**: Bram Stein (bramstein)
* **Source**: [https://github.com/bramstein/typeset](https://github.com/bramstein/typeset)
* **Description**: Original JavaScript implementation of the Knuth-Plass line breaking algorithm, adapted for this engine.
## 8. Practical Refactoring Recommendations (Updated)
Based on the detailed analysis of the engine's functionality and the goal of optimizing for **modularity, separation of concerns, and reusability**, the following refined refactoring strategy is recommended. This approach breaks the system into highly focused, potentially reusable components, moving away from reliance on global state and tightly coupled logic.
### Core Component Modules:
1. **`AnimationQueue` (`animation-queue.js`)**
* **Responsibility**: Solely manage the timing and execution queue for all scheduled animations (primarily text reveal).
* **Core Functions**: `schedule(func, delay, ...args)`, `fastForward()`, `stop()`, `setSpeed(value)`.
* **State**: Manages the internal `queue` array, the current `delay` accumulator, and the animation `speed`.
* **Reusability**: Highly reusable for any system requiring sequenced, timed execution with speed control and interruption.
2. **`TextProcessor` (`text-processor.js`)**
* **Responsibility**: Encapsulate text pre-processing steps required before layout calculation.
* **Core Functions**: `process(text)` method that applies typographic enhancements (SmartyPants) and hyphenation (Hyphenopoly).
* **Dependencies**: Takes instances or functions of the SmartyPants and Hyphenopoly libraries during construction.
* **Reusability**: Reusable wherever this specific text processing pipeline (SmartyPants + Hyphenopoly) is needed.
3. **`ParagraphLayout` (`paragraph-layout.js`)**
* **Responsibility**: Interface with the Knuth-Plass line breaking algorithm (`kap` function) to calculate optimal line breaks.
* **Core Functions**: `calculateLayout(processedText, measures)` method.
* **Dependencies**: Takes the `kap` function and a `measureText` function (capable of measuring text widths in the target rendering context) during construction.
* **Reusability**: Reusable in any system needing high-quality paragraph line breaking, provided the `kap` algorithm and a text measurement function are supplied.
4. **`LayoutRenderer` (`layout-renderer.js`)**
* **Responsibility**: Translate the abstract layout data (from `ParagraphLayout`) into concrete visual elements (DOM nodes in this case). Handle visual tag rendering.
* **Core Functions**: `renderParagraph(layoutData, measures)` creates positioned DOM elements (e.g., `<span>` for words/tags), applies styles (initial opacity 0), and *uses* `AnimationQueue.schedule` to initiate fade-in animations. `renderVisualTag(tagType, tagData)` handles rendering for `IMAGE`, `BACKGROUND`, `CHAPTER`, `SEPARATOR`, and applying CSS classes.
* **Dependencies**: Takes an `AnimationQueue` instance and potentially configuration for visual elements.
* **Reusability**: Moderately reusable. The core logic of translating layout data is reusable, but the specific DOM creation part is tied to HTML/DOM rendering. Could be adapted for other rendering targets (e.g., Canvas).
5. **`AudioManager` (`audio-manager.js`)**
* **Responsibility**: Manage loading and playback of non-TTS audio effects triggered by tags (e.g., `AUDIO <url>`).
* **Core Functions**: `loadSound(id, url)`, `playSound(id)`, `stopSound(id)`, `stopAllSounds()`.
* **Reusability**: Highly reusable component for basic audio management in web applications.
6. **`TtsPlayer` (`tts-player.js`)**
* **Responsibility**: Handle interactions with the Text-to-Speech service (e.g., ElevenLabs API), manage playback, and potentially synchronize with the `AnimationQueue`.
* **Core Functions**: `speak(text)`, `stopSpeaking()`, `setVoice(voiceId)`, `configure(apiKey, ...)`.
* **Dependencies**: May need the `AnimationQueue` for synchronization if required.
* **Reusability**: Reusable for adding TTS functionality, specific implementation depends on the chosen TTS provider API.
7. **`PersistenceManager` (`persistence-manager.js`)**
* **Responsibility**: Handle saving and loading the game state.
* **Core Functions**: `saveState(stateObject)`, `loadState()`. `stateObject` would contain Ink state JSON, potentially UI state, scroll position, etc.
* **Dependencies**: Configurable storage backend (e.g., `localStorage` wrapper).
* **Reusability**: Highly reusable for saving/loading application state to various storage mechanisms.
8. **`InkStoryPlayer` (`ink-story-player.js`)**
* **Responsibility**: Orchestrate the narrative flow specific to the Ink story. Manage the `inkjs.Story` instance.
* **Core Functions**: `loadStory(storyContentJson)`, `continueStory(containerElement)`, `chooseChoice(index)`. It drives the `story.Continue()` loop, gets text and tags. It *delegates* tasks:
* Text processing -> `TextProcessor`
* Layout calculation -> `ParagraphLayout`
* Rendering paragraphs/visuals -> `LayoutRenderer`
* Handling `AUDIO` tags -> `AudioManager`
* Handling speech requests (if implemented via tags) -> `TtsPlayer`
* Saving/Loading -> `PersistenceManager`
* **Dependencies**: `inkjs.Story` class, `TextProcessor`, `ParagraphLayout`, `LayoutRenderer`, `AudioManager`, `TtsPlayer`, `PersistenceManager`.
* **Reusability**: Specific to using Ink narratives but uses reusable components for its operations.
9. **`UiController` (`ui-controller.js`)**
* **Responsibility**: Manage user interface interactions (event listeners) and update UI elements (sliders, buttons, status indicators).
* **Core Functions**: `setupEventListeners()`, methods to handle specific events (e.g., `handleSpeedChange`, `handleFastForwardToggle`, `handleChoiceClick`, `handleSaveClick`, `handleLoadClick`, `handleTtsToggle`).
* **Dependencies**: Interacts primarily with `InkStoryPlayer` (e.g., to choose choice, trigger save/load), `AnimationQueue` (to set speed, trigger fast-forward), `TtsPlayer` (to toggle speech), `AudioManager` (potentially to control master volume).
* **Reusability**: Specific to the application's UI structure but follows a standard controller pattern.
### Main Application Integration (`animated-fiction.js` or `main.js`):
```javascript
// main.js (Example Setup)
import { AnimationQueue } from './animation-queue.js';
import { TextProcessor } from './text-processor.js';
// ... import other modules ...
import { Story as InkStory } from './ink.js'; // Assuming inkjs is available
class AnimatedFictionApp {
constructor(config) {
this.config = config; // Story content URL, container element, API keys, etc.
this.storyContent = null; // Loaded later
// 1. Instantiate Core Components
this.animationQueue = new AnimationQueue();
// Provide external libraries/functions to components that need them
this.textProcessor = new TextProcessor(SmartyPants, hyphenator_en); // Assuming these are globally available or imported
this.paragraphLayout = new ParagraphLayout(kap, this.measureDomText.bind(this)); // `kap` algorithm, bind measure func
this.layoutRenderer = new LayoutRenderer(this.animationQueue);
this.audioManager = new AudioManager();
this.ttsPlayer = new TtsPlayer({ apiKey: config.ttsApiKey /*, optional animationQueue */ });
this.persistenceManager = new PersistenceManager({ storage: localStorage }); // Configure storage backend
// 2. Instantiate Orchestrator & UI
this.storyPlayer = new InkStoryPlayer({
InkStory: InkStory, // Pass the inkjs Story constructor
textProcessor: this.textProcessor,
paragraphLayout: this.paragraphLayout,
layoutRenderer: this.layoutRenderer,
audioManager: this.audioManager,
ttsPlayer: this.ttsPlayer,
persistenceManager: this.persistenceManager,
});
this.uiController = new UiController({
storyPlayer: this.storyPlayer,
animationQueue: this.animationQueue,
ttsPlayer: this.ttsPlayer,
// Pass references to DOM elements (buttons, sliders)
speedSliderElement: document.getElementById('speedSlider'),
choiceContainerElement: document.getElementById('choices'),
// ... other elements
});
}
// Method needed by ParagraphLayout, tied to rendering context
measureDomText(text, style = '') {
// Implementation to measure text width in the DOM
// (e.g., create temporary span, apply style, measure offsetWidth)
// ... return width ...
}
async load() {
// Fetch story content, etc.
const response = await fetch(this.config.storyUrl);
this.storyContent = await response.json();
}
start() {
if (!this.storyContent) throw new Error("Story not loaded");
// Setup initial states, load saved game if applicable
const savedState = this.persistenceManager.loadState();
const initialInkState = savedState ? savedState.inkJson : null;
this.storyPlayer.loadStory(this.storyContent, initialInkState);
this.uiController.setupEventListeners();
// Set initial speed from config or saved state
this.animationQueue.setSpeed(this.config.initialSpeed || 0.05);
// Begin the story
this.storyPlayer.continueStory(document.getElementById(this.config.storyContainerId));
}
}
// Initialize and start the application
window.onload = async () => {
const app = new AnimatedFictionApp({
storyUrl: 'story.json',
storyContainerId: 'story',
ttsApiKey: 'YOUR_API_KEY' // Example config
// ... other configurations
});
await app.load();
app.start();
};
```
### Benefits of this Refined Structure:
* **High Modularity**: Each class has a single, well-defined responsibility.
* **Improved Reusability**: Components like `AnimationQueue`, `TextProcessor`, `ParagraphLayout`, `AudioManager`, `TtsPlayer`, `PersistenceManager` have minimal dependencies on the specific interactive fiction context and can be reused elsewhere.
* **Clear Separation of Concerns**: Narrative logic (`InkStoryPlayer`) is separated from rendering (`LayoutRenderer`), timing (`AnimationQueue`), text manipulation (`TextProcessor`), UI (`UiController`), and side effects (Audio/TTS/Persistence).
* **Testability**: Individual components can be unit-tested more easily by mocking their dependencies.
* **Maintainability**: Changes within one module are less likely to impact others. Replacing a component (e.g., swapping the TTS provider) becomes more manageable.
This refactoring focuses squarely on creating independent, reusable building blocks, aligning perfectly with the goals specified.
View File
+48
View File
@@ -0,0 +1,48 @@
# Animated Fiction Engine - Modular Refactoring
This directory contains the refactored, modular version of the interactive fiction engine, previously implemented monolithically in `references/game.js`. The refactoring follows the recommendations outlined in Chapter 8 of `references/Documentation.md`.
## Overview
The engine is now broken down into several distinct, reusable components, each with a specific responsibility. This improves maintainability, testability, and allows for easier integration into different projects or replacement of individual components.
## Modules
The core modules are:
1. **`animation-queue.js` (`AnimationQueue`)**: Manages the timing and execution queue for animations, primarily text reveal. Handles scheduling, fast-forwarding, stopping, and speed control.
2. **`text-processor.js` (`TextProcessor`)**: Encapsulates text pre-processing steps (SmartyPants for typography, Hyphenopoly for hyphenation).
3. **`paragraph-layout.js` (`ParagraphLayout`)**: Interfaces with the Knuth-Plass line breaking algorithm (`kap` function) to calculate optimal paragraph layouts. Requires a text measurement function.
4. **`layout-renderer.js` (`LayoutRenderer`)**: Translates the calculated layout data into DOM elements, handles visual tag rendering (images, backgrounds, etc.), and schedules animations using the `AnimationQueue`.
5. **`audio-manager.js` (`AudioManager`)**: Manages loading and playback of non-TTS audio effects triggered by Ink tags (`AUDIO`, `AUDIOLOOP`).
6. **`tts-player.js` (`TtsPlayer`)**: Handles interactions with Text-to-Speech services (using `tts-factory.js` for selection) and manages TTS playback.
7. **`persistence-manager.js` (`PersistenceManager`)**: Handles saving and loading the game state (Ink state JSON and rendered history) using a configurable storage backend (defaulting to `localStorage`).
8. **`ink-story-player.js` (`InkStoryPlayer`)**: Orchestrates the Ink narrative flow. Manages the `inkjs.Story` instance, processes story content and tags, and delegates tasks to other modules (text processing, layout, rendering, audio, TTS, persistence).
9. **`ui-controller.js` (`UiController`)**: Manages user interface interactions (buttons, sliders, keyboard shortcuts) and updates UI elements. Interacts with other modules to trigger actions (e.g., change speed, save/load, choose choice).
10. **`animated-fiction.js` (`AnimatedFiction`)**: The main application class that integrates all the modules. It handles initialization, loading the story, and starting the application flow.
## Supporting Libraries
This engine relies on several external and internal libraries located in `public/js`:
* `ink.js` (Loaded via CDN in `modular-index.html`, wrapped in `window.inkjs`)
* `smartypants.js`
* `linked-list.js` (Used by `linebreak.js`)
* `linebreak.js` (Core Knuth-Plass algorithm)
* `knuth-and-plass.js` (Adapter for `linebreak.js`)
* `Hyphenopoly_Loader.js` & `Hyphenopoly.js` (Hyphenation library)
* `kokoro-js.js`, `kokoro-handler.js`, `tts-handler.js`, `tts-factory.js` (Text-to-Speech components)
## Usage
1. **Include Libraries**: Ensure all necessary supporting libraries (`smartypants.js`, `ink.js`, `Hyphenopoly`, etc.) are loaded in your HTML file *before* the main application module.
2. **Include Main Module**: Load the main application module (`animated-fiction.js`) using `<script type="module">`.
3. **HTML Structure**: Ensure your HTML has the necessary container elements (e.g., `#story`, `#choices`) and UI controls (buttons, sliders) with the expected IDs, or configure the `UiController` with the correct element references.
4. **Initialization**: The `animated-fiction.js` script will automatically instantiate the `AnimatedFiction` class on `window.onload`, load the story specified in its configuration (`Herrenhaus.ink.json` by default), and start the interactive experience.
Refer to `public/modular-index.html` for a working example of how to structure the HTML and include the necessary scripts.
## Original Files
* The original monolithic implementation can be found in `references/game.js`.
* The documentation detailing the original implementation and the refactoring plan is in `references/Documentation.md`.