/** * 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} tags - The tags for the paragraph * @returns {Array} 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} 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 = '' + firstWord + ' ' + 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 = `${choiceText}`; 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 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; } }