Checkpoint current interactive fiction state
This commit is contained in:
Vendored
+566
@@ -0,0 +1,566 @@
|
||||
(function(storyContent) {
|
||||
// Create ink story from the content using inkjs
|
||||
var story = new inkjs.Story(storyContent);
|
||||
|
||||
var savePoint = "";
|
||||
|
||||
let fade_in = true;
|
||||
|
||||
// Global tags - those at the top of the ink file
|
||||
// We support:
|
||||
// # theme: dark
|
||||
// # author: Your Name
|
||||
var globalTags = story.globalTags;
|
||||
if( globalTags ) {
|
||||
for(var i=0; i<story.globalTags.length; i++) {
|
||||
var globalTag = story.globalTags[i];
|
||||
var splitTag = splitPropertyTag(globalTag);
|
||||
|
||||
// THEME: dark
|
||||
if( splitTag && splitTag.property == "title" ) {
|
||||
var title = document.querySelector('.title');
|
||||
title.innerHTML = splitTag.val;
|
||||
}
|
||||
|
||||
// author: Your Name
|
||||
else if( splitTag && splitTag.property == "author" ) {
|
||||
var byline = document.querySelector('.byline');
|
||||
byline.innerHTML = "by "+splitTag.val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var storyContainer = document.querySelector('#story');
|
||||
var choiceContainer = document.querySelector('#choices');
|
||||
var outerScrollContainer = document.querySelector('#book');
|
||||
|
||||
function updateParagraphPreview(paragraph_data, indent_width, preview_width) {
|
||||
var old_preview = document.getElementById("preview");
|
||||
var preview = document.createElement("div");
|
||||
preview.id = "preview";
|
||||
preview.style.width = preview_width + 'px';
|
||||
if(old_preview) {
|
||||
old_preview.replaceWith(preview);
|
||||
// preview = old_preview;
|
||||
} else {
|
||||
document.body.appendChild(preview);
|
||||
}
|
||||
// p = typesetParagraph(paragraph_data, indent_width);
|
||||
// preview.appendChild(p);
|
||||
}
|
||||
|
||||
let timeoutQueue = [];
|
||||
|
||||
function scheduleTimeout(func, delay, ...args) {
|
||||
const timeoutObject = {
|
||||
execute: () => func(...args),
|
||||
timeoutId: null
|
||||
};
|
||||
|
||||
timeoutObject.timeoutId = setTimeout(() => {
|
||||
timeoutObject.execute();
|
||||
timeoutQueue = timeoutQueue.filter(t => t !== timeoutObject);
|
||||
}, delay);
|
||||
|
||||
timeoutQueue.push(timeoutObject);
|
||||
|
||||
return timeoutObject.timeoutId;
|
||||
}
|
||||
|
||||
function fastForward() {
|
||||
// Sort the queue based on timeoutId (assuming that smaller ids are scheduled earlier)
|
||||
timeoutQueue.sort((a, b) => a.timeoutId - b.timeoutId);
|
||||
// Clear and execute all timeouts
|
||||
timeoutQueue.forEach(timeoutObject => {
|
||||
clearTimeout(timeoutObject.timeoutId);
|
||||
timeoutObject.execute();
|
||||
});
|
||||
|
||||
timeoutQueue = [];
|
||||
document.getElementById("page_right").scrollTo({top: document.getElementById("page_right").scrollHeight, behavior: 'smooth'});
|
||||
}
|
||||
|
||||
// var numberOfPreviewLines = 0;
|
||||
|
||||
function typesetParagraph(paragraph_data, indent_width, delay = 0) {
|
||||
console.log("Typesetting Paragraph with: ", paragraph_data, indent_width);
|
||||
var left = indent_width;
|
||||
var p = document.createElement("p");
|
||||
p.style.position = 'relative';
|
||||
var line_height = parseFloat(window.getComputedStyle(document.querySelector("#ruler")).lineHeight);
|
||||
// numberOfPreviewLines += paragraph_data.breaks.length - 1;
|
||||
// console.log("Calculated line height:", line_height);
|
||||
p.style.height = line_height * (paragraph_data.breaks.length - 1) + 'px';
|
||||
p.style.marginBlockEnd = 0;
|
||||
for(let i = 1; i < paragraph_data.breaks.length; i++) {
|
||||
if(i > 1)
|
||||
left = 0;
|
||||
for(let j = paragraph_data.breaks[i-1].position; j <= paragraph_data.breaks[i].position; j++) {
|
||||
// console.log("i =",i,"j =",j,"from =",paragraph_data.breaks[i-1].position,"to =",paragraph_data.breaks[i].position,"node_width =", paragraph_data.nodes[j].width, "left =", left, "type =", paragraph_data.nodes[j].type, "value =", paragraph_data.nodes[j].value);
|
||||
if(paragraph_data.nodes[j].type === 'box' && paragraph_data.nodes[j].value !== '' && j < paragraph_data.breaks[i].position) {
|
||||
if(j > paragraph_data.breaks[i-1].position + 1 && paragraph_data.nodes[j-1].type === 'penalty' && p.lastChild) {
|
||||
p.lastChild.textContent += paragraph_data.nodes[j].value;
|
||||
left += paragraph_data.nodes[j].width;
|
||||
} else {
|
||||
let word = document.createElement("span");
|
||||
word.style.position = 'absolute';
|
||||
word.classList.add("fade-in");
|
||||
word.style.top = line_height * (i - 1) + 'px';
|
||||
word.style.left = left + 'px';
|
||||
word.innerHTML = paragraph_data.nodes[j].value;
|
||||
insertAfter(delay, p, word);
|
||||
delay += 100.0;
|
||||
// p.appendChild(word);
|
||||
if(j > 0)
|
||||
left += paragraph_data.nodes[j].width;
|
||||
else
|
||||
left += paragraph_data.nodes[j].width - indent_width;
|
||||
}
|
||||
} else if(j > paragraph_data.breaks[i-1].position && paragraph_data.nodes[j].type === 'glue' && paragraph_data.nodes[j].width !== 0 && j <= paragraph_data.breaks[i].position) {
|
||||
// Insert space character
|
||||
if(paragraph_data.breaks[i].ratio > 0) {
|
||||
left += paragraph_data.nodes[j].width + paragraph_data.breaks[i].ratio * paragraph_data.nodes[j].stretch;
|
||||
} else {
|
||||
left += paragraph_data.nodes[j].width + paragraph_data.breaks[i].ratio * paragraph_data.nodes[j].shrink;
|
||||
}
|
||||
} else if(paragraph_data.nodes[j].type === 'penalty' && paragraph_data.nodes[j].penalty === 100 && j === paragraph_data.breaks[i].position) {
|
||||
let word = document.createElement("span");
|
||||
word.style.position = 'absolute';
|
||||
word.style.top = line_height * (i - 1) + 'px';
|
||||
word.style.left = left + 'px';
|
||||
word.innerHTML = "-";
|
||||
insertAfter(delay, p, word);
|
||||
delay += 100;
|
||||
// p.appendChild(word);
|
||||
// left += paragraph_data.nodes[j].width;
|
||||
}
|
||||
}
|
||||
};
|
||||
return [p, delay];
|
||||
}
|
||||
|
||||
function measureText(str) {
|
||||
if (str === ' ') {
|
||||
str = '\u00A0';
|
||||
}
|
||||
ruler.textContent = str;
|
||||
return ruler.getClientRects()[0].width;
|
||||
}
|
||||
|
||||
function 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);
|
||||
let story = document.getElementById("story");
|
||||
let paddingTop = window.getComputedStyle(story).paddingTop;
|
||||
let paddingBottom = window.getComputedStyle(story).paddingBottom;
|
||||
document.documentElement.style.setProperty('--story-line-height', (story.clientHeight - paddingTop - paddingBottom) / 28)
|
||||
}
|
||||
|
||||
// Update the aspect ratio when the page loads
|
||||
updateBookDimensions();
|
||||
|
||||
// Update the aspect ratio whenever the window is resized
|
||||
window.addEventListener('resize', updateBookDimensions);
|
||||
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.code === 'Space') {
|
||||
fade_in = false;
|
||||
fastForward();
|
||||
}
|
||||
});
|
||||
|
||||
// page features setup
|
||||
var hasSave = loadSavePoint();
|
||||
setupButtons(hasSave);
|
||||
|
||||
// Set initial save point
|
||||
savePoint = story.state.toJson();
|
||||
|
||||
// Kick off the start of the story!
|
||||
continueStory(true);
|
||||
|
||||
// Main story processing function. Each time this is called it generates
|
||||
// all the next content up as far as the next set of choices.
|
||||
function continueStory(firstTime) {
|
||||
|
||||
var paragraphIndex = 0;
|
||||
var delay = 0.0;
|
||||
|
||||
// Don't over-scroll past new content
|
||||
var previousBottomEdge = firstTime ? 0 : contentBottomEdgeY();
|
||||
|
||||
var fade_in = true
|
||||
|
||||
// Generate story text - loop through available content
|
||||
while(story.canContinue) {
|
||||
// Get ink to generate the next paragraph
|
||||
var paragraphText = story.Continue();
|
||||
var tags = story.currentTags;
|
||||
|
||||
// Any special tags included with this line
|
||||
var customClasses = [];
|
||||
for(var i=0; i<tags.length; i++) {
|
||||
var tag = tags[i];
|
||||
|
||||
// Detect tags of the form "X: Y". Currently used for IMAGE and CLASS but could be
|
||||
// customised to be used for other things too.
|
||||
var splitTag = splitPropertyTag(tag);
|
||||
|
||||
// AUDIO: src
|
||||
if( splitTag && splitTag.property == "AUDIO" ) {
|
||||
if('audio' in this) {
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
}
|
||||
this.audio = new Audio(splitTag.val);
|
||||
this.audio.play();
|
||||
}
|
||||
|
||||
// AUDIOLOOP: src
|
||||
else if( splitTag && splitTag.property == "AUDIOLOOP" ) {
|
||||
if('audioLoop' in this) {
|
||||
this.audioLoop.pause();
|
||||
this.audioLoop.removeAttribute('src');
|
||||
this.audioLoop.load();
|
||||
}
|
||||
this.audioLoop = new Audio(splitTag.val);
|
||||
this.audioLoop.play();
|
||||
this.audioLoop.loop = true;
|
||||
}
|
||||
|
||||
// IMAGE: src
|
||||
if( splitTag && splitTag.property == "IMAGE" ) {
|
||||
var imageElement = document.createElement('img');
|
||||
imageElement.src = splitTag.val;
|
||||
storyContainer.appendChild(imageElement);
|
||||
|
||||
showAfter(delay, imageElement);
|
||||
delay += 200.0;
|
||||
}
|
||||
|
||||
// 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" ) {
|
||||
outerScrollContainer.style.backgroundImage = 'url('+splitTag.val+')';
|
||||
}
|
||||
|
||||
// CLASS: className
|
||||
else if( splitTag && splitTag.property == "CLASS" ) {
|
||||
customClasses.push(splitTag.val);
|
||||
}
|
||||
|
||||
// CLEAR - removes all existing content.
|
||||
// RESTART - clears everything and restarts the story from the beginning
|
||||
else if( tag == "CLEAR" || tag == "RESTART" ) {
|
||||
removeAll("p");
|
||||
removeAll("img");
|
||||
|
||||
// Comment out this line if you want to leave the header visible when clearing
|
||||
// setVisible(".header", false);
|
||||
|
||||
if( tag == "RESTART" ) {
|
||||
restart();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create paragraph element (initially hidden)
|
||||
(function(text) {
|
||||
if(text.trim().length === 0)
|
||||
return;
|
||||
console.log("Hyphenating:", text);
|
||||
let hyphenator_promise = Hyphenopoly.hyphenators["en-us"].then((hyphenator_en) => {
|
||||
var measure = parseFloat(window.getComputedStyle(document.getElementById("story")).width);
|
||||
var indentWidth = parseFloat(window.getComputedStyle(document.querySelector("#indent")).textIndent);
|
||||
var previewWidth = measure;
|
||||
var preview_data = kap(hyphenator_en(text, '.hyphenatePipe'), measureText, 'align-justify', measure, true, indentWidth);
|
||||
return { preview_data, indentWidth, previewWidth};
|
||||
});
|
||||
hyphenator_promise.then(({ preview_data, indentWidth, previewWidth }) => {
|
||||
// updateParagraphPreview(preview_data, indentWidth, previewWidth);
|
||||
var p, d;
|
||||
[p, d] = typesetParagraph(preview_data, indentWidth, delay);
|
||||
delay = d;
|
||||
// Add any custom classes derived from ink tags
|
||||
for(var i=0; i<customClasses.length; i++)
|
||||
p.classList.add(customClasses[i]);
|
||||
storyContainer.appendChild(p);
|
||||
p.scrollIntoView({ behavior: 'smooth'});
|
||||
});
|
||||
})(paragraphText);
|
||||
|
||||
// var paragraphElement = document.createElement('p');
|
||||
// var words = paragraphText.split(" ");
|
||||
// words.forEach(word => {
|
||||
// var wordElement = document.createElement('span');
|
||||
// Hyphenopoly.hyphenators["en-us"].then((hyphenator_en) => {
|
||||
// wordElement.innerHTML = hyphenator_en(word);
|
||||
// });
|
||||
// // showAfter(delay, wordElement);
|
||||
// insertAfter(delay, paragraphElement, wordElement, fade_in);
|
||||
// insertAfter(delay, paragraphElement, document.createTextNode(" "), false);
|
||||
// delay +=100.0;
|
||||
// // paragraphElement.appendChild(wordElement);
|
||||
// // paragraphElement.appendChild(document.createTextNode(" "));
|
||||
// });
|
||||
// // paragraphElement.innerHTML = paragraphText;
|
||||
// storyContainer.appendChild(paragraphElement);
|
||||
|
||||
|
||||
// Fade in paragraph after a short delay
|
||||
// showAfter(delay, paragraphElement);
|
||||
// delay += 200.0;
|
||||
}
|
||||
|
||||
// Create HTML choices from ink choices
|
||||
story.currentChoices.forEach(function(choice) {
|
||||
|
||||
// Create paragraph with anchor element
|
||||
var choiceParagraphElement = document.createElement('p');
|
||||
choiceParagraphElement.classList.add("choice");
|
||||
choiceParagraphElement.innerHTML = `<a href='#'>${choice.text}</a>`
|
||||
// choiceContainer.appendChild(choiceParagraphElement);
|
||||
insertAfter(delay, choiceContainer, choiceParagraphElement, fade_in);
|
||||
// Fade choice in after a short delay
|
||||
// showAfter(delay, choiceParagraphElement);
|
||||
delay += 200.0;
|
||||
|
||||
// Click on choice
|
||||
var choiceAnchorEl = choiceParagraphElement.querySelectorAll("a")[0];
|
||||
choiceAnchorEl.addEventListener("click", function(event) {
|
||||
|
||||
// Don't follow <a> link
|
||||
event.preventDefault();
|
||||
|
||||
// Remove all existing choices
|
||||
removeAll(".choice", true);
|
||||
|
||||
// Tell the story where to go next
|
||||
story.ChooseChoiceIndex(choice.index);
|
||||
|
||||
// This is where the save button will save from
|
||||
savePoint = story.state.toJson();
|
||||
|
||||
// Aaand loop
|
||||
continueStory();
|
||||
});
|
||||
});
|
||||
|
||||
// Extend height to fit
|
||||
// We do this manually so that removing elements and creating new ones doesn't
|
||||
// cause the height (and therefore scroll) to jump backwards temporarily.
|
||||
// storyContainer.style.height = contentBottomEdgeY()+"px";
|
||||
|
||||
if( !firstTime )
|
||||
scrollDown(previousBottomEdge);
|
||||
|
||||
}
|
||||
|
||||
function restart() {
|
||||
story.ResetState();
|
||||
|
||||
setVisible(".header", true);
|
||||
removeAll(".choice", true);
|
||||
|
||||
// set save point to here
|
||||
savePoint = story.state.toJson();
|
||||
|
||||
continueStory(true);
|
||||
|
||||
outerScrollContainer.scrollTo({ top: 0, left: 0, behavior: 'smooth'});
|
||||
}
|
||||
|
||||
// -----------------------------------
|
||||
// Various Helper functions
|
||||
// -----------------------------------
|
||||
|
||||
// Fades in an element after a specified delay
|
||||
function showAfter(delay, el) {
|
||||
el.classList.add("hide");
|
||||
setTimeout(function() {
|
||||
setTimeout(function() { el.classList.remove("hide") }, delay);
|
||||
});
|
||||
}
|
||||
|
||||
function insertAfter(delay, target, el, fade_in = true) {
|
||||
if(fade_in) {
|
||||
el.classList.add("fade-in");
|
||||
scheduleTimeout(function() {
|
||||
target.appendChild(el);
|
||||
el.scrollIntoView({ behavior: 'smooth'});
|
||||
}, delay);
|
||||
} else {
|
||||
scheduleTimeout(function() {
|
||||
target.appendChild(el);
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
// Scrolls the page down, but no further than the bottom edge of what you could
|
||||
// see previously, so it doesn't go too far.
|
||||
function scrollDown(previousBottomEdge) {
|
||||
return; // TODO: Fix or remove function
|
||||
// Line up top of screen with the bottom of where the previous content ended
|
||||
var target = previousBottomEdge;
|
||||
|
||||
// Can't go further than the very bottom of the page
|
||||
var limit = outerScrollContainer.scrollHeight - outerScrollContainer.clientHeight;
|
||||
if( target > limit ) target = limit;
|
||||
|
||||
var start = outerScrollContainer.scrollTop;
|
||||
|
||||
var dist = target - start;
|
||||
var duration = 300 + 300*dist/100;
|
||||
var startTime = null;
|
||||
function step(time) {
|
||||
if( startTime == null ) startTime = time;
|
||||
var t = (time-startTime) / duration;
|
||||
var lerp = 3*t*t - 2*t*t*t; // ease in/out
|
||||
outerScrollContainer.scrollTo({ left: 0, top: (1.0-lerp)*start + lerp*target, behavior: 'smooth'});
|
||||
if( t < 1 ) requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
// The Y coordinate of the bottom end of all the story content, used
|
||||
// for growing the container, and deciding how far to scroll.
|
||||
function contentBottomEdgeY() {
|
||||
var bottomElement = storyContainer.lastElementChild;
|
||||
return bottomElement ? bottomElement.offsetTop + bottomElement.offsetHeight : 0;
|
||||
}
|
||||
|
||||
// Remove all elements that match the given selector. Used for removing choices after
|
||||
// you've picked one, as well as for the CLEAR and RESTART tags.
|
||||
function removeAll(selector, choices = false)
|
||||
{
|
||||
if(choices)
|
||||
var allElements = choiceContainer.querySelectorAll(selector);
|
||||
else
|
||||
var allElements = storyContainer.querySelectorAll(selector);
|
||||
for(var i=0; i<allElements.length; i++) {
|
||||
var el = allElements[i];
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
// Used for hiding and showing the header when you CLEAR or RESTART the story respectively.
|
||||
function setVisible(selector, visible)
|
||||
{
|
||||
var allElements = storyContainer.querySelectorAll(selector);
|
||||
for(var i=0; i<allElements.length; i++) {
|
||||
var el = allElements[i];
|
||||
if( !visible )
|
||||
el.classList.add("invisible");
|
||||
else
|
||||
el.classList.remove("invisible");
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for parsing out tags of the form:
|
||||
// # PROPERTY: value
|
||||
// e.g. IMAGE: source path
|
||||
function splitPropertyTag(tag) {
|
||||
var propertySplitIdx = tag.indexOf(":");
|
||||
if( propertySplitIdx != null ) {
|
||||
var property = tag.substr(0, propertySplitIdx).trim();
|
||||
var val = tag.substr(propertySplitIdx+1).trim();
|
||||
return {
|
||||
property: property,
|
||||
val: val
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Loads save state if exists in the browser memory
|
||||
function loadSavePoint() {
|
||||
try {
|
||||
let savedState = window.localStorage.getItem('save-state');
|
||||
if (savedState) {
|
||||
story.state.LoadJson(savedState);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug("Couldn't load save state");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Used to hook up the functionality for global functionality buttons
|
||||
function setupButtons(hasSave) {
|
||||
|
||||
let rewindEl = document.getElementById("rewind");
|
||||
if (rewindEl) rewindEl.addEventListener("click", function(event) {
|
||||
removeAll("p");
|
||||
removeAll("img");
|
||||
setVisible(".header", false);
|
||||
restart();
|
||||
});
|
||||
|
||||
let saveEl = document.getElementById("save");
|
||||
if (saveEl) saveEl.addEventListener("click", function(event) {
|
||||
try {
|
||||
window.localStorage.setItem('save-state', savePoint);
|
||||
document.getElementById("reload").removeAttribute("disabled");
|
||||
} catch (e) {
|
||||
console.warn("Couldn't save state");
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
let reloadEl = document.getElementById("reload");
|
||||
if (!hasSave) {
|
||||
reloadEl.setAttribute("disabled", "disabled");
|
||||
}
|
||||
reloadEl.addEventListener("click", function(event) {
|
||||
if (reloadEl.getAttribute("disabled"))
|
||||
return;
|
||||
|
||||
removeAll("p");
|
||||
removeAll("img");
|
||||
removeAll(".choice", true);
|
||||
try {
|
||||
let savedState = window.localStorage.getItem('save-state');
|
||||
if (savedState) story.state.LoadJson(savedState);
|
||||
} catch (e) {
|
||||
console.debug("Couldn't load save state");
|
||||
}
|
||||
continueStory(true);
|
||||
});
|
||||
}
|
||||
|
||||
})(storyContent);
|
||||
Reference in New Issue
Block a user