diff --git a/README.md b/README.md index 388f378..2fe61e9 100644 --- a/README.md +++ b/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 diff --git a/dist/sai.css b/dist/sai.css index 9ce8816..a7b55cb 100644 --- a/dist/sai.css +++ b/dist/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)); + } +} diff --git a/dist/sai.js b/dist/sai.js index bb3fe81..61216a2 100644 --- a/dist/sai.js +++ b/dist/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; diff --git a/docs/manifest.md b/docs/manifest.md index 7c121a0..9a842e0 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -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. diff --git a/examples/static-site/story.ink b/examples/static-site/story.ink index 5cd943e..0445e33 100644 --- a/examples/static-site/story.ink +++ b/examples/static-site/story.ink @@ -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 diff --git a/examples/static-site/story.ink.json b/examples/static-site/story.ink.json index d983c70..d290333 100644 --- a/examples/static-site/story.ink.json +++ b/examples/static-site/story.ink.json @@ -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":{}} \ No newline at end of file +{"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":{}} \ No newline at end of file diff --git a/examples/static-site/visual-manifest.json b/examples/static-site/visual-manifest.json index 19c4f80..a816c32 100644 --- a/examples/static-site/visual-manifest.json +++ b/examples/static-site/visual-manifest.json @@ -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 + } + } } } } diff --git a/src/sai.css b/src/sai.css index 9ce8816..a7b55cb 100644 --- a/src/sai.css +++ b/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)); + } +} diff --git a/src/sai.js b/src/sai.js index bb3fe81..61216a2 100644 --- a/src/sai.js +++ b/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;