Extract Sai Ink embedder
This commit is contained in:
commit
20d01a8ff7
23 changed files with 13537 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
dist/*.map
|
||||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
examples/**/*.ink
|
||||||
|
dist/ink.js
|
||||||
|
src/ink.js
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
169
README.md
Normal 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
5237
dist/ink.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
521
dist/sai.css
vendored
Normal file
521
dist/sai.css
vendored
Normal 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
741
dist/sai.js
vendored
Normal 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
89
docs/manifest.md
Normal 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.
|
||||||
8
examples/static-site/aqua.svg
Normal file
8
examples/static-site/aqua.svg
Normal 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 |
36
examples/static-site/index.html
Normal file
36
examples/static-site/index.html
Normal 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>
|
||||||
9
examples/static-site/nibu.svg
Normal file
9
examples/static-site/nibu.svg
Normal 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 |
18
examples/static-site/scene.svg
Normal file
18
examples/static-site/scene.svg
Normal 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 |
20
examples/static-site/story.ink
Normal file
20
examples/static-site/story.ink
Normal 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
|
||||||
1
examples/static-site/story.ink.json
Normal file
1
examples/static-site/story.ink.json
Normal 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":{}}
|
||||||
24
examples/static-site/visual-manifest.json
Normal file
24
examples/static-site/visual-manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
examples/static-site/void.svg
Normal file
8
examples/static-site/void.svg
Normal 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
43
package-lock.json
generated
Normal 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
39
package.json
Normal 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
16
scripts/build.mjs
Normal 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
31
scripts/check.mjs
Normal 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
5237
src/ink.js
Normal file
File diff suppressed because it is too large
Load diff
521
src/sai.css
Normal file
521
src/sai.css
Normal 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
741
src/sai.js
Normal 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();
|
||||||
|
})();
|
||||||
Loading…
Reference in a new issue