Files
ai.interactive.fiction/public/js/ink-story-player.js
T

720 lines
26 KiB
JavaScript

/**
* 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;
}
}