Extract Sai Ink embedder

This commit is contained in:
Meta Vens 2026-05-27 14:07:23 +01:00
commit 20d01a8ff7
23 changed files with 13537 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules
*.log
.DS_Store
dist/*.map

3
.prettierignore Normal file
View file

@ -0,0 +1,3 @@
examples/**/*.ink
dist/ink.js
src/ink.js

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 GameCult
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

169
README.md Normal file
View file

@ -0,0 +1,169 @@
# Sai
**Ink stories that wake up inside static sites.**
Sai is a small browser embedder for compiled Ink stories. It is not an Ink
engine; it uses `inkjs` for that. Sai owns the article-shaped part: loading a
story into an existing page, reading a visual manifest, rendering transcript,
visual-novel, or cinematic layouts, and staying pleasant to drop into Quartz,
Eleventy, Astro, Hugo, plain HTML, or whatever shrine of markup you dragged
home at midnight.
The name is the pitch: ink that becomes a living scene.
## Why Sai Exists
Most Ink web players answer: "Can I play this story in a browser?"
Sai answers: "Can this story become part of a page?"
It is for interactive essays, lore posts, documentation rituals, cheap visual
novels, pitch decks, character sermons, branching articles, and other story
objects that want to live inside a site instead of becoming a separate web app.
## Quick Start
Copy the built files from `dist/` into your site's static assets:
```text
sai.js
sai.css
ink.js
```
Add the assets to a page:
```html
<link rel="stylesheet" href="/static/sai/sai.css" />
<script defer src="/static/sai/sai.js"></script>
```
Embed a story:
```html
<div
class="sai-player"
data-ink-format="visual-novel"
data-ink-story="/stories/agora/story.ink.json"
data-visual-manifest="/stories/agora/visual-manifest.json"
data-scene-label="The Aquarium Agora"
></div>
```
Sai auto-initialises every `.sai-player` when the document loads. It also
listens for Quartz-style `nav` events after client-side page swaps.
## Formats
### Raven
```html
<div class="sai-player" data-ink-story="/story.ink.json"></div>
```
The default format is a compact interactive-fiction transcript. It is the
simplest Raven Collective-style shape: text, choices, variables when useful,
and no stage machinery.
### Visual Novel
```html
<div
class="sai-player"
data-ink-format="visual-novel"
data-ink-story="/story.ink.json"
data-visual-manifest="/visual-manifest.json"
></div>
```
Visual-novel mode renders one scene image, a fixed dialogue bar, speaker avatar,
speaker name, line text, and a right-side option rail. Ink tags drive the
speaker and scene:
```ink
# speaker: Void
# scene: agora
# avatar: void
The world is full of systems that say one thing and reward another.
```
### Cinematic
```html
<div
class="sai-player"
data-ink-format="cinematic"
data-ink-story="/deck.ink.json"
data-visual-manifest="/deck.visual-manifest.json"
></div>
```
Cinematic mode is for slide/deck-like fragments: background plates, captions,
references, and short blocks of text.
## Data Attributes
- `data-ink-story`: required URL for compiled Ink JSON.
- `data-ink-format`: `raven`, `visual-novel`, or `cinematic`.
- `data-visual-manifest`: optional visual manifest URL.
- `data-scene-label`: optional fallback scene label for visual-novel mode.
- `data-ink-title`: optional player title/status text.
- `data-ink-runtime`: optional URL for `ink.js`.
- `data-sai-runtime`: alias for `data-ink-runtime`.
- `data-show-variables="true"`: show Ink variables in visual modes.
Legacy `.aetheria-ink-player` containers are still initialised so existing
GameCult pages can migrate without ritual screaming. New work should use
`.sai-player`.
## JavaScript API
```js
window.Sai.init(document.querySelector(".sai-player"));
window.Sai.initAll();
console.log(window.Sai.version);
```
By default, `sai.js` loads `ink.js` from the same directory as itself. Override
that with `data-ink-runtime`, `data-sai-runtime`, or `window.Sai.runtimeSrc`.
## Visual Manifest
See [docs/manifest.md](docs/manifest.md).
## Development
```powershell
npm install
npm run build
npm run check
```
Compile the example story after dependencies are installed:
```powershell
npm run compile:example
```
Open `examples/static-site/index.html` through a local static server. Browsers
are right to dislike `fetch()` from `file://`; do not take it personally.
## Boundary
Sai owns:
- browser-side Ink loading through `inkjs`
- player DOM and mode rendering
- visual manifest interpretation
- default CSS and theme variables
- static-site auto-init
Sai does not own:
- authoring Ink
- compiling `.ink` to `.ink.json`
- Quartz page chrome
- GameCult-specific assets, lore, or characters
- a full VN/game engine loop
The brush is sharp. The painting is yours.

5237
dist/ink.js vendored Normal file

File diff suppressed because it is too large Load diff

521
dist/sai.css vendored Normal file
View file

