Initial skeleton

This commit is contained in:
Matteo Settenvini 2024-08-31 16:44:23 +02:00
commit cb5edfe635
Signed by: matteo
GPG Key ID: 1C1B12600D81DE05
10 changed files with 4141 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
*~
*.orig
*.rej
/node_modules
/dist

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "work-it-out",
"version": "1.0.0",
"private": true,
"description": "",
"keywords": [],
"author": "",
"license": "CC-BY-SA-4.0",
"browser": "index.js",
"devDependencies": {
"@tsconfig/node20": "^20.1.4",
"css-loader": "^7.1.2",
"html-webpack-plugin": "^5.6.0",
"inkjs": "^2.3",
"sass": "^1.77.8",
"sass-loader": "^16.0.1",
"style-loader": "^4.0.0",
"ts-loader": "^9.5.1",
"typescript": "^5.5.4",
"webpack": "^5.94.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
},
"scripts": {
"preinstall": "npx only-allow pnpm",
"build": "webpack --mode production",
"dev": "webpack serve --mode development",
"test": "echo \"Error: no test specified\" && exit 1"
},
"engines": {
"node": ">= 20.0.0",
"pnpm": ">= 9.0"
}
}

3254
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

4
src/global.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '*.ink' {
const value: string;
export default value;
}

29
src/index.html Normal file
View File

@ -0,0 +1,29 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:;base64,iVBORw0KGgo="><!-- Empty favicon -->
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<div class="outerContainer">
<h3 class="written-in-ink"><a href="https://www.inklestudios.com/ink">WRITTEN IN INK</a></h3>
<div id="controls" class="buttons">
<a id="rewind" title="Restart story from beginning">restart</a>
<a id="save" title="Save progress">save</a>
<a id="reload" title="Reload from save point">load</a>
<a id="theme-switch" title="Switch theme">theme</a>
</div>
<div id="story" class="container">
<div class="header">
<h1>Work It Out</h1>
<h2 class="byline"></h2>
</div>
</div>
</div>
</body>
</html>

434
src/index.ts Normal file
View File

