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