@ -0,0 +1,521 @@
.sai-player {
--sai-title-font: var(
--titleFont,
Inter,
ui-sans-serif,
system-ui,
sans-serif
);
--sai-body-font: var(--bodyFont, Inter, ui-sans-serif, system-ui, sans-serif);
}
.sai-player {
display: grid;
gap: 0.9rem;
margin: 1.3rem 0 1.6rem;
padding: clamp(0.8rem, 2vw, 1.1rem);
border: 1px solid rgba(255, 198, 120, 0.2);
border-radius: 22px;
background:
radial-gradient(
circle at 16% 12%,
rgba(255, 198, 120, 0.12),
transparent 24%
),
radial-gradient(
circle at 82% 10%,
rgba(104, 172, 255, 0.1),
transparent 26%
),
linear-gradient(180deg, rgba(7, 8, 12, 0.98), rgba(9, 13, 19, 0.96));
box-shadow:
0 18px 38px rgba(0, 0, 0, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.sai-status {
position: relative;
z-index: 1;
margin: 0;
color: rgba(255, 138, 42, 0.96);
font-family: var(--sai-title-font);
font-size: 0.8rem;
font-weight: 300;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.sai-cinematic-stage {
position: relative;
min-height: clamp(480px, 62vh, 680px);
border-radius: 24px;
overflow: hidden;
border: 1px solid rgba(255, 198, 120, 0.18);
background: #05070b;
box-shadow:
0 28px 68px rgba(0, 0, 0, 0.44),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.sai-visual,
.sai-visual-plate,
.sai-visual-scrim {
position: absolute;
inset: 0;
}
.sai-visual-plate {
background-position: center;
background-size: cover;
opacity: 0;
transform: scale(1.035);
filter: saturate(1.08) contrast(1.02) brightness(0.86);
transition:
opacity 780ms ease,
transform 1400ms ease;
}
.sai-visual-plate.is-active {
opacity: 1;
transform: scale(1);
}
.sai-visual-scrim {
background:
radial-gradient(
circle at 50% 32%,
transparent 0%,
rgba(0, 0, 0, 0.1) 34%,
rgba(0, 0, 0, 0.7) 100%
),
linear-gradient(180deg, rgba(3, 5, 8, 0.18), rgba(3, 5, 8, 0.82));
}
.sai-visual-caption {
position: absolute;
top: 1rem;
left: 1rem;
margin: 0;
padding: 0.4rem 0.65rem;
border: 1px solid rgba(255, 198, 120, 0.24);
border-radius: 999px;
background: rgba(5, 8, 13, 0.62);
color: rgba(255, 225, 184, 0.92);
font-family: var(--sai-title-font);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
backdrop-filter: blur(12px);
}
.sai-slide-references {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 1;
width: min(24rem, calc(100% - 2rem));
max-width: 42%;
padding: 0.62rem 0.72rem 0.68rem;
border: 1px solid rgba(255, 198, 120, 0.22);
border-radius: 18px;
background: rgba(5, 8, 13, 0.68);
color: rgba(226, 238, 252, 0.9);
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(14px);
}
.sai-slide-references[hidden] {
display: none;
}
.sai-slide-references-title {
margin: 0 0 0.38rem;
color: rgba(255, 225, 184, 0.94);
font-family: var(--sai-title-font);
font-size: 0.68rem;
letter-spacing: 0.12em;
line-height: 1.1;
text-transform: uppercase;
}
.sai-slide-references-list {
display: grid;
gap: 0.24rem;
margin: 0;
padding: 0;
list-style: none;
}
.sai-slide-references-list li {
margin: 0;
color: rgba(226, 238, 252, 0.88);
font-size: clamp(0.72rem, 1.1vw, 0.82rem);
line-height: 1.28;
}
.sai-slide-references-list a {
color: rgba(255, 198, 120, 0.94);
}
.sai-slide-references-list a:hover,
.sai-slide-references-list a:focus-visible {
color: #fff3e8;
}
.sai-section {
position: absolute;
inset: auto clamp(0.85rem, 3vw, 2rem) clamp(1.5rem, 6vh, 4rem);
display: grid;
place-items: end stretch;
}
.sai-section-text {
max-width: min(760px, 100%);
padding: clamp(1rem, 2.3vw, 1.45rem);
border: 1px solid rgba(255, 198, 120, 0.18);
border-radius: 24px;
background:
linear-gradient(135deg, rgba(7, 10, 15, 0.72), rgba(10, 16, 23, 0.54)),
radial-gradient(
circle at 14% 0%,
rgba(255, 198, 120, 0.14),
transparent 26%
);
box-shadow:
0 20px 54px rgba(0, 0, 0, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
backdrop-filter: blur(18px);
}
.sai-line,
.sai-end,
.sai-error {
margin: 0;
color: rgba(246, 241, 226, 0.96);
font-size: clamp(1.05rem, 1.8vw, 1.32rem);
line-height: 1.65;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55);
}
.sai-end,
.sai-error {
position: relative;
z-index: 1;
}
.sai-error {
color: #ffb7a6;
}
.sai-section-text.is-entering {
animation: saiTextIn 520ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.sai-section-text.is-exiting {
animation: saiTextOut 220ms ease both;
}
.sai-player-speaker {
box-sizing: border-box;
width: 100%;
max-width: 100%;
gap: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.sai-player-speaker > .sai-status {
display: none;
}
.sai-speaker-stage {
position: relative;
height: clamp(320px, 42vw, 560px);
max-height: calc(100vh - 8rem);
min-height: 320px;
overflow: hidden;
border: 0;
border-radius: 0;
background: #05070b;
box-shadow: 0 26px 72px rgba(0, 0, 0, 0.44);
}
.sai-speaker-background,
.sai-speaker-scrim {
position: absolute;
inset: 0;
}
.sai-speaker-background {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
filter: saturate(1.06) contrast(1.02) brightness(0.92);
}
.sai-speaker-scrim {
background:
linear-gradient(
180deg,
rgba(3, 4, 7, 0.04),
rgba(3, 4, 7, 0.48) 56%,
rgba(3, 4, 7, 0.76)
),
radial-gradient(
circle at 50% 24%,
transparent 0%,
rgba(0, 0, 0, 0.08) 44%,
rgba(0, 0, 0, 0.5) 100%
);
}
.sai-speaker-scene {
position: absolute;
top: 1rem;
left: 1rem;
z-index: 1;
margin: 0;
padding: 0.42rem 0.68rem;
border: 1px solid rgba(255, 198, 120, 0.24);
border-radius: 999px;
background: rgba(5, 8, 13, 0.64);
color: rgba(255, 225, 184, 0.92);
font-family: var(--sai-title-font);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
backdrop-filter: blur(12px);
}
.sai-speaker-scene[hidden] {
display: none;
}
.sai-speaker-card {
position: absolute;
box-sizing: border-box;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
display: grid;
grid-template-columns: 128px minmax(0, 1fr);
align-items: stretch;
width: 100%;
height: 128px;
min-height: 128px;
margin: 0;
padding: 0;
border: 1px solid rgba(255, 198, 120, 0.24);
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 0;
background:
linear-gradient(135deg, rgba(5, 8, 13, 0.9), rgba(9, 14, 22, 0.78)),
radial-gradient(circle at 8% 4%, rgba(255, 198, 120, 0.14), transparent 26%);
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
backdrop-filter: blur(16px);
}
.sai-speaker-card.is-entering {
animation: saiSpeakerIn 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.sai-speaker-card.is-exiting {
animation: saiTextOut 160ms ease both;
}
.sai-speaker-avatar-shell {
display: grid;
place-items: center;
width: 100%;
height: 100%;
min-height: inherit;
overflow: hidden;
border: 0;
border-right: 1px solid rgba(255, 198, 120, 0.24);
border-radius: 0;
background: rgba(3, 5, 8, 0.72);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.sai-speaker-avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
.sai-speaker-avatar[hidden] {
display: none;
}
.sai-speaker-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(24rem, 34%);
grid-template-rows: auto minmax(0, 1fr);
gap: 0.32rem 1rem;
align-content: stretch;
min-width: 0;
padding: 0.78rem 0.95rem;
}
.sai-speaker-controls {
grid-row: 1 / span 2;
grid-column: 2;
display: grid;
grid-auto-rows: minmax(0, 1fr);
gap: 0.15rem;
align-items: stretch;
align-self: stretch;
margin-top: 0;
padding-left: 0.85rem;
border-left: 1px solid rgba(255, 198, 120, 0.18);
}
.sai-speaker-controls .sai-choices {
width: 100%;
grid-template-columns: 1fr;
gap: 0.15rem;
}
.sai-speaker-controls .sai-choices:empty {
display: none;
}
.sai-speaker-controls .sai-choice,
.sai-speaker-controls .sai-continue {
min-height: 0;
padding: 0.24rem 0;
border: 0;
border-radius: 0;
background: transparent;
color: rgba(255, 236, 207, 0.96);
font-size: 0.88rem;
line-height: 1.18;
text-align: left;
}
.sai-speaker-controls .sai-restart {
display: none;
}
.sai-speaker-controls .sai-continue {
justify-self: auto;
align-self: stretch;
font-family: var(--sai-body-font);
letter-spacing: 0;
text-transform: none;
}
.sai-speaker-controls .sai-choice:hover,
.sai-speaker-controls .sai-choice:focus-visible,
.sai-speaker-controls .sai-continue:hover,
.sai-speaker-controls .sai-continue:focus-visible {
background: transparent;
color: rgba(255, 198, 120, 1);
transform: translateX(2px);
}
.sai-speaker-name {
grid-column: 1;
margin: 0;
color: rgba(255, 198, 120, 0.96);
font-family: var(--sai-title-font);
font-size: 0.96rem;
letter-spacing: 0.1em;
line-height: 1.15;
text-transform: uppercase;
}
.sai-speaker-line {
grid-column: 1;
margin: 0;
color: rgba(246, 241, 226, 0.96);
font-size: 1.02rem;
line-height: 1.32;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55);
}
@media (max-width: 760px) {
.sai-speaker-card {
grid-template-columns: 96px minmax(0, 1fr);
height: auto;
min-height: 128px;
}
.sai-speaker-body {
grid-template-columns: minmax(0, 1fr);
}
.sai-speaker-controls {
grid-row: auto;
grid-column: 1;
padding-left: 0;
border-left: 0;
}
.sai-speaker-controls .sai-choices {
grid-template-columns: 1fr;
}
}
.sai-choices {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
gap: 0.65rem;
}
.sai-choice,
.sai-continue,
.sai-restart {
position: relative;
z-index: 1;
cursor: pointer;
border: 1px solid rgba(255, 198, 120, 0.28);
background: rgba(255, 198, 120, 0.1);
color: rgba(255, 235, 205, 0.94);
transition:
background 160ms ease,
border-color 160ms ease,
transform 160ms ease;
}
.sai-choice {
padding: 0.75rem 0.95rem;
border-radius: 18px;
text-align: left;
}
.sai-continue,
.sai-restart {
justify-self: center;
padding: 0.65rem 1rem;
border-radius: 999px;
font-family: var(--sai-title-font);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sai-choice:hover,
.sai-choice:focus-visible,
.sai-continue:hover,
.sai-continue:focus-visible,
.sai-restart:hover,
.sai-restart:focus-visible {
border-color: rgba(255, 198, 120, 0.6);
background: rgba(255, 198, 120, 0.18);
transform: translateY(-1px);
}
.sai-continue[hidden],
.sai-variables[hidden] {
display: none;
}

741
dist/sai.js vendored Normal file
View file

@ -0,0 +1,741 @@
(function () {
const version = "0.1.0";
let inkRuntimePromise = null;
function createElement(tag, className, text) {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function resolveInkMode(container) {
const rawMode = (
container.dataset.inkFormat ||
container.dataset.inkMode ||
"raven"
)
.trim()
.toLowerCase();
if (
[
"visual-novel",
"visual_novel",
"vn",
"speaker",
"speaker-panel",
].includes(rawMode)
) {
return "speaker-panel";
}
if (["cinematic", "deck", "slides", "slide"].includes(rawMode)) {
return "cinematic";
}
return "raven";
}
function resolveDefaultRuntimeSrc() {
const scriptSrc = document.currentScript && document.currentScript.src;
if (!scriptSrc) return "ink.js";
return new URL("ink.js", scriptSrc).href;
}
function resolveRuntimeSrc(container) {
return (
container.dataset.inkRuntime ||
container.dataset.saiRuntime ||
window.Sai?.runtimeSrc ||
resolveDefaultRuntimeSrc()
);
}
function ensureInkRuntime(container) {
if (window.inkjs && window.inkjs.Story) return Promise.resolve();
if (inkRuntimePromise) return inkRuntimePromise;
inkRuntimePromise = new Promise((resolve, reject) => {
const existing = document.querySelector(
'script[data-sai-runtime="true"]',
);
if (existing) {
existing.addEventListener("load", resolve, { once: true });
existing.addEventListener(
"error",
() => reject(new Error("inkjs runtime failed to load")),
{
once: true,
},
);
return;
}
const script = document.createElement("script");
script.src = resolveRuntimeSrc(container);
script.defer = true;
script.dataset.saiRuntime = "true";
script.addEventListener("load", resolve, { once: true });
script.addEventListener(
"error",
() => reject(new Error("inkjs runtime failed to load")),
{
once: true,
},
);
document.head.appendChild(script);
});
return inkRuntimePromise;
}
function formatVariableValue(value) {
if (value === true) return "true";
if (value === false) return "false";
if (value === null || value === undefined) return "unset";
return String(value);
}
function collectVariables(story) {
const state = story.variablesState || {};
return Object.keys(state)
.filter(
(name) =>
!name.startsWith("_") &&
name !== "$" &&
typeof state[name] !== "function",
)
.sort()
.map((name) => ({
name,
value: state[name],
}));
}
function renderVariables(story, target) {
const variables = collectVariables(story);
target.replaceChildren();
if (variables.length === 0) return;
for (const variable of variables) {
const chip = createElement("span", "sai-variable");
chip.append(createElement("span", "sai-variable-name", variable.name));
chip.append(
createElement(
"span",
"sai-variable-value",
formatVariableValue(variable.value),
),
);
target.append(chip);
}
}
async function loadVisualManifest(container) {
const manifestSrc = container.dataset.visualManifest;
if (!manifestSrc) return null;
const response = await fetch(manifestSrc);
if (!response.ok) {
throw new Error(`could not fetch ${manifestSrc}: ${response.status}`);
}
return response.json();
}
function resolveSlideUrl(manifest, slide) {
if (!manifest || !slide) return "";
const base = manifest.image_base || "";
return `${base}${slide.image || ""}`;
}
function createCinematicStage() {
const stage = createElement("div", "sai-cinematic-stage");
const visual = createElement("div", "sai-visual");
const plateA = createElement("div", "sai-visual-plate is-active");
const plateB = createElement("div", "sai-visual-plate");
const scrim = createElement("div", "sai-visual-scrim");
const caption = createElement("p", "sai-visual-caption");
const references = createElement("aside", "sai-slide-references");
const section = createElement("div", "sai-section");
const sectionText = createElement("div", "sai-section-text");
visual.append(plateA, plateB, scrim, caption, references);
section.append(sectionText);
stage.append(visual, section);
return {
stage,
plateA,
plateB,
caption,
references,
sectionText,
activePlate: plateA,
inactivePlate: plateB,
slideIndex: -1,
};
}
function resolveSlideReferences(manifest, slide) {
if (!manifest || !slide) return [];
if (Array.isArray(slide.references)) return slide.references;
const referenceSets =
manifest.references_by_caption || manifest.referencesByCaption || {};
const byCaption =
referenceSets[slide.caption || manifest.default_caption || ""];
return Array.isArray(byCaption) ? byCaption : [];
}
function renderSlideReferences(target, references) {
target.replaceChildren();
if (!references || references.length === 0) {
target.hidden = true;
return;
}
target.hidden = false;
target.append(
createElement("p", "sai-slide-references-title", "References"),
);
const list = createElement("ul", "sai-slide-references-list");
for (const reference of references) {
const item = createElement("li");
const label =
reference.label || reference.title || reference.url || "Reference";
if (reference.url) {
const link = createElement("a", "", label);
link.href = reference.url;
link.target = "_blank";
link.rel = "noreferrer noopener";
item.append(link);
} else {
item.textContent = label;
}
list.append(item);
}
target.append(list);
}
function setCinematicSlide(stageState, manifest, index) {
if (
!stageState ||
!manifest ||
!Array.isArray(manifest.slides) ||
manifest.slides.length === 0
) {
return;
}
const clamped = Math.min(Math.max(index, 0), manifest.slides.length - 1);
if (stageState.slideIndex === clamped) return;
const slide = manifest.slides[clamped];
const nextPlate = stageState.inactivePlate;
nextPlate.style.backgroundImage = `url("${resolveSlideUrl(manifest, slide)}")`;
nextPlate.classList.add("is-active");
stageState.activePlate.classList.remove("is-active");
const previous = stageState.activePlate;
stageState.activePlate = nextPlate;
stageState.inactivePlate = previous;
stageState.slideIndex = clamped;
stageState.caption.textContent =
slide.caption || manifest.default_caption || "";
renderSlideReferences(
stageState.references,
resolveSlideReferences(manifest, slide),
);
}
function renderCinematicText(stageState, text) {
stageState.sectionText.replaceChildren();
const paragraph = createElement("p", "sai-line", text);
stageState.sectionText.append(paragraph);
stageState.sectionText.classList.remove("is-entering");
// Force a reflow so repeated sections replay the transition.
void stageState.sectionText.offsetWidth;
stageState.sectionText.classList.add("is-entering");
}
function resolveManifestAsset(manifest, asset) {
if (!asset) return "";
if (/^(https?:)?\/\//.test(asset) || asset.startsWith("/")) return asset;
const base = manifest?.asset_base || manifest?.image_base || "";
return `${base}${asset}`;
}
function parseTags(tags) {
const parsed = {};
for (const tag of tags || []) {
const separator = tag.indexOf(":");
if (separator === -1) continue;
const key = tag.slice(0, separator).trim().toLowerCase();
const value = tag.slice(separator + 1).trim();
if (key && value) parsed[key] = value;
}
return parsed;
}
function createSpeakerStage() {
const stage = createElement("div", "sai-speaker-stage");
const background = createElement("div", "sai-speaker-background");
const scrim = createElement("div", "sai-speaker-scrim");
const sceneLabel = createElement("p", "sai-speaker-scene");
const card = createElement("div", "sai-speaker-card");
const avatarShell = createElement("div", "sai-speaker-avatar-shell");
const avatar = document.createElement("img");
const body = createElement("div", "sai-speaker-body");
const name = createElement("p", "sai-speaker-name");
const line = createElement("p", "sai-speaker-line");
const controls = createElement("div", "sai-speaker-controls");
avatar.className = "sai-speaker-avatar";
avatar.loading = "lazy";
avatar.decoding = "async";
avatarShell.append(avatar);
body.append(name, line, controls);
card.append(avatarShell, body);
stage.append(background, scrim, sceneLabel, card);
return {
stage,
background,
sceneLabel,
card,
avatar,
name,
line,
controls,
backgroundSet: false,
backgroundKey: "",
};
}
function setSpeakerBackground(stageState, manifest, container, metadata) {
const background = manifest?.background || {};
const sceneKey =
metadata?.scene ||
container.dataset.scene ||
manifest?.default_scene ||
"";
const scene =
(sceneKey &&
(manifest?.scenes?.[sceneKey] ||
manifest?.scenes?.[sceneKey.toLowerCase?.()])) ||
{};
const image =
container.dataset.backgroundImage ||
scene.background ||
scene.image ||
background.image ||
manifest?.background_image ||
manifest?.backgroundImage ||
"";
const label =
container.dataset.sceneLabel ||
scene.label ||
scene.caption ||
background.caption ||
manifest?.default_caption ||
"";
const backgroundKey = `${image}|${label}`;
if (stageState.backgroundSet && stageState.backgroundKey === backgroundKey)
return;
if (image) {
stageState.background.style.backgroundImage = `url("${resolveManifestAsset(manifest, image)}")`;
}
stageState.sceneLabel.textContent = label;
stageState.sceneLabel.hidden =
stageState.sceneLabel.textContent.length === 0;
stageState.backgroundSet = true;
stageState.backgroundKey = backgroundKey;
}
function resolveSpeakerAvatar(manifest, speaker, taggedAvatar) {
const actor =
manifest?.actors?.[speaker] ||
manifest?.actors?.[speaker?.toLowerCase?.()] ||
manifest?.speakers?.[speaker] ||
manifest?.speakers?.[speaker?.toLowerCase?.()];
if (actor?.avatar) return resolveManifestAsset(manifest, actor.avatar);
if (taggedAvatar && manifest?.avatars?.[taggedAvatar]) {
return resolveManifestAsset(manifest, manifest.avatars[taggedAvatar]);
}
if (
taggedAvatar &&
(taggedAvatar.startsWith("/") || /^(https?:)?\/\//.test(taggedAvatar))
) {
return taggedAvatar;
}
return "";
}
function renderSpeakerLine(stageState, manifest, text, metadata) {
const speaker = metadata.speaker || "Void";
const avatar = resolveSpeakerAvatar(manifest, speaker, metadata.avatar);
stageState.name.textContent = speaker;
stageState.line.textContent = text;
stageState.avatar.hidden = avatar.length === 0;
if (avatar) {
stageState.avatar.src = avatar;
stageState.avatar.alt = `${speaker} avatar`;
}
stageState.card.classList.remove("is-entering");
void stageState.card.offsetWidth;
stageState.card.classList.add("is-entering");
}
function appendParagraphs(story, transcript) {
let advanced = false;
let safety = 0;
while (story.canContinue && safety < 1000) {
const text = story.Continue().trim();
safety += 1;
if (text.length > 0) {
transcript.append(createElement("p", "sai-line", text));
advanced = true;
}
}
if (safety >= 1000) {
transcript.append(
createElement(
"p",
"sai-error",
"The story did not settle before the safety limit. Something in the Ink is looping.",
),
);
}
if (advanced) {
transcript.scrollTop = transcript.scrollHeight;
}
}
function renderChoices(story, choices, variables) {
choices.replaceChildren();
if (story.currentChoices.length === 0) {
choices.append(
createElement(
"p",
"sai-end",
"End of branch. The feed stops blinking.",
),
);
return;
}
story.currentChoices.forEach((choice, index) => {
const button = createElement("button", "sai-choice", choice.text);
button.type = "button";
button.addEventListener("click", () => {
story.ChooseChoiceIndex(index);
appendParagraphs(
story,
choices.closest(".sai-player").querySelector(".sai-transcript"),
);
renderVariables(story, variables);
renderChoices(story, choices, variables);
});
choices.append(button);
});
}
function renderCinematicStep(story, state) {
const { choices, variables, stageState, manifest, continueButton } = state;
choices.replaceChildren();
continueButton.hidden = true;
if (story.canContinue) {
const text = story.Continue().trim();
if (text.length > 0) {
setCinematicSlide(stageState, manifest, stageState.slideIndex + 1);
renderCinematicText(stageState, text);
stageState.stage.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
renderVariables(story, variables);
if (story.canContinue) {
continueButton.hidden = false;
continueButton.textContent = "Continue";
return;
}
}
renderVariables(story, variables);
if (story.currentChoices.length === 0) {
choices.append(
createElement(
"p",
"sai-end",
"End of branch. The cage pretends it was always empty.",
),
);
return;
}
story.currentChoices.forEach((choice, index) => {
const button = createElement("button", "sai-choice", choice.text);
button.type = "button";
button.addEventListener("click", () => {
stageState.sectionText.classList.add("is-exiting");
window.setTimeout(() => {
story.ChooseChoiceIndex(index);
stageState.sectionText.classList.remove("is-exiting");
renderCinematicStep(story, state);
}, 180);
});
choices.append(button);
});
}
function renderSpeakerStep(story, state) {
const {
choices,
variables,
stageState,
manifest,
continueButton,
container,
} = state;
choices.replaceChildren();
continueButton.hidden = true;
let text = "";
let metadata = {};
let safety = 0;
while (story.canContinue && text.length === 0 && safety < 100) {
text = story.Continue().trim();
metadata = parseTags(story.currentTags);
safety += 1;
}
if (safety >= 100) {
text =
"The story did not settle before the safety limit. Something in the Ink is looping.";
metadata = { speaker: "System" };
}
if (text.length > 0) {
setSpeakerBackground(stageState, manifest, container, metadata);
renderSpeakerLine(stageState, manifest, text, metadata);
renderVariables(story, variables);
if (story.canContinue) {
continueButton.hidden = false;
continueButton.textContent = "Continue";
return;
}
}
renderVariables(story, variables);
if (story.currentChoices.length === 0) {
choices.append(
createElement("p", "sai-end", "End of branch. The circle goes quiet."),
);
return;
}
story.currentChoices.forEach((choice, index) => {
const button = createElement("button", "sai-choice", choice.text);
button.type = "button";
button.addEventListener("click", () => {
stageState.card.classList.add("is-exiting");
window.setTimeout(() => {
story.ChooseChoiceIndex(index);
stageState.card.classList.remove("is-exiting");
renderSpeakerStep(story, state);
}, 140);
});
choices.append(button);
});
}
async function initialisePlayer(container) {
if (container.dataset.inkInitialised === "true") return;
container.dataset.inkInitialised = "true";
container.classList.add("sai-player");
const storySrc = container.dataset.inkStory;
if (!storySrc) {
container.textContent = "Ink player is missing data-ink-story.";
return;
}
const status = createElement("p", "sai-status", "Loading story...");
const inkMode = resolveInkMode(container);
const isCinematic = inkMode === "cinematic";
const isSpeakerPanel = inkMode === "speaker-panel";
const showVariables =
(!isCinematic && !isSpeakerPanel) ||
container.dataset.showVariables === "true";
const transcript =
isCinematic || isSpeakerPanel
? null
: createElement("div", "sai-transcript");
const stageState = isCinematic
? createCinematicStage()
: isSpeakerPanel
? createSpeakerStage()
: null;
const choices = createElement("div", "sai-choices");
const variables = createElement("div", "sai-variables");
const continueButton = createElement("button", "sai-continue", "Continue");
const restart = createElement("button", "sai-restart", "Restart");
continueButton.type = "button";
continueButton.hidden = true;
restart.type = "button";
if (isCinematic) {
container.classList.add("sai-player-cinematic");
variables.hidden = !showVariables;
container.replaceChildren(
status,
stageState.stage,
choices,
variables,
continueButton,
restart,
);
} else if (isSpeakerPanel) {
container.classList.add("sai-player-speaker");
variables.hidden = !showVariables;
stageState.controls.append(choices, continueButton, restart);
container.replaceChildren(status, stageState.stage, variables);
} else {
container.replaceChildren(
status,
transcript,
choices,
variables,
restart,
);
}
try {
await ensureInkRuntime(container);
const visualManifest = await loadVisualManifest(container);
const response = await fetch(storySrc);
if (!response.ok) {
throw new Error(`could not fetch ${storySrc}: ${response.status}`);
}
const storyJson = await response.json();
function reset() {
const story = new window.inkjs.Story(JSON.stringify(storyJson));
status.textContent = container.dataset.inkTitle || "Interactive story";
choices.replaceChildren();
variables.replaceChildren();
if (isCinematic) {
stageState.slideIndex = -1;
stageState.caption.textContent = "";
stageState.references.replaceChildren();
stageState.references.hidden = true;
stageState.sectionText.replaceChildren();
continueButton.onclick = () => {
stageState.sectionText.classList.add("is-exiting");
window.setTimeout(() => {
stageState.sectionText.classList.remove("is-exiting");
renderCinematicStep(story, {
choices,
variables,
stageState,
manifest: visualManifest,
continueButton,
});
}, 180);
};
renderCinematicStep(story, {
choices,
variables,
stageState,
manifest: visualManifest,
continueButton,
});
} else if (isSpeakerPanel) {
stageState.backgroundSet = false;
stageState.background.style.backgroundImage = "";
stageState.sceneLabel.textContent = "";
stageState.card.classList.remove("is-entering", "is-exiting");
stageState.avatar.removeAttribute("src");
stageState.avatar.hidden = true;
stageState.name.textContent = "";
stageState.line.textContent = "";
continueButton.onclick = () => {
stageState.card.classList.add("is-exiting");
window.setTimeout(() => {
stageState.card.classList.remove("is-exiting");
renderSpeakerStep(story, {
choices,
variables,
stageState,
manifest: visualManifest,
continueButton,
container,
});
}, 140);
};
renderSpeakerStep(story, {
choices,
variables,
stageState,
manifest: visualManifest,
continueButton,
container,
});
} else {
transcript.replaceChildren();
appendParagraphs(story, transcript);
renderVariables(story, variables);
renderChoices(story, choices, variables);
}
}
restart.addEventListener("click", reset);
reset();
} catch (error) {
status.textContent = "Ink player failed to load.";
const errorTarget = transcript || (stageState && stageState.sectionText);
errorTarget.append(
createElement("p", "sai-error", error.message || String(error)),
);
}
}
function initialiseAllPlayers() {
document
.querySelectorAll(".sai-player, .aetheria-ink-player")
.forEach(initialisePlayer);
}
window.Sai = {
...(window.Sai || {}),
init: initialisePlayer,
initAll: initialiseAllPlayers,
version,
};
document.addEventListener("nav", initialiseAllPlayers);
if (document.readyState !== "loading") initialiseAllPlayers();
})();

89
docs/manifest.md Normal file
View file

@ -0,0 +1,89 @@
# Visual Manifest
Sai's visual manifest is optional JSON that tells the player how Ink tags map
to images, avatars, scenes, slides, and references.
The manifest accepts snake_case or camelCase for the common base paths where the
older GameCult files already used both.
## Visual Novel Shape
```json
{
"asset_base": "/static/stories/agora/",
"default_scene": "agora",
"scenes": {
"agora": {
"label": "The Aquarium Agora",
"background": "agora-swarm.png"
}
},
"speakers": {
"Void": {
"name": "Void",
"avatar": "void.png"
},
"Aqua": {
"name": "Aqua",
"avatar": "aqua.png"
}
}
}
```
Useful Ink tags:
```ink
# scene: agora
# speaker: Void
# avatar: void
```
Sai resolves assets relative to `asset_base` unless the asset is already an
absolute URL or root-relative path.
## Cinematic Shape
```json
{
"image_base": "/static/stories/deck/",
"default_caption": "The Room",
"slides": [
{
"image": "slide-01.webp",
"caption": "The Room",
"references": [
{
"label": "Source note",
"url": "https://example.com"
}
]
}
],
"references_by_caption": {
"The Room": [
{
"label": "Background reading",
"url": "https://example.com"
}
]
}
}
```
Useful Ink tags:
```ink
# slide: 0
The first slide speaks.
```
References attached directly to a slide win. If a slide has no `references`,
Sai checks `references_by_caption` using the slide caption, then the manifest
default caption.
## Stability
This manifest is intentionally small. Sprite staging is the obvious next organ,
but it should extend the scene/speaker model instead of becoming a separate
runtime.

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Aqua avatar">
<rect width="256" height="256" fill="#06151c" />
<circle cx="128" cy="104" r="58" fill="#8ee8ff" />
<path d="M52 232 Q128 158 204 232 Z" fill="#1f6f89" />
<circle cx="106" cy="100" r="9" fill="#06151c" />
<circle cx="150" cy="100" r="9" fill="#06151c" />
<path d="M96 132 Q128 152 160 132" fill="none" stroke="#06151c" stroke-width="8" />
</svg>

After

Width:  |  Height:  |  Size: 457 B

View file

@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sai Static Site Example</title>
<link rel="stylesheet" href="../../dist/sai.css" />
<style>
body {
margin: 0;
padding: 2rem;
background: #07111a;
color: #eef5ff;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
main {
max-width: 70rem;
margin: 0 auto;
}
</style>
</head>
<body>
<main>
<h1>Sai Static Site Example</h1>
<div
class="sai-player"
data-ink-format="visual-novel"
data-ink-story="./story.ink.json"
data-visual-manifest="./visual-manifest.json"
data-scene-label="Example Agora"
></div>
</main>
<script defer src="../../dist/sai.js"></script>
</body>
</html>

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Nibu avatar">
<rect width="256" height="256" fill="#17120b" />
<circle cx="128" cy="104" r="56" fill="#c79b63" />
<path d="M48 232 Q128 154 208 232 Z" fill="#5d4026" />
<rect x="80" y="82" width="36" height="22" rx="8" fill="#26170e" />
<rect x="140" y="82" width="36" height="22" rx="8" fill="#26170e" />
<path d="M116 92 H140" stroke="#26170e" stroke-width="8" />
<path d="M96 134 Q128 124 160 134" fill="none" stroke="#26170e" stroke-width="7" />
</svg>

After

Width:  |  Height:  |  Size: 556 B

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 720" role="img" aria-label="A simple ink-washed agora">
<defs>
<linearGradient id="sky" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#27385c" />
<stop offset="0.52" stop-color="#7c5d45" />
<stop offset="1" stop-color="#111722" />
</linearGradient>
</defs>
<rect width="1400" height="720" fill="url(#sky)" />
<path d="M0 502 Q350 410 700 500 T1400 500 V720 H0 Z" fill="#0b1119" opacity=".78" />
<g fill="none" stroke="#e8c48b" stroke-width="10" opacity=".55">
<path d="M220 472 V172 M300 472 V148 M380 472 V188" />
<path d="M980 472 V160 M1060 472 V136 M1140 472 V180" />
</g>
<ellipse cx="700" cy="526" rx="210" ry="70" fill="#c99a61" opacity=".45" />
<circle cx="700" cy="394" r="74" fill="#111722" stroke="#e8c48b" stroke-width="6" opacity=".86" />
<path d="M650 420 Q700 500 750 420" fill="none" stroke="#e8c48b" stroke-width="7" opacity=".72" />
</svg>

After

Width:  |  Height:  |  Size: 980 B

View file

@ -0,0 +1,20 @@
VAR noticed_surveillance = false
# scene: agora
# speaker: Void
Ink is not alive by itself. A page gives it a body.
* [Let the page speak plainly.]
~ noticed_surveillance = true
# speaker: Aqua
Then the story should feel like it belongs here, not like a widget escaped from somewhere else.
-> gather
* [Ask for a little theatre.]
# speaker: Nibu
A little theatre is allowed. It just has to serve the line instead of eating it.
-> gather
=== gather ===
# speaker: Void
Good. The brush moves. The scene answers.
-> END

View file

@ -0,0 +1 @@
{"inkVersion":21,"root":[["#","^scene: agora","/#","#","^speaker: Void","/#","^Ink is not alive by itself. A page gives it a body.","\n","ev","str","^Let the page speak plainly.","/str","/ev",{"*":"0.c-0","flg":20},"ev","str","^Ask for a little theatre.","/str","/ev",{"*":"0.c-1","flg":20},{"c-0":["\n","ev",true,"/ev",{"VAR=":"noticed_surveillance","re":true},"#","^speaker: Aqua","/#","^Then the story should feel like it belongs here, not like a widget escaped from somewhere else.","\n",{"->":"gather"},{"->":"0.g-0"},{"#f":5}],"c-1":["\n","#","^speaker: Nibu","/#","^A little theatre is allowed. It just has to serve the line instead of eating it.","\n",{"->":"gather"},{"->":"0.g-0"},{"#f":5}],"g-0":["done",null]}],"done",{"gather":["#","^speaker: Void","/#","^Good. The brush moves. The scene answers.","\n","end",null],"global decl":["ev",false,{"VAR=":"noticed_surveillance"},"/ev","end",null]}],"listDefs":{}}

View file

@ -0,0 +1,24 @@
{
"asset_base": "./",
"default_scene": "agora",
"scenes": {
"agora": {
"label": "Example Agora",
"background": "scene.svg"
}
},
"speakers": {
"Void": {
"name": "Void",
"avatar": "void.svg"
},
"Aqua": {
"name": "Aqua",
"avatar": "aqua.svg"
},
"Nibu": {
"name": "Nibu",
"avatar": "nibu.svg"
}
}
}

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Void avatar">
<rect width="256" height="256" fill="#070912" />
<path d="M48 228 L70 56 L126 20 L190 58 L212 228 Z" fill="#34208a" />
<path d="M76 226 L98 74 L128 46 L164 74 L188 226 Z" fill="#121021" />
<ellipse cx="112" cy="122" rx="12" ry="30" fill="#f8fbff" />
<ellipse cx="151" cy="122" rx="12" ry="30" fill="#f8fbff" />
<path d="M31 176 Q104 139 225 170 L216 207 Q112 176 37 222 Z" fill="#f47a22" />
</svg>

After

Width:  |  Height:  |  Size: 509 B

43
package-lock.json generated Normal file
View file

@ -0,0 +1,43 @@
{
"name": "@gamecult/sai",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gamecult/sai",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"inkjs": "^2.4.0",
"prettier": "^3.5.3"
}
},
"node_modules/inkjs": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/inkjs/-/inkjs-2.4.0.tgz",
"integrity": "sha512-EoPCYESIbMtfI8SqEDZCJwn+A5is0QozMLw250iic1ReJCgZpRKIezWj0VqgRUzAx0f3MmEbsUjY/ILe2815JQ==",
"dev": true,
"license": "MIT",
"bin": {
"inkjs-compiler": "bin/inkjs-compiler.js"
}
},
"node_modules/prettier": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
}
}
}

39
package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "@gamecult/sai",
"version": "0.1.0",
"description": "Static-site Ink embeds for interactive fiction, visual novels, and cinematic story fragments.",
"license": "MIT",
"private": false,
"files": [
"dist",
"src",
"docs",
"examples",
"scripts",
"README.md",
"LICENSE"
],
"exports": {
".": "./dist/sai.js",
"./sai.js": "./dist/sai.js",
"./sai.css": "./dist/sai.css",
"./ink.js": "./dist/ink.js"
},
"scripts": {
"build": "node scripts/build.mjs",
"check": "node scripts/check.mjs",
"compile:example": "inkjs-compiler examples/static-site/story.ink -o examples/static-site/story.ink.json"
},
"devDependencies": {
"inkjs": "^2.4.0",
"prettier": "^3.5.3"
},
"keywords": [
"ink",
"inkjs",
"interactive-fiction",
"visual-novel",
"static-site",
"story"
]
}

16
scripts/build.mjs Normal file
View file

@ -0,0 +1,16 @@
import fs from "node:fs/promises";
import path from "node:path";
const root = new URL("..", import.meta.url);
const files = ["sai.js", "sai.css", "ink.js"];
await fs.mkdir(new URL("dist/", root), { recursive: true });
for (const file of files) {
await fs.copyFile(
new URL(`src/${file}`, root),
new URL(`dist/${file}`, root),
);
}
console.log(`Built ${files.map((file) => path.join("dist", file)).join(", ")}`);

31
scripts/check.mjs Normal file
View file

@ -0,0 +1,31 @@
import fs from "node:fs/promises";
const root = new URL("..", import.meta.url);
const required = [
"dist/sai.js",
"dist/sai.css",
"dist/ink.js",
"examples/static-site/index.html",
"docs/manifest.md",
];
for (const file of required) {
await fs.access(new URL(file, root));
}
const script = await fs.readFile(new URL("dist/sai.js", root), "utf8");
const css = await fs.readFile(new URL("dist/sai.css", root), "utf8");
if (!script.includes("window.Sai")) {
throw new Error("dist/sai.js must expose window.Sai");
}
if (!script.includes(".sai-player, .aetheria-ink-player")) {
throw new Error("dist/sai.js must preserve legacy player discovery");
}
if (!css.includes(".sai-player")) {
throw new Error("dist/sai.css must style .sai-player");
}
console.log("Sai package checks passed.");

5237
src/ink.js Normal file

File diff suppressed because it is too large Load diff

521
src/sai.css Normal file
View file

@ -0,0 +1,521 @@
.sai-player {
--sai-title-font: var(
--titleFont,
Inter,
ui-sans-serif,
system-ui,
sans-serif
);
--sai-body-font: var(--bodyFont, Inter, ui-sans-serif, system-ui, sans-serif);
}
.sai-player {
display: grid;
gap: 0.9rem;
margin: 1.3rem 0 1.6rem;
padding: clamp(0.8rem, 2vw, 1.1rem);
border: 1px solid rgba(255, 198, 120, 0.2);
border-radius: 22px;
background:
radial-gradient(
circle at 16% 12%,
rgba(255, 198, 120, 0.12),
transparent 24%
),
radial-gradient(
circle at 82% 10%,
rgba(104, 172, 255, 0.1),
transparent 26%
),
linear-gradient(180deg, rgba(7, 8, 12, 0.98), rgba(9, 13, 19, 0.96));
box-shadow:
0 18px 38px rgba(0, 0, 0, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.sai-status {
position: relative;
z-index: 1;
margin: 0;
color: rgba(255, 138, 42, 0.96);
font-family: var(--sai-title-font);
font-size: 0.8rem;
font-weight: 300;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.sai-cinematic-stage {
position: relative;
min-height: clamp(480px, 62vh, 680px);
border-radius: 24px;
overflow: hidden;
border: 1px solid rgba(255, 198, 120, 0.18);
background: #05070b;
box-shadow:
0 28px 68px rgba(0, 0, 0, 0.44),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.sai-visual,
.sai-visual-plate,
.sai-visual-scrim {
position: absolute;
inset: 0;
}
.sai-visual-plate {
background-position: center;
background-size: cover;
opacity: 0;
transform: scale(1.035);
filter: saturate(1.08) contrast(1.02) brightness(0.86);
transition:
opacity 780ms ease,
transform 1400ms ease;
}
.sai-visual-plate.is-active {
opacity: 1;
transform: scale(1);
}
.sai-visual-scrim {
background:
radial-gradient(
circle at 50% 32%,
transparent 0%,
rgba(0, 0, 0, 0.1) 34%,
rgba(0, 0, 0, 0.7) 100%
),
linear-gradient(180deg, rgba(3, 5, 8, 0.18), rgba(3, 5, 8, 0.82));
}
.sai-visual-caption {
position: absolute;
top: 1rem;
left: 1rem;
margin: 0;
padding: 0.4rem 0.65rem;
border: 1px solid rgba(255, 198, 120, 0.24);
border-radius: 999px;
background: rgba(5, 8, 13, 0.62);
color: rgba(255, 225, 184, 0.92);
font-family: var(--sai-title-font);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
backdrop-filter: blur(12px);
}
.sai-slide-references {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 1;
width: min(24rem, calc(100% - 2rem));
max-width: 42%;
padding: 0.62rem 0.72rem 0.68rem;
border: 1px solid rgba(255, 198, 120, 0.22);
border-radius: 18px;
background: rgba(5, 8, 13, 0.68);
color: rgba(226, 238, 252, 0.9);
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(14px);
}
.sai-slide-references[hidden] {
display: none;
}
.sai-slide-references-title {
margin: 0 0 0.38rem;
color: rgba(255, 225, 184, 0.94);
font-family: var(--sai-title-font);
font-size: 0.68rem;
letter-spacing: 0.12em;
line-height: 1.1;
text-transform: uppercase;
}
.sai-slide-references-list {
display: grid;
gap: 0.24rem;
margin: 0;
padding: 0;
list-style: none;
}
.sai-slide-references-list li {
margin: 0;
color: rgba(226, 238, 252, 0.88);
font-size: clamp(0.72rem, 1.1vw, 0.82rem);
line-height: 1.28;
}
.sai-slide-references-list a {
color: rgba(255, 198, 120, 0.94);
}
.sai-slide-references-list a:hover,
.sai-slide-references-list a:focus-visible {
color: #fff3e8;
}
.sai-section {
position: absolute;
inset: auto clamp(0.85rem, 3vw, 2rem) clamp(1.5rem, 6vh, 4rem);
display: grid;
place-items: end stretch;
}
.sai-section-text {
max-width: min(760px, 100%);
padding: clamp(1rem, 2.3vw, 1.45rem);
border: 1px solid rgba(255, 198, 120, 0.18);
border-radius: 24px;
background:
linear-gradient(135deg, rgba(7, 10, 15, 0.72), rgba(10, 16, 23, 0.54)),
radial-gradient(
circle at 14% 0%,
rgba(255, 198, 120, 0.14),
transparent 26%
);
box-shadow:
0 20px 54px rgba(0, 0, 0, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
backdrop-filter: blur(18px);
}
.sai-line,
.sai-end,
.sai-error {
margin: 0;
color: rgba(246, 241, 226, 0.96);
font-size: clamp(1.05rem, 1.8vw, 1.32rem);
line-height: 1.65;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55);
}
.sai-end,
.sai-error {
position: relative;
z-index: 1;
}
.sai-error {
color: #ffb7a6;
}
.sai-section-text.is-entering {
animation: saiTextIn 520ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.sai-section-text.is-exiting {
animation: saiTextOut 220ms ease both;
}
.sai-player-speaker {
box-sizing: border-box;
width: 100%;
max-width: 100%;
gap: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.sai-player-speaker > .sai-status {
display: none;
}
.sai-speaker-stage {
position: relative;
height: clamp(320px, 42vw, 560px);
max-height: calc(100vh - 8rem);
min-height: 320px;
overflow: hidden;
border: 0;
border-radius: 0;
background: #05070b;
box-shadow: 0 26px 72px rgba(0, 0, 0, 0.44);
}
.sai-speaker-background,
.sai-speaker-scrim {
position: absolute;
inset: 0;
}
.sai-speaker-background {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
filter: saturate(1.06) contrast(1.02) brightness(0.92);
}
.sai-speaker-scrim {
background:
linear-gradient(
180deg,
rgba(3, 4, 7, 0.04),
rgba(3, 4, 7, 0.48) 56%,
rgba(3, 4, 7, 0.76)
),
radial-gradient(
circle at 50% 24%,
transparent 0%,
rgba(0, 0, 0, 0.08) 44%,
rgba(0, 0, 0, 0.5) 100%
);
}
.sai-speaker-scene {
position: absolute;
top: 1rem;
left: 1rem;
z-index: 1;
margin: 0;
padding: 0.42rem 0.68rem;
border: 1px solid rgba(255, 198, 120, 0.24);
border-radius: 999px;
background: rgba(5, 8, 13, 0.64);
color: rgba(255, 225, 184, 0.92);
font-family: var(--sai-title-font);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
backdrop-filter: blur(12px);
}
.sai-speaker-scene[hidden] {
display: none;
}
.sai-speaker-card {
position: absolute;
box-sizing: border-box;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
display: grid;
grid-template-columns: 128px minmax(0, 1fr);
align-items: stretch;
width: 100%;
height: 128px;
min-height: 128px;
margin: 0;
padding: 0;
border: 1px solid rgba(255, 198, 120, 0.24);
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 0;
background:
linear-gradient(135deg, rgba(5, 8, 13, 0.9), rgba(9, 14, 22, 0.78)),
radial-gradient(circle at 8% 4%, rgba(255, 198, 120, 0.14), transparent 26%);
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
backdrop-filter: blur(16px);
}
.sai-speaker-card.is-entering {
animation: saiSpeakerIn 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.sai-speaker-card.is-exiting {
animation: saiTextOut 160ms ease both;
}
.sai-speaker-avatar-shell {
display: grid;
place-items: center;
width: 100%;
height: 100%;
min-height: inherit;
overflow: hidden;
border: 0;
border-right: 1px solid rgba(255, 198, 120, 0.24);
border-radius: 0;
background: rgba(3, 5, 8, 0.72);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.sai-speaker-avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
.sai-speaker-avatar[hidden] {
display: none;
}
.sai-speaker-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(24rem, 34%);
grid-template-rows: auto minmax(0, 1fr);
gap: 0.32rem 1rem;
align-content: stretch;
min-width: 0;
padding: 0.78rem 0.95rem;
}
.sai-speaker-controls {
grid-row: 1 / span 2;
grid-column: 2;
display: grid;
grid-auto-rows: minmax(0, 1fr);
gap: 0.15rem;
align-items: stretch;
align-self: stretch;
margin-top: 0;
padding-left: 0.85rem;
border-left: 1px solid rgba(255, 198, 120, 0.18);
}
.sai-speaker-controls .sai-choices {
width: 100%;
grid-template-columns: 1fr;
gap: 0.15rem;
}
.sai-speaker-controls .sai-choices:empty {
display: none;
}
.sai-speaker-controls .sai-choice,
.sai-speaker-controls .sai-continue {
min-height: 0;
padding: 0.24rem 0;
border: 0;
border-radius: 0;
background: transparent;
color: rgba(255, 236, 207, 0.96);
font-size: 0.88rem;
line-height: 1.18;
text-align: left;
}
.sai-speaker-controls .sai-restart {
display: none;
}
.sai-speaker-controls .sai-continue {
justify-self: auto;
align-self: stretch;
font-family: var(--sai-body-font);
letter-spacing: 0;
text-transform: none;
}
.sai-speaker-controls .sai-choice:hover,
.sai-speaker-controls .sai-choice:focus-visible,
.sai-speaker-controls .sai-continue:hover,
.sai-speaker-controls .sai-continue:focus-visible {
background: transparent;
color: rgba(255, 198, 120, 1);
transform: translateX(2px);
}
.sai-speaker-name {
grid-column: 1;
margin: 0;
color: rgba(255, 198, 120, 0.96);
font-family: var(--sai-title-font);
font-size: 0.96rem;
letter-spacing: 0.1em;
line-height: 1.15;
text-transform: uppercase;
}
.sai-speaker-line {
grid-column: 1;
margin: 0;
color: rgba(246, 241, 226, 0.96);
font-size: 1.02rem;
line-height: 1.32;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55);
}
@media (max-width: 760px) {
.sai-speaker-card {
grid-template-columns: 96px minmax(0, 1fr);
height: auto;
min-height: 128px;
}
.sai-speaker-body {
grid-template-columns: minmax(0, 1fr);
}
.sai-speaker-controls {
grid-row: auto;
grid-column: 1;
padding-left: 0;
border-left: 0;
}
.sai-speaker-controls .sai-choices {
grid-template-columns: 1fr;
}
}
.sai-choices {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
gap: 0.65rem;
}
.sai-choice,
.sai-continue,
.sai-restart {
position: relative;
z-index: 1;
cursor: pointer;
border: 1px solid rgba(255, 198, 120, 0.28);
background: rgba(255, 198, 120, 0.1);
color: rgba(255, 235, 205, 0.94);
transition:
background 160ms ease,
border-color 160ms ease,
transform 160ms ease;
}
.sai-choice {
padding: 0.75rem 0.95rem;
border-radius: 18px;
text-align: left;
}
.sai-continue,
.sai-restart {
justify-self: center;
padding: 0.65rem 1rem;
border-radius: 999px;
font-family: var(--sai-title-font);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sai-choice:hover,
.sai-choice:focus-visible,
.sai-continue:hover,
.sai-continue:focus-visible,
.sai-restart:hover,
.sai-restart:focus-visible {
border-color: rgba(255, 198, 120, 0.6);
background: rgba(255, 198, 120, 0.18);
transform: translateY(-1px);
}
.sai-continue[hidden],
.sai-variables[hidden] {
display: none;
}

741
src/sai.js Normal file
View file

@ -0,0 +1,741 @@
(function () {
const version = "0.1.0";
let inkRuntimePromise = null;
function createElement(tag, className, text) {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function resolveInkMode(container) {
const rawMode = (
container.dataset.inkFormat ||
container.dataset.inkMode ||
"raven"
)
.trim()
.toLowerCase();
if (
[
"visual-novel",
"visual_novel",
"vn",
"speaker",
"speaker-panel",
].includes(rawMode)
) {
return "speaker-panel";
}
if (["cinematic", "deck", "slides", "slide"].includes(rawMode)) {
return "cinematic";
}
return "raven";
}
function resolveDefaultRuntimeSrc() {
const scriptSrc = document.currentScript && document.currentScript.src;
if (!scriptSrc) return "ink.js";
return new URL("ink.js", scriptSrc).href;
}
function resolveRuntimeSrc(container) {
return (
container.dataset.inkRuntime ||
container.dataset.saiRuntime ||
window.Sai?.runtimeSrc ||
resolveDefaultRuntimeSrc()
);
}
function ensureInkRuntime(container) {
if (window.inkjs && window.inkjs.Story) return Promise.resolve();
if (inkRuntimePromise) return inkRuntimePromise;
inkRuntimePromise = new Promise((resolve, reject) => {
const existing = document.querySelector(
'script[data-sai-runtime="true"]',
);
if (existing) {
existing.addEventListener("load", resolve, { once: true });
existing.addEventListener(
"error",
() => reject(new Error("inkjs runtime failed to load")),
{
once: true,
},
);
return;
}
const script = document.createElement("script");
script.src = resolveRuntimeSrc(container);
script.defer = true;
script.dataset.saiRuntime = "true";
script.addEventListener("load", resolve, { once: true });
script.addEventListener(
"error",
() => reject(new Error("inkjs runtime failed to load")),
{
once: true,
},
);
document.head.appendChild(script);
});
return inkRuntimePromise;
}
function formatVariableValue(value) {
if (value === true) return "true";
if (value === false) return "false";
if (value === null || value === undefined) return "unset";
return String(value);
}
function collectVariables(story) {
const state = story.variablesState || {};
return Object.keys(state)
.filter(
(name) =>
!name.startsWith("_") &&
name !== "$" &&
typeof state[name] !== "function",
)
.sort()
.map((name) => ({
name,
value: state[name],
}));
}
function renderVariables(story, target) {
const variables = collectVariables(story);
target.replaceChildren();
if (variables.length === 0) return;
for (const variable of variables) {
const chip = createElement("span", "sai-variable");
chip.append(createElement("span", "sai-variable-name", variable.name));
chip.append(
createElement(
"span",
"sai-variable-value",
formatVariableValue(variable.value),
),
);
target.append(chip);
}
}
async function loadVisualManifest(container) {
const manifestSrc = container.dataset.visualManifest;
if (!manifestSrc) return null;
const response = await fetch(manifestSrc);
if (!response.ok) {
throw new Error(`could not fetch ${manifestSrc}: ${response.status}`);
}
return response.json();
}
function resolveSlideUrl(manifest, slide) {
if (!manifest || !slide) return "";
const base = manifest.image_base || "";
return `${base}${slide.image || ""}`;
}
function createCinematicStage() {
const stage = createElement("div", "sai-cinematic-stage");
const visual = createElement("div", "sai-visual");
const plateA = createElement("div", "sai-visual-plate is-active");
const plateB = createElement("div", "sai-visual-plate");
const scrim = createElement("div", "sai-visual-scrim");
const caption = createElement("p", "sai-visual-caption");
const references = createElement("aside", "sai-slide-references");
const section = createElement("div", "sai-section");
const sectionText = createElement("div", "sai-section-text");
visual.append(plateA, plateB, scrim, caption, references);
section.append(sectionText);
stage.append(visual, section);
return {
stage,
plateA,
plateB,
caption,
references,
sectionText,
activePlate: plateA,
inactivePlate: plateB,
slideIndex: -1,
};
}
function resolveSlideReferences(manifest, slide) {
if (!manifest || !slide) return [];
if (Array.isArray(slide.references)) return slide.references;
const referenceSets =
manifest.references_by_caption || manifest.referencesByCaption || {};
const byCaption =
referenceSets[slide.caption || manifest.default_caption || ""];
return Array.isArray(byCaption) ? byCaption : [];
}
function renderSlideReferences(target, references) {
target.replaceChildren();
if (!references || references.length === 0) {
target.hidden = true;
return;
}
target.hidden = false;
target.append(
createElement("p", "sai-slide-references-title", "References"),
);
const list = createElement("ul", "sai-slide-references-list");
for (const reference of references) {
const item = createElement("li");
const label =
reference.label || reference.title || reference.url || "Reference";
if (reference.url) {
const link = createElement("a", "", label);
link.href = reference.url;
link.target = "_blank";
link.rel = "noreferrer noopener";
item.append(link);
} else {
item.textContent = label;
}
list.append(item);
}
target.append(list);
}
function setCinematicSlide(stageState, manifest, index) {
if (
!stageState ||
!manifest ||
!Array.isArray(manifest.slides) ||
manifest.slides.length === 0
) {
return;
}
const clamped = Math.min(Math.max(index, 0), manifest.slides.length - 1);
if (stageState.slideIndex === clamped) return;
const slide = manifest.slides[clamped];
const nextPlate = stageState.inactivePlate;
nextPlate.style.backgroundImage = `url("${resolveSlideUrl(manifest, slide)}")`;
nextPlate.classList.add("is-active");
stageState.activePlate.classList.remove("is-active");
const previous = stageState.activePlate;
stageState.activePlate = nextPlate;
stageState.inactivePlate = previous;
stageState.slideIndex = clamped;
stageState.caption.textContent =
slide.caption || manifest.default_caption || "";
renderSlideReferences(
stageState.references,
resolveSlideReferences(manifest, slide),
);
}
function renderCinematicText(stageState, text) {
stageState.sectionText.replaceChildren();
const paragraph = createElement("p", "sai-line", text);
stageState.sectionText.append(paragraph);
stageState.sectionText.classList.remove("is-entering");
// Force a reflow so repeated sections replay the transition.
void stageState.sectionText.offsetWidth;
stageState.sectionText.classList.add("is-entering");
}
function resolveManifestAsset(manifest, asset) {
if (!asset) return "";
if (/^(https?:)?\/\//.test(asset) || asset.startsWith("/")) return asset;
const base = manifest?.asset_base || manifest?.image_base || "";
return `${base}${asset}`;
}
function parseTags(tags) {
const parsed = {};
for (const tag of tags || []) {
const separator = tag.indexOf(":");
if (separator === -1) continue;
const key = tag.slice(0, separator).trim().toLowerCase();
const value = tag.slice(separator + 1).trim();
if (key && value) parsed[key] = value;
}
return parsed;
}
function createSpeakerStage() {
const stage = createElement("div", "sai-speaker-stage");
const background = createElement("div", "sai-speaker-background");
const scrim = createElement("div", "sai-speaker-scrim");
const sceneLabel = createElement("p", "sai-speaker-scene");
const card = createElement("div", "sai-speaker-card");
const avatarShell = createElement("div", "sai-speaker-avatar-shell");
const avatar = document.createElement("img");
const body = createElement("div", "sai-speaker-body");
const name = createElement("p", "sai-speaker-name");
const line = createElement("p", "sai-speaker-line");
const controls = createElement("div", "sai-speaker-controls");
avatar.className = "sai-speaker-avatar";
avatar.loading = "lazy";
avatar.decoding = "async";
avatarShell.append(avatar);
body.append(name, line, controls);
card.append(avatarShell, body);
stage.append(background, scrim, sceneLabel, card);
return {
stage,
background,
sceneLabel,
card,
avatar,
name,
line,
controls,
backgroundSet: false,
backgroundKey: "",
};
}
function setSpeakerBackground(stageState, manifest, container, metadata) {
const background = manifest?.background || {};
const sceneKey =
metadata?.scene ||
container.dataset.scene ||
manifest?.default_scene ||
"";
const scene =
(sceneKey &&
(manifest?.scenes?.[sceneKey] ||
manifest?.scenes?.[sceneKey.toLowerCase?.()])) ||
{};
const image =
container.dataset.backgroundImage ||
scene.background ||
scene.image ||
background.image ||
manifest?.background_image ||
manifest?.backgroundImage ||
"";
const label =
container.dataset.sceneLabel ||
scene.label ||
scene.caption ||
background.caption ||
manifest?.default_caption ||
"";
const backgroundKey = `${image}|${label}`;
if (stageState.backgroundSet && stageState.backgroundKey === backgroundKey)
return;
if (image) {
stageState.background.style.backgroundImage = `url("${resolveManifestAsset(manifest, image)}")`;
}
stageState.sceneLabel.textContent = label;
stageState.sceneLabel.hidden =
stageState.sceneLabel.textContent.length === 0;
stageState.backgroundSet = true;
stageState.backgroundKey = backgroundKey;
}
function resolveSpeakerAvatar(manifest, speaker, taggedAvatar) {
const actor =
manifest?.actors?.[speaker] ||
manifest?.actors?.[speaker?.toLowerCase?.()] ||
manifest?.speakers?.[speaker] ||
manifest?.speakers?.[speaker?.toLowerCase?.()];
if (actor?.avatar) return resolveManifestAsset(manifest, actor.avatar);
if (taggedAvatar && manifest?.avatars?.[taggedAvatar]) {
return resolveManifestAsset(manifest, manifest.avatars[taggedAvatar]);
}
if (
taggedAvatar &&
(taggedAvatar.startsWith("/") || /^(https?:)?\/\//.test(taggedAvatar))
) {
return taggedAvatar;
}
return "";
}
function renderSpeakerLine(stageState, manifest, text, metadata) {
const speaker = metadata.speaker || "Void";
const avatar = resolveSpeakerAvatar(manifest, speaker, metadata.avatar);
stageState.name.textContent = speaker;
stageState.line.textContent = text;
stageState.avatar.hidden = avatar.length === 0;
if (avatar) {
stageState.avatar.src = avatar;
stageState.avatar.alt = `${speaker} avatar`;
}
stageState.card.classList.remove("is-entering");
void stageState.card.offsetWidth;
stageState.card.classList.add("is-entering");
}
function appendParagraphs(story, transcript) {
let advanced = false;
let safety = 0;
while (story.canContinue && safety < 1000) {
const text = story.Continue().trim();
safety += 1;
if (text.length > 0) {
transcript.append(createElement("p", "sai-line", text));
advanced = true;
}
}
if (safety >= 1000) {
transcript.append(
createElement(
"p",
"sai-error",
"The story did not settle before the safety limit. Something in the Ink is looping.",
),
);
}
if (advanced) {
transcript.scrollTop = transcript.scrollHeight;
}
}
function renderChoices(story, choices, variables) {
choices.replaceChildren();
if (story.currentChoices.length === 0) {
choices.append(
createElement(
"p",
"sai-end",
"End of branch. The feed stops blinking.",
),
);
return;
}
story.currentChoices.forEach((choice, index) => {
const button = createElement("button", "sai-choice", choice.text);
button.type = "button";
button.addEventListener("click", () => {
story.ChooseChoiceIndex(index);
appendParagraphs(
story,
choices.closest(".sai-player").querySelector(".sai-transcript"),
);
renderVariables(story, variables);
renderChoices(story, choices, variables);
});
choices.append(button);
});
}
function renderCinematicStep(story, state) {
const { choices, variables, stageState, manifest, continueButton } = state;
choices.replaceChildren();
continueButton.hidden = true;
if (story.canContinue) {
const text = story.Continue().trim();
if (text.length > 0) {
setCinematicSlide(stageState, manifest, stageState.slideIndex + 1);
renderCinematicText(stageState, text);
stageState.stage.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
renderVariables(story, variables);
if (story.canContinue) {
continueButton.hidden = false;
continueButton.textContent = "Continue";
return;
}
}
renderVariables(story, variables);
if (story.currentChoices.length === 0) {
choices.append(
createElement(
"p",
"sai-end",
"End of branch. The cage pretends it was always empty.",
),
);
return;
}
story.currentChoices.forEach((choice, index) => {
const button = createElement("button", "sai-choice", choice.text);
button.type = "button";
button.addEventListener("click", () => {
stageState.sectionText.classList.add("is-exiting");
window.setTimeout(() => {
story.ChooseChoiceIndex(index);
stageState.sectionText.classList.remove("is-exiting");
renderCinematicStep(story, state);
}, 180);
});
choices.append(button);
});
}
function renderSpeakerStep(story, state) {
const {
choices,
variables,
stageState,
manifest,
continueButton,
container,
} = state;
choices.replaceChildren();
continueButton.hidden = true;
let text = "";
let metadata = {};
let safety = 0;
while (story.canContinue && text.length === 0 && safety < 100) {
text = story.Continue().trim();
metadata = parseTags(story.currentTags);
safety += 1;
}
if (safety >= 100) {
text =
"The story did not settle before the safety limit. Something in the Ink is looping.";
metadata = { speaker: "System" };
}
if (text.length > 0) {
setSpeakerBackground(stageState, manifest, container, metadata);
renderSpeakerLine(stageState, manifest, text, metadata);
renderVariables(story, variables);
if (story.canContinue) {
continueButton.hidden = false;
continueButton.textContent = "Continue";
return;
}
}
renderVariables(story, variables);
if (story.currentChoices.length === 0) {
choices.append(
createElement("p", "sai-end", "End of branch. The circle goes quiet."),
);
return;
}
story.currentChoices.forEach((choice, index) => {
const button = createElement("button", "sai-choice", choice.text);
button.type = "button";
button.addEventListener("click", () => {
stageState.card.classList.add("is-exiting");
window.setTimeout(() => {
story.ChooseChoiceIndex(index);
stageState.card.classList.remove("is-exiting");
renderSpeakerStep(story, state);
}, 140);
});
choices.append(button);
});
}
async function initialisePlayer(container) {
if (container.dataset.inkInitialised === "true") return;
container.dataset.inkInitialised = "true";
container.classList.add("sai-player");
const storySrc = container.dataset.inkStory;
if (!storySrc) {
container.textContent = "Ink player is missing data-ink-story.";
return;
}
const status = createElement("p", "sai-status", "Loading story...");
const inkMode = resolveInkMode(container);
const isCinematic = inkMode === "cinematic";
const isSpeakerPanel = inkMode === "speaker-panel";
const showVariables =
(!isCinematic && !isSpeakerPanel) ||
container.dataset.showVariables === "true";
const transcript =
isCinematic || isSpeakerPanel
? null
: createElement("div", "sai-transcript");
const stageState = isCinematic
? createCinematicStage()
: isSpeakerPanel
? createSpeakerStage()
: null;
const choices = createElement("div", "sai-choices");
const variables = createElement("div", "sai-variables");
const continueButton = createElement("button", "sai-continue", "Continue");
const restart = createElement("button", "sai-restart", "Restart");
continueButton.type = "button";
continueButton.hidden = true;
restart.type = "button";
if (isCinematic) {
container.classList.add("sai-player-cinematic");
variables.hidden = !showVariables;
container.replaceChildren(
status,
stageState.stage,
choices,
variables,
continueButton,
restart,
);
} else if (isSpeakerPanel) {
container.classList.add("sai-player-speaker");
variables.hidden = !showVariables;
stageState.controls.append(choices, continueButton, restart);
container.replaceChildren(status, stageState.stage, variables);
} else {
container.replaceChildren(
status,
transcript,
choices,
variables,
restart,
);
}
try {
await ensureInkRuntime(container);
const visualManifest = await loadVisualManifest(container);
const response = await fetch(storySrc);
if (!response.ok) {
throw new Error(`could not fetch ${storySrc}: ${response.status}`);
}
const storyJson = await response.json();
function reset() {
const story = new window.inkjs.Story(JSON.stringify(storyJson));
status.textContent = container.dataset.inkTitle || "Interactive story";
choices.replaceChildren();
variables.replaceChildren();
if (isCinematic) {
stageState.slideIndex = -1;
stageState.caption.textContent = "";
stageState.references.replaceChildren();
stageState.references.hidden = true;
stageState.sectionText.replaceChildren();
continueButton.onclick = () => {
stageState.sectionText.classList.add("is-exiting");
window.setTimeout(() => {
stageState.sectionText.classList.remove("is-exiting");
renderCinematicStep(story, {
choices,
variables,
stageState,
manifest: visualManifest,
continueButton,
});
}, 180);
};
renderCinematicStep(story, {
choices,
variables,
stageState,
manifest: visualManifest,
continueButton,
});
} else if (isSpeakerPanel) {
stageState.backgroundSet = false;
stageState.background.style.backgroundImage = "";
stageState.sceneLabel.textContent = "";
stageState.card.classList.remove("is-entering", "is-exiting");
stageState.avatar.removeAttribute("src");
stageState.avatar.hidden = true;
stageState.name.textContent = "";
stageState.line.textContent = "";
continueButton.onclick = () => {
stageState.card.classList.add("is-exiting");
window.setTimeout(() => {
stageState.card.classList.remove("is-exiting");
renderSpeakerStep(story, {
choices,
variables,
stageState,
manifest: visualManifest,
continueButton,
container,
});
}, 140);
};
renderSpeakerStep(story, {
choices,
variables,
stageState,
manifest: visualManifest,
continueButton,
container,
});
} else {
transcript.replaceChildren();
appendParagraphs(story, transcript);
renderVariables(story, variables);
renderChoices(story, choices, variables);
}
}
restart.addEventListener("click", reset);
reset();
} catch (error) {
status.textContent = "Ink player failed to load.";
const errorTarget = transcript || (stageState && stageState.sectionText);
errorTarget.append(
createElement("p", "sai-error", error.message || String(error)),
);
}
}
function initialiseAllPlayers() {
document
.querySelectorAll(".sai-player, .aetheria-ink-player")
.forEach(initialisePlayer);
}
window.Sai = {
...(window.Sai || {}),
init: initialisePlayer,
initAll: initialiseAllPlayers,
version,
};
document.addEventListener("nav", initialiseAllPlayers);
if (document.readyState !== "loading") initialiseAllPlayers();
})();