@ -0,0 +1,434 @@
// This file was adapted to Typescript from the original
// example from https://github.com/inkle/inky/blob/858f7e545f864d114ed2bc662e0948a53461695c/app/export-for-web-template/main.js
// Copyright (c) 2016 inkle Ltd.
// Under the MIT license.
import { Compiler } from 'inkjs/compiler/Compiler';
import "./style.scss";
import data from '../story/main.ink';
const story = new Compiler(data).Compile();
// Global tags - those at the top of the ink file
// We support:
// # theme: dark
// # author: Your Name
var globalTagTheme: string | null = null;
story.globalTags?.forEach((globalTag) => {
let splitTag = splitPropertyTag(globalTag);
switch (splitTag?.property) {
case "theme":
globalTagTheme = splitTag.val;
break;
case "author":
var byline = document.querySelector('.byline')!;
byline.innerHTML = "by " + splitTag.val;
break;
}
});
setupTheme(globalTagTheme);
var storyContainer = document.getElementById('story')!;
var outerScrollContainer = document.querySelector<HTMLElement>('.outerContainer')!;
// page features setup
var hasSave = loadSavePoint();
setupButtons(hasSave);
// Set initial save point
var 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(this: any, firstTime: boolean) {
var paragraphIndex = 0;
var delay = 0.0;
// Don't over-scroll past new content
var previousBottomEdge = firstTime ? 0 : contentBottomEdgeY();
// Generate story text - loop through available content
while (story.canContinue) {
// Get ink to generate the next paragraph
var paragraphText = story.Continue()!;
// Any special tags included with this line
var customClasses: string[] = [];
story.currentTags?.forEach((tag) => {
// 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);
switch (splitTag?.property.toUpperCase()) {
// AUDIO: src
case "AUDIO":
if ('audio' in this) {
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
}
this.audio = new Audio(splitTag.val);
this.audio.play();
break;
// AUDIOLOOP: src
case "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;
break;
// IMAGE: src
case "IMAGE":
var imageElement = document.createElement('img');
imageElement.src = splitTag.val;
storyContainer.appendChild(imageElement);
imageElement.onload = () => {
console.log(`scrollingto ${previousBottomEdge}`)
scrollDown(previousBottomEdge)
}
showAfter(delay, imageElement);
delay += 200.0;
break;
// LINK: url
case "LINK":
window.location.href = splitTag.val;
break;
// LINKOPEN: url
case "LINKOPEN":
window.open(splitTag.val);
break;
// BACKGROUND: src
case "BACKGROUND":
outerScrollContainer.style.backgroundImage = 'url(' + splitTag.val + ')';
break;
// CLASS: className
case "CLASS":
customClasses.push(splitTag.val);
break;
// CLEAR - removes all existing content.
case "CLEAR":
clearStory();
break;
// RESTART - clears everything and restarts the story from the beginning
case "RESTART":
restart();
return;
}
});
// Check if paragraphText is empty
if (paragraphText?.trim().length == 0) {
continue; // Skip empty paragraphs
}
// Create paragraph element (initially hidden)
var paragraphElement = document.createElement('p');
paragraphElement.innerHTML = paragraphText;
storyContainer.appendChild(paragraphElement);
// Add any custom classes derived from ink tags
for (var i = 0; i < customClasses.length; i++)
paragraphElement.classList.add(customClasses[i]);
// Fade in paragraph after a short delay
showAfter(delay, paragraphElement);
delay += 200.0;
}
// Create HTML choices from ink choices
story.currentChoices.forEach((choice) => {
// Create paragraph with anchor element
var customClasses: string[] = [];
var isClickable = true;
choice.tags?.forEach((choiceTag) => {
var splitTag = splitPropertyTag(choiceTag);
switch(splitTag?.property.toUpperCase()) {
case "UNCLICKABLE":
isClickable = false;
break;
case "CLASS":
customClasses.push(splitTag.val);
break;
}
});
var choiceParagraphElement = document.createElement('p');
choiceParagraphElement.classList.add("choice");
customClasses.forEach((klass) => {
choiceParagraphElement.classList.add(klass);
});
if (isClickable) {
choiceParagraphElement.innerHTML = `<a href='#'>${choice.text}</a>`
} else {
choiceParagraphElement.innerHTML = `<span class='unclickable'>${choice.text}</span>`
}
storyContainer.appendChild(choiceParagraphElement);
// Fade choice in after a short delay
showAfter(delay, choiceParagraphElement);
delay += 200.0;
// Click on choice
if (isClickable) {
var choiceAnchorEl = choiceParagraphElement.querySelectorAll("a")[0];
choiceAnchorEl.addEventListener("click", function (event) {
// Don't follow <a> link
event.preventDefault();
// 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";
// Remove all existing choices
removeAll(".choice");
// 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(false);
});
}
});
// Unset storyContainer's height, allowing it to resize itself
storyContainer.style.height = "";
if (!firstTime)
scrollDown(previousBottomEdge);
}
function clearStory() {
removeAll("p");
removeAll("img");
// Comment out this line if you want to leave the header visible when clearing
setVisible(".header", false);
}
function restart() {
clearStory();
story.ResetState();
setVisible(".header", true);
// set save point to here
savePoint = story.state.toJson();
continueStory(true);
outerScrollContainer.scrollTo(0, 0);
}
// -----------------------------------
// Various Helper functions
// -----------------------------------
// Detects whether the user accepts animations
function isAnimationEnabled() {
return window.matchMedia('(prefers-reduced-motion: no-preference)').matches;
}
// Fades in an element after a specified delay
function showAfter(delay: number, el: Element) {
if (isAnimationEnabled()) {
el.classList.add("hide");
setTimeout(function () { el.classList.remove("hide") }, delay);
} else {
// If the user doesn't want animations, show immediately
el.classList.remove("hide");
}
}
// 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: number) {
// If the user doesn't want animations, let them scroll manually
if (!isAnimationEnabled()) {
return;
}
// 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: EpochTimeStamp | null = null;
function step(time: EpochTimeStamp) {
if (startTime == null) startTime = time;
var t = (time - startTime) / duration;
var lerp = 3 * t * t - 2 * t * t * t; // ease in/out
outerScrollContainer.scrollTo(0, (1.0 - lerp) * start + lerp * target);
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 as HTMLElement;
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: string) {
storyContainer.querySelectorAll<HTMLElement>(selector).forEach((el) => {
el.parentNode!.removeChild(el);
});
}
// Used for hiding and showing the header when you CLEAR or RESTART the story respectively.
function setVisible(selector: string, visible: boolean) {
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: string) {
var propertySplitIdx = tag.indexOf(":");
if (propertySplitIdx != null) {
var property = tag.slice(0, propertySplitIdx).trim();
var val = tag.slice(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;
}
// Detects which theme (light or dark) to use
function setupTheme(globalTagTheme: string | null) {
// load theme from browser memory
var savedTheme;
try {
savedTheme = window.localStorage.getItem('theme');
} catch (e) {
console.debug("Couldn't load saved theme");
}
// Check whether the OS/browser is configured for dark mode
var browserDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
let preferredTheme = savedTheme ?? globalTagTheme ?? (browserDark ? "dark" : "light");
document.body.classList.add(preferredTheme);
}
// Used to hook up the functionality for global functionality buttons
function setupButtons(hasSave: boolean) {
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");
window.localStorage.setItem('theme', document.body.classList.contains("dark") ? "dark" : "");
} 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");
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);
});
let themeSwitchEl = document.getElementById("theme-switch");
if (themeSwitchEl) themeSwitchEl.addEventListener("click", function (event) {
document.body.classList.add("switched");
document.body.classList.toggle("dark");
});
}

