Add visual novel sprite staging

This commit is contained in:
Meta Vens 2026-05-31 00:41:14 +01:00
commit 9001ef6ab2
9 changed files with 727 additions and 19 deletions

View file

@ -77,17 +77,24 @@ and no stage machinery.
```
Visual-novel mode renders one scene image, a fixed dialogue bar, speaker avatar,
speaker name, line text, optional DOM cards, and a right-side option rail. Ink
tags drive the speaker, scene, and staged page elements:
speaker name, line text, optional character sprites, optional DOM cards, and a
right-side option rail. Ink tags drive the speaker, scene, staged sprites, and
staged page elements:
```ink
# speaker: Void
# scene: agora
# avatar: void
# sprite: welcome@left
# sprites: Void.welcome@left, Aqua.listen@right
# dom: roadmap, receipts
The world is full of systems that say one thing and reward another.
```
If a speaker has `sprites` in the visual manifest, Sai shows the speaker's
default sprite automatically. Use `# sprite: none` for lines that should stage
only background, DOM cards, and dialogue.
The `dom` tag reads card definitions from the visual manifest and clones
matching page DOM into the scene stage. This is for static-site tours where the
story frames canonical Quartz/HTML content without making the story file a

149
dist/sai.css vendored
View file

@ -298,10 +298,77 @@
display: none;
}
.sai-sprite-layer {
position: absolute;
inset: 0 0 128px;
z-index: 1;
pointer-events: none;
}
.sai-sprite-layer[hidden] {
display: none;
}
.sai-sprite-layer.is-entering .sai-sprite {
animation: saiSpriteIn 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.sai-sprite {
position: absolute;
bottom: clamp(0.3rem, 2vw, 1.4rem);
left: 50%;
display: grid;
width: min(34vw, 18rem);
max-width: 42%;
min-width: 9rem;
margin: 0;
transform: translateX(-50%)
translate(
var(--sai-sprite-offset-x, 0),
var(--sai-sprite-offset-y, 0)
)
scale(var(--sai-sprite-scale, 1));
transform-origin: bottom center;
filter: drop-shadow(0 1.2rem 1.8rem rgba(0, 0, 0, 0.55));
}
.sai-sprite[data-sai-position="left"] {
left: 22%;
}
.sai-sprite[data-sai-position="midleft"],
.sai-sprite[data-sai-position="centerleft"] {
left: 34%;
}
.sai-sprite[data-sai-position="right"] {
left: 78%;
}
.sai-sprite[data-sai-position="midright"],
.sai-sprite[data-sai-position="centerright"] {
left: 66%;
}
.sai-sprite[data-sai-position="farleft"] {
left: 12%;
}
.sai-sprite[data-sai-position="farright"] {
left: 88%;
}
.sai-sprite-image {
display: block;
width: 100%;
height: auto;
object-fit: contain;
}
.sai-dom-layer {
position: absolute;
inset: 3.6rem 1rem 9rem;
z-index: 1;
z-index: 2;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: 0.9rem;
@ -397,7 +464,7 @@
right: 0;
bottom: 0;
left: 0;
z-index: 1;
z-index: 3;
display: grid;
grid-template-columns: 128px minmax(0, 1fr);
align-items: stretch;
@ -550,6 +617,26 @@
grid-template-columns: minmax(0, 1fr);
}
.sai-sprite-layer {
bottom: 13rem;
}
.sai-sprite {
width: min(42vw, 12rem);
min-width: 7.5rem;
max-width: 48%;
}
.sai-sprite[data-sai-position="left"],
.sai-sprite[data-sai-position="farleft"] {
left: 25%;
}
.sai-sprite[data-sai-position="right"],
.sai-sprite[data-sai-position="farright"] {
left: 75%;
}
.sai-speaker-card {
grid-template-columns: 96px minmax(0, 1fr);
height: auto;
@ -626,3 +713,61 @@
.sai-variables[hidden] {
display: none;
}
@keyframes saiTextIn {
from {
opacity: 0;
transform: translateY(0.75rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes saiTextOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(0.45rem);
}
}
@keyframes saiSpeakerIn {
from {
opacity: 0;
transform: translateY(0.7rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes saiSpriteIn {
from {
opacity: 0;
transform: translateX(-50%)
translate(
var(--sai-sprite-offset-x, 0),
calc(var(--sai-sprite-offset-y, 0) + 1.2rem)
)
scale(calc(var(--sai-sprite-scale, 1) * 0.98));
}
to {
opacity: 1;
transform: translateX(-50%)
translate(
var(--sai-sprite-offset-x, 0),
var(--sai-sprite-offset-y, 0)
)
scale(var(--sai-sprite-scale, 1));
}
}

178
dist/sai.js vendored
View file

@ -287,6 +287,7 @@
const background = createElement("div", "sai-speaker-background");
const scrim = createElement("div", "sai-speaker-scrim");
const sceneLabel = createElement("p", "sai-speaker-scene");
const spriteLayer = createElement("div", "sai-sprite-layer");
const domLayer = createElement("div", "sai-dom-layer");
const card = createElement("div", "sai-speaker-card");
const avatarShell = createElement("div", "sai-speaker-avatar-shell");
@ -303,13 +304,15 @@
avatarShell.append(avatar);
body.append(name, line, controls);
card.append(avatarShell, body);
spriteLayer.hidden = true;
domLayer.hidden = true;
stage.append(background, scrim, sceneLabel, domLayer, card);
stage.append(background, scrim, spriteLayer, sceneLabel, domLayer, card);
return {
stage,
background,
sceneLabel,
spriteLayer,
domLayer,
card,
avatar,
@ -321,6 +324,174 @@
};
}
function resolveActor(manifest, actorName) {
if (!actorName) return null;
return (
manifest?.actors?.[actorName] ||
manifest?.actors?.[actorName.toLowerCase?.()] ||
manifest?.speakers?.[actorName] ||
manifest?.speakers?.[actorName.toLowerCase?.()] ||
null
);
}
function resolveActorKey(manifest, actorName) {
if (!actorName) return "";
const actorMaps = [manifest?.actors || {}, manifest?.speakers || {}];
for (const actors of actorMaps) {
const key = Object.keys(actors).find(
(candidate) => candidate.toLowerCase() === actorName.toLowerCase(),
);
if (key) return key;
}
return actorName;
}
function parseSpriteReference(manifest, rawReference, fallbackSpeaker) {
const raw = (rawReference || "").trim();
if (!raw) return null;
const withoutHash = raw.startsWith("#") ? raw.slice(1).trim() : raw;
if (["none", "clear", "off", "hide"].includes(withoutHash.toLowerCase())) {
return { hidden: true };
}
const [identityPart, positionPart] = withoutHash.split("@", 2);
const [subjectPart, colonPosition] = identityPart.split(":", 2);
const position = (positionPart || colonPosition || "").trim();
const actorNames = Object.keys({
...(manifest?.actors || {}),
...(manifest?.speakers || {}),
}).sort((a, b) => b.length - a.length);
const actorPrefix = actorNames.find((name) =>
subjectPart.toLowerCase().startsWith(`${name.toLowerCase()}.`),
);
let actor = fallbackSpeaker;
let expression = subjectPart.trim();
if (actorPrefix) {
actor = actorPrefix;
expression = subjectPart.slice(actorPrefix.length + 1).trim();
} else {
const separator = subjectPart.indexOf(".");
if (separator !== -1) {
actor = subjectPart.slice(0, separator).trim();
expression = subjectPart.slice(separator + 1).trim();
}
}
return {
actor: resolveActorKey(manifest, actor || fallbackSpeaker),
expression: expression || "default",
position: position || "",
};
}
function resolveSpriteEntry(actor, expression) {
if (!actor) return null;
const sprites = actor.sprites || {};
const key = expression || actor.default_sprite || actor.defaultSprite || "idle";
return (
sprites[key] ||
sprites[key?.toLowerCase?.()] ||
sprites.default ||
sprites.idle ||
actor.sprite ||
actor.default_sprite ||
actor.defaultSprite ||
null
);
}
function normaliseSpriteEntry(entry) {
if (!entry) return null;
if (typeof entry === "string") {
return { image: entry };
}
if (entry.image || entry.src) {
return { ...entry, image: entry.image || entry.src };
}
return null;
}
function resolveSpriteRequests(manifest, metadata) {
const speaker = metadata.speaker || "Void";
const rawSprites = metadata.sprites || metadata.characters || "";
const rawSprite = metadata.sprite || "";
const explicit = rawSprites || rawSprite;
const parsed = explicit
? explicit
.split(",")
.map((token) => parseSpriteReference(manifest, token, speaker))
.filter(Boolean)
: [parseSpriteReference(manifest, "default", speaker)];
if (parsed.some((request) => request.hidden)) return [];
return parsed
.map((request, index) => {
const actorName = request.actor || speaker;
const actor = resolveActor(manifest, actorName);
const entry = normaliseSpriteEntry(
resolveSpriteEntry(actor, request.expression),
);
if (!entry?.image) return null;
return {
actorName,
expression: request.expression,
image: resolveManifestAsset(manifest, entry.image),
alt:
entry.alt ||
`${actor?.name || actorName} ${request.expression || "sprite"}`,
position:
request.position ||
entry.position ||
actor?.position ||
(parsed.length === 1 ? "center" : index === 0 ? "left" : "right"),
scale: entry.scale || actor?.sprite_scale || actor?.spriteScale || 1,
offsetX: entry.offset_x || entry.offsetX || 0,
offsetY: entry.offset_y || entry.offsetY || 0,
};
})
.filter(Boolean);
}
function renderSpeakerSprites(stageState, manifest, metadata) {
if (!stageState.spriteLayer) return;
const sprites = resolveSpriteRequests(manifest, metadata);
stageState.spriteLayer.replaceChildren();
stageState.spriteLayer.hidden = sprites.length === 0;
stageState.stage.classList.toggle("has-sprites", sprites.length > 0);
for (const sprite of sprites) {
const figure = createElement("figure", "sai-sprite");
const image = document.createElement("img");
const position = sprite.position.toLowerCase().replace(/[^a-z0-9_-]/g, "");
figure.dataset.saiActor = sprite.actorName;
figure.dataset.saiExpression = sprite.expression || "default";
figure.dataset.saiPosition = position || "center";
figure.style.setProperty("--sai-sprite-scale", String(sprite.scale));
figure.style.setProperty("--sai-sprite-offset-x", `${sprite.offsetX}px`);
figure.style.setProperty("--sai-sprite-offset-y", `${sprite.offsetY}px`);
image.className = "sai-sprite-image";
image.src = sprite.image;
image.alt = sprite.alt;
image.loading = "lazy";
image.decoding = "async";
figure.append(image);
stageState.spriteLayer.append(figure);
}
stageState.spriteLayer.classList.remove("is-entering");
void stageState.spriteLayer.offsetWidth;
stageState.spriteLayer.classList.add("is-entering");
}
function setSpeakerBackground(stageState, manifest, container, metadata) {
const background = manifest?.background || {};
const sceneKey =
@ -605,6 +776,7 @@
if (text.length > 0) {
setSpeakerBackground(stageState, manifest, container, metadata);
renderSpeakerSprites(stageState, manifest, metadata);
renderDomCards(stageState, manifest, metadata);
renderSpeakerLine(stageState, manifest, text, metadata);
renderVariables(story, variables);
@ -747,9 +919,11 @@
stageState.backgroundSet = false;
stageState.background.style.backgroundImage = "";
stageState.sceneLabel.textContent = "";
stageState.spriteLayer.replaceChildren();
stageState.spriteLayer.hidden = true;
stageState.domLayer.replaceChildren();
stageState.domLayer.hidden = true;
stageState.stage.classList.remove("has-dom-cards");
stageState.stage.classList.remove("has-dom-cards", "has-sprites");
stageState.card.classList.remove("is-entering", "is-exiting");
stageState.avatar.removeAttribute("src");
stageState.avatar.hidden = true;

View file

@ -21,11 +21,24 @@ older GameCult files already used both.
"speakers": {
"Void": {
"name": "Void",
"avatar": "void.png"
"avatar": "void.png",
"default_sprite": "idle",
"sprites": {
"idle": "sprites/void-idle.png",
"welcome": {
"image": "sprites/void-welcome.png",
"position": "left",
"scale": 1.08
}
}
},
"Aqua": {
"name": "Aqua",
"avatar": "aqua.png"
"avatar": "aqua.png",
"sprites": {
"idle": "sprites/aqua-idle.png",
"listen": "sprites/aqua-listen.png"
}
}
},
"dom_cards": {
@ -47,12 +60,22 @@ Useful Ink tags:
# scene: agora
# speaker: Void
# avatar: void
# sprite: welcome@left
# sprites: Void.welcome@left, Aqua.listen@right
# dom: roadmap, receipts
```
Sai resolves assets relative to `asset_base` unless the asset is already an
absolute URL or root-relative path.
Sprite tags are optional. If no sprite tag is present, Sai tries to show the
current speaker's `default_sprite`, then `idle`, then `default`. Use
`# sprite: none` to hide sprites for a line. Use `# sprite:` for the current
speaker and `# sprites:` for a comma-separated stage composition. Sprite
references accept `expression@position`, `Actor.expression@position`, or
`Actor.expression:position`. Common positions are `farleft`, `left`,
`midleft`, `center`, `midright`, `right`, and `farright`.
The optional `dom_cards` map lets a visual-novel line stage existing page DOM
inside the scene. Each card points at a selector in the rendered document; Sai
clones that element into the stage, strips duplicate `id` attributes from the
@ -101,6 +124,5 @@ 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.
This manifest is intentionally small. Sprite staging extends the scene/speaker
model; it is not a separate VN engine loop.

View file

@ -2,19 +2,23 @@ VAR noticed_surveillance = false
# scene: agora
# speaker: Void
# sprite: welcome@left
Ink is not alive by itself. A page gives it a body.
* [Let the page speak plainly.]
~ noticed_surveillance = true
# speaker: Aqua
# sprite: listen@right
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
# sprite: sharp@right
A little theatre is allowed. It just has to serve the line instead of eating it.
-> gather
=== gather ===
# speaker: Void
# sprites: Void.idle@left, Aqua.idle@right
Good. The brush moves. The scene answers.
-> END

View file

@ -1 +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":{}}
{"inkVersion":21,"root":[["#","^scene: agora","/#","#","^speaker: Void","/#","#","^sprite: welcome@left","/#","^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","/#","#","^sprite: listen@right","/#","^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","/#","#","^sprite: sharp@right","/#","^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","/#","#","^sprites: Void.idle@left, Aqua.idle@right","/#","^Good. The brush moves. The scene answers.","\n","end",null],"global decl":["ev",false,{"VAR=":"noticed_surveillance"},"/ev","end",null]}],"listDefs":{}}

View file

@ -10,15 +10,52 @@
"speakers": {
"Void": {
"name": "Void",
"avatar": "void.svg"
"avatar": "void.svg",
"default_sprite": "idle",
"sprites": {
"idle": {
"image": "void.svg",
"position": "left",
"scale": 1.1
},
"welcome": {
"image": "void.svg",
"position": "left",
"scale": 1.15
}
}
},
"Aqua": {
"name": "Aqua",
"avatar": "aqua.svg"
"avatar": "aqua.svg",
"default_sprite": "idle",
"sprites": {
"idle": {
"image": "aqua.svg",
"position": "right"
},
"listen": {
"image": "aqua.svg",
"position": "right",
"scale": 1.08
}
}
},
"Nibu": {
"name": "Nibu",
"avatar": "nibu.svg"
"avatar": "nibu.svg",
"default_sprite": "idle",
"sprites": {
"idle": {
"image": "nibu.svg",
"position": "right"
},
"sharp": {
"image": "nibu.svg",
"position": "right",
"scale": 1.08
}
}
}
}
}

View file

@ -298,10 +298,77 @@
display: none;
}
.sai-sprite-layer {
position: absolute;
inset: 0 0 128px;
z-index: 1;
pointer-events: none;
}
.sai-sprite-layer[hidden] {
display: none;
}
.sai-sprite-layer.is-entering .sai-sprite {
animation: saiSpriteIn 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.sai-sprite {
position: absolute;
bottom: clamp(0.3rem, 2vw, 1.4rem);
left: 50%;
display: grid;
width: min(34vw, 18rem);
max-width: 42%;
min-width: 9rem;
margin: 0;
transform: translateX(-50%)
translate(
var(--sai-sprite-offset-x, 0),
var(--sai-sprite-offset-y, 0)
)
scale(var(--sai-sprite-scale, 1));
transform-origin: bottom center;
filter: drop-shadow(0 1.2rem 1.8rem rgba(0, 0, 0, 0.55));
}
.sai-sprite[data-sai-position="left"] {
left: 22%;
}
.sai-sprite[data-sai-position="midleft"],
.sai-sprite[data-sai-position="centerleft"] {
left: 34%;
}
.sai-sprite[data-sai-position="right"] {
left: 78%;
}
.sai-sprite[data-sai-position="midright"],
.sai-sprite[data-sai-position="centerright"] {
left: 66%;
}
.sai-sprite[data-sai-position="farleft"] {
left: 12%;
}
.sai-sprite[data-sai-position="farright"] {
left: 88%;
}
.sai-sprite-image {
display: block;
width: 100%;
height: auto;
object-fit: contain;
}
.sai-dom-layer {
position: absolute;
inset: 3.6rem 1rem 9rem;
z-index: 1;
z-index: 2;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: 0.9rem;
@ -397,7 +464,7 @@
right: 0;
bottom: 0;
left: 0;
z-index: 1;
z-index: 3;
display: grid;
grid-template-columns: 128px minmax(0, 1fr);
align-items: stretch;
@ -550,6 +617,26 @@
grid-template-columns: minmax(0, 1fr);
}
.sai-sprite-layer {
bottom: 13rem;
}
.sai-sprite {
width: min(42vw, 12rem);
min-width: 7.5rem;
max-width: 48%;
}
.sai-sprite[data-sai-position="left"],
.sai-sprite[data-sai-position="farleft"] {
left: 25%;
}
.sai-sprite[data-sai-position="right"],
.sai-sprite[data-sai-position="farright"] {
left: 75%;
}
.sai-speaker-card {
grid-template-columns: 96px minmax(0, 1fr);
height: auto;
@ -626,3 +713,61 @@
.sai-variables[hidden] {
display: none;
}
@keyframes saiTextIn {
from {
opacity: 0;
transform: translateY(0.75rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes saiTextOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(0.45rem);
}
}
@keyframes saiSpeakerIn {
from {
opacity: 0;
transform: translateY(0.7rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes saiSpriteIn {
from {
opacity: 0;
transform: translateX(-50%)
translate(
var(--sai-sprite-offset-x, 0),
calc(var(--sai-sprite-offset-y, 0) + 1.2rem)
)
scale(calc(var(--sai-sprite-scale, 1) * 0.98));
}
to {
opacity: 1;
transform: translateX(-50%)
translate(
var(--sai-sprite-offset-x, 0),
var(--sai-sprite-offset-y, 0)
)
scale(var(--sai-sprite-scale, 1));
}
}

View file

@ -287,6 +287,7 @@
const background = createElement("div", "sai-speaker-background");
const scrim = createElement("div", "sai-speaker-scrim");
const sceneLabel = createElement("p", "sai-speaker-scene");
const spriteLayer = createElement("div", "sai-sprite-layer");
const domLayer = createElement("div", "sai-dom-layer");
const card = createElement("div", "sai-speaker-card");
const avatarShell = createElement("div", "sai-speaker-avatar-shell");
@ -303,13 +304,15 @@
avatarShell.append(avatar);
body.append(name, line, controls);
card.append(avatarShell, body);
spriteLayer.hidden = true;
domLayer.hidden = true;
stage.append(background, scrim, sceneLabel, domLayer, card);
stage.append(background, scrim, spriteLayer, sceneLabel, domLayer, card);
return {
stage,
background,
sceneLabel,
spriteLayer,
domLayer,
card,
avatar,
@ -321,6 +324,174 @@
};
}
function resolveActor(manifest, actorName) {
if (!actorName) return null;
return (
manifest?.actors?.[actorName] ||
manifest?.actors?.[actorName.toLowerCase?.()] ||
manifest?.speakers?.[actorName] ||
manifest?.speakers?.[actorName.toLowerCase?.()] ||
null
);
}
function resolveActorKey(manifest, actorName) {
if (!actorName) return "";
const actorMaps = [manifest?.actors || {}, manifest?.speakers || {}];
for (const actors of actorMaps) {
const key = Object.keys(actors).find(
(candidate) => candidate.toLowerCase() === actorName.toLowerCase(),
);
if (key) return key;
}
return actorName;
}
function parseSpriteReference(manifest, rawReference, fallbackSpeaker) {
const raw = (rawReference || "").trim();
if (!raw) return null;
const withoutHash = raw.startsWith("#") ? raw.slice(1).trim() : raw;
if (["none", "clear", "off", "hide"].includes(withoutHash.toLowerCase())) {
return { hidden: true };
}
const [identityPart, positionPart] = withoutHash.split("@", 2);
const [subjectPart, colonPosition] = identityPart.split(":", 2);
const position = (positionPart || colonPosition || "").trim();
const actorNames = Object.keys({
...(manifest?.actors || {}),
...(manifest?.speakers || {}),
}).sort((a, b) => b.length - a.length);
const actorPrefix = actorNames.find((name) =>
subjectPart.toLowerCase().startsWith(`${name.toLowerCase()}.`),
);
let actor = fallbackSpeaker;
let expression = subjectPart.trim();
if (actorPrefix) {
actor = actorPrefix;
expression = subjectPart.slice(actorPrefix.length + 1).trim();
} else {
const separator = subjectPart.indexOf(".");
if (separator !== -1) {
actor = subjectPart.slice(0, separator).trim();
expression = subjectPart.slice(separator + 1).trim();
}
}
return {
actor: resolveActorKey(manifest, actor || fallbackSpeaker),
expression: expression || "default",
position: position || "",
};
}
function resolveSpriteEntry(actor, expression) {
if (!actor) return null;
const sprites = actor.sprites || {};
const key = expression || actor.default_sprite || actor.defaultSprite || "idle";
return (
sprites[key] ||
sprites[key?.toLowerCase?.()] ||
sprites.default ||
sprites.idle ||
actor.sprite ||
actor.default_sprite ||
actor.defaultSprite ||
null
);
}
function normaliseSpriteEntry(entry) {
if (!entry) return null;
if (typeof entry === "string") {
return { image: entry };
}
if (entry.image || entry.src) {
return { ...entry, image: entry.image || entry.src };
}
return null;
}
function resolveSpriteRequests(manifest, metadata) {
const speaker = metadata.speaker || "Void";
const rawSprites = metadata.sprites || metadata.characters || "";
const rawSprite = metadata.sprite || "";
const explicit = rawSprites || rawSprite;
const parsed = explicit
? explicit
.split(",")
.map((token) => parseSpriteReference(manifest, token, speaker))
.filter(Boolean)
: [parseSpriteReference(manifest, "default", speaker)];
if (parsed.some((request) => request.hidden)) return [];
return parsed
.map((request, index) => {
const actorName = request.actor || speaker;
const actor = resolveActor(manifest, actorName);
const entry = normaliseSpriteEntry(
resolveSpriteEntry(actor, request.expression),
);
if (!entry?.image) return null;
return {
actorName,
expression: request.expression,
image: resolveManifestAsset(manifest, entry.image),
alt:
entry.alt ||
`${actor?.name || actorName} ${request.expression || "sprite"}`,
position:
request.position ||
entry.position ||
actor?.position ||
(parsed.length === 1 ? "center" : index === 0 ? "left" : "right"),
scale: entry.scale || actor?.sprite_scale || actor?.spriteScale || 1,
offsetX: entry.offset_x || entry.offsetX || 0,
offsetY: entry.offset_y || entry.offsetY || 0,
};
})
.filter(Boolean);
}
function renderSpeakerSprites(stageState, manifest, metadata) {
if (!stageState.spriteLayer) return;
const sprites = resolveSpriteRequests(manifest, metadata);
stageState.spriteLayer.replaceChildren();
stageState.spriteLayer.hidden = sprites.length === 0;
stageState.stage.classList.toggle("has-sprites", sprites.length > 0);
for (const sprite of sprites) {
const figure = createElement("figure", "sai-sprite");
const image = document.createElement("img");
const position = sprite.position.toLowerCase().replace(/[^a-z0-9_-]/g, "");
figure.dataset.saiActor = sprite.actorName;
figure.dataset.saiExpression = sprite.expression || "default";
figure.dataset.saiPosition = position || "center";
figure.style.setProperty("--sai-sprite-scale", String(sprite.scale));
figure.style.setProperty("--sai-sprite-offset-x", `${sprite.offsetX}px`);
figure.style.setProperty("--sai-sprite-offset-y", `${sprite.offsetY}px`);
image.className = "sai-sprite-image";
image.src = sprite.image;
image.alt = sprite.alt;
image.loading = "lazy";
image.decoding = "async";
figure.append(image);
stageState.spriteLayer.append(figure);
}
stageState.spriteLayer.classList.remove("is-entering");
void stageState.spriteLayer.offsetWidth;
stageState.spriteLayer.classList.add("is-entering");
}
function setSpeakerBackground(stageState, manifest, container, metadata) {
const background = manifest?.background || {};
const sceneKey =
@ -605,6 +776,7 @@
if (text.length > 0) {
setSpeakerBackground(stageState, manifest, container, metadata);
renderSpeakerSprites(stageState, manifest, metadata);
renderDomCards(stageState, manifest, metadata);
renderSpeakerLine(stageState, manifest, text, metadata);
renderVariables(story, variables);
@ -747,9 +919,11 @@
stageState.backgroundSet = false;
stageState.background.style.backgroundImage = "";
stageState.sceneLabel.textContent = "";
stageState.spriteLayer.replaceChildren();
stageState.spriteLayer.hidden = true;
stageState.domLayer.replaceChildren();
stageState.domLayer.hidden = true;
stageState.stage.classList.remove("has-dom-cards");
stageState.stage.classList.remove("has-dom-cards", "has-sprites");
stageState.card.classList.remove("is-entering", "is-exiting");
stageState.avatar.removeAttribute("src");
stageState.avatar.hidden = true;