Checkpoint current interactive fiction state

This commit is contained in:
2026-05-14 21:17:43 +02:00
parent c745efd1d2
commit 873049f7e6
183 changed files with 13755 additions and 1459 deletions
+566
View File
@@ -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);