306
src/style.scss Normal file
View File

@ -0,0 +1,306 @@
@import url('https://fonts.googleapis.com/css?family=Open+Sans:300,700|Quattrocento:700');
body {
font-family: 'Open Sans', sans-serif;
font-weight: lighter;
background: white;
overflow: hidden;
}
@media screen and (prefers-reduced-motion: no-preference) {
body.switched {
transition: color 0.6s, background-color 0.6s;
}
}
h1,
h2 {
text-align: center;
font-family: "Quattrocento", Georgia, 'Times New Roman', Times, serif;
margin: 0;
padding: 0;
}
h1 {
font-size: 30pt;
margin-bottom: 10px;
}
h2 {
font-size: 14pt;
font-style: italic;
font-family: sans-serif;
font-weight: lighter;
color: #BBB;
}
.header {
padding-top: 3em;
padding-bottom: 3em;
}
/*
Built-in class:
# author: Name
*/
.byline {
font-style: italic;
}
.written-in-ink {
z-index: 3;
font-size: 9pt;
font-family: sans-serif;
text-align: center;
font-weight: 700;
position: fixed;
display: block;
width: 100%;
background: white;
margin: 0;
padding-top: 6px;
padding-bottom: 6px;
height: 14px;
top: 0;
}
@media screen and (prefers-reduced-motion: no-preference) {
.written-in-ink {
transition: color 0.6s, background 0.6s;
}
}
/*
Enables <iframe> support work on itch.io when using mobile iOS
*/
.outerContainer {
position: absolute;
display: block;
margin: 0;
padding: 0;
-webkit-overflow-scrolling: touch;
overflow: scroll;
overflow-x: hidden;
height: 100%;
width: 100%;
top: 0;
left: 0;
margin-top: 24px;
background-size: cover;
background-repeat: no-repeat;
}
@media screen and (max-width: 980px) {
.outerContainer {
margin-top: 44px;
background-size: cover;
background-repeat: no-repeat;
}
}
.container {
display: block;
max-width: 600px;
margin: 0 auto;
padding: 20px;
padding-top: 4em;
background: white;
}
@media screen and (prefers-reduced-motion: no-preference) {
.switched .container {
transition: background-color 0.6s;
}
}
p {
font-size: 13pt;
color: #888;
line-height: 1.7em;
font-weight: lighter;
}
a {
font-weight: 700;
color: #b97c2c;
font-family: sans-serif;
text-decoration: none;
}
.unclickable {
font-weight: 700;
color: #4f3411;
font-family: sans-serif;
text-decoration: none;
cursor: not-allowed;
}
a:hover {
color: black;
}
@media screen and (prefers-reduced-motion: no-preference) {
a {
transition: color 0.6s;
}
a:hover {
transition: color 0.1s;
}
}
strong {
color: black;
font-weight: bold;
}
img {
display: block;
margin: 0 auto;
max-width: 100%;
}
.container .hide {
opacity: 0.0;
}
.container .invisible {
display: none;
}
.container>* {
opacity: 1.0;
}
@media screen and (prefers-reduced-motion: no-preference) {
.container>* {
transition: opacity 1.0s;
}
}
/*
Class applied to all choices
(Will always appear inside <p> element by default.)
*/
.choice {
text-align: center;
line-height: 1.7em;
}
/*
Class applied to first choice
*/
:not(.choice)+.choice {
padding-top: 1em;
}
/*
Class applied to choice links
*/
.choice a, .choice span {
font-size: 15pt;
}
/*
Built-in class:
The End # CLASS: end
*/
.end {
text-align: center;
font-weight: bold;
color: black;
padding-top: 20px;
padding-bottom: 20px;
}
#controls {
z-index: 4;
font-size: 9pt;
text-align: center;
padding-bottom: 6px;
position: fixed;
right: 14px;
top: 4px;
user-select: none;
background: white;
}
@media screen and (prefers-reduced-motion: no-preference) {
#controls {
transition: color 0.6s, background 0.6s;
}
}
#controls [disabled] {
color: #ccc;
}
#controls>*:not(:last-child):after {
content: " | ";
}
@media screen and (max-width: 980px) {
#controls {
z-index: 2;
padding-top: 24px;
top: 0;
left: 0;
right: 0;
}
}
/*
Dark Theme (Added in Inky 0.10.0)
# theme: dark
*/
body.dark {
background: black;
color: white;
}
.dark h2 {
color: #666;
}
.dark .container {
background: black;
}
.dark .written-in-ink {
background: black;
}
.dark a {
color: #cc8f1a;
}
.dark .unclickable{
color: #c4af87;
cursor:not-allowed;
}
.dark a:hover {
color: white;
}
@media screen and (prefers-reduced-motion: no-preference) {
.dark a {
transition: color 0.6s;
}
}
.dark strong {
color: white;
}
.dark #controls [disabled] {
color: #444;
}
.dark .end {
color: white;
}
.dark #controls {
background: black;
}

18
story/main.ink Normal file
View File

@ -0,0 +1,18 @@
# author: Matteo Settenvini
-> intro
=== intro ===
We were thinking that. And again, and again.
+ Maybe yes -> ending
+ Maybe not -> intro
-> ending
=== ending ===
Goodbye then.
-> END

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"compilerOptions": {
"lib": ["es2023", "DOM"]
},
"include": ["src/**/*"]
}

49
webpack.config.js Normal file
View File

@ -0,0 +1,49 @@
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = {
entry: './src/index.ts', // Entry file
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.ink$/,
type: 'asset/source',
exclude: /node_modules/,
},
{
test: /\.s[ac]ss$/,
use: [
"style-loader", // Creates `style` nodes from JS strings
"css-loader", // Translates CSS into CommonJS
"sass-loader", // Compiles Sass to CSS
],
},
],
},
resolve: {
extensions: ['.ts', '.js'],
},
plugins: [
new HtmlWebpackPlugin({
title: "Work It Out",
template: "src/index.html",
})
],
devServer: {
static: {
directory: path.join(__dirname, 'dist'),
},
liveReload: true,
compress: true,
port: 9000,
},
};