Add visual novel sprite staging
This commit is contained in:
parent
364fc80f44
commit
9001ef6ab2
9 changed files with 727 additions and 19 deletions
11
README.md
11
README.md
|
|
@ -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
149
dist/sai.css
vendored
|
|
@ -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
178
dist/sai.js
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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":{}}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
149
src/sai.css
149
src/sai.css
|
|
@ -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
src/sai.js
178
src/sai.js
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue