The caption wears the actor's face

Every reel caption now carries the acting wizard's standee at its left
— the same token art the board plays with, framed in the caption's own
ink — and the burned-in video caption bar gets the portrait too, so a
shared clip names its actors by face as well as by name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 21:07:45 -04:00
co-authored by Claude Fable 5
parent f5151a0c99
commit 252d10711d
+44 -3
View File
@@ -3,6 +3,7 @@
import FirstPerson from "./fpv/FirstPerson.svelte";
import { untrack } from "svelte";
import { humanize } from "./net.svelte";
import { tokenArt } from "./art";
import { scheduleFx, type BoardFx } from "./fx";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
import { castRay, edgeMid } from "./fpv/raycast";
@@ -91,6 +92,12 @@
);
const atEnd = $derived(idx >= steps.length - 1);
/** The acting wizard's standee, shown beside their words. */
const actorArt = $derived.by(() => {
const p = step.view.players.find((x) => x.id === step.actor);
return p ? tokenArt(`wizard-${p.colorIndex}`, "players") : null;
});
// The reel draws the same sight line the live table shows for an LOS
// attack in progress, so a replay-watcher can see how a spell reached them.
const sightTrace = $derived(stackSightTrace(step.view));
@@ -517,22 +524,41 @@
comp.width = 1280;
comp.height = 720;
const g = comp.getContext("2d")!;
const faces = new Map<string, HTMLImageElement>();
const faceFor = (src: string): HTMLImageElement | null => {
let img = faces.get(src);
if (!img) {
img = new Image();
img.src = src;
faces.set(src, img);
}
return img.complete && img.naturalWidth > 0 ? img : null;
};
let compRaf = 0;
const drawComp = () => {
g.imageSmoothingEnabled = false;
g.drawImage(cv, 0, 0, 1280, 720);
g.imageSmoothingEnabled = true;
// The caption bar: who did what, in the reel's own words.
// The caption bar: who did what, in the reel's own words — their
// standee at the left.
g.fillStyle = "rgba(12, 10, 8, 0.78)";
g.fillRect(0, 720 - 96, 1280, 96);
const face = actorArt ? faceFor(actorArt) : null;
const textX = face ? 110 : 28;
if (face) {
g.drawImage(face, 24, 720 - 96 + 14, 68, 68);
g.strokeStyle = "#43331f";
g.lineWidth = 2;
g.strokeRect(24, 720 - 96 + 14, 68, 68);
}
g.fillStyle = "#e0b34a";
g.font = "600 22px Oswald, sans-serif";
g.fillText(step.actor.toUpperCase(), 28, 720 - 60, 400);
g.fillText(step.actor.toUpperCase(), textX, 720 - 60, 400);
g.fillStyle = "#efe8d4";
g.font = "21px 'Courier Prime', monospace";
const said = lines.slice(0, 2);
if (said.length === 0) said.push("…considers the maze.");
said.forEach((line, i) => g.fillText(line, 28, 720 - 32 + i * 26, 1224));
said.forEach((line, i) => g.fillText(line, textX, 720 - 32 + i * 26, 1224 - (textX - 28)));
// The colophon corner.
g.fillStyle = "rgba(224, 179, 74, 0.6)";
g.font = "600 20px Oswald, sans-serif";
@@ -611,6 +637,8 @@
{/if}
</div>
<div class="replay-caption">
{#if actorArt}<img class="caption-face" src={actorArt} alt={step.actor} />{/if}
<div class="caption-lines">
<strong>{step.actor}</strong>
{#each lines as line, i (i)}<div>{line}</div>{/each}
{#if lines.length === 0 && !step.chat?.length}<div>…considers the maze.</div>{/if}
@@ -618,6 +646,7 @@
<div class="reel-talk">💬 {c.player}: {c.text}</div>
{/each}
</div>
</div>
<div class="replay-controls">
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move"></button>
<button class="playpause" onclick={() => (playing = !playing)} aria-label={playing ? "pause" : "play"}>
@@ -707,7 +736,19 @@
font-size: 0.78rem;
line-height: 1.45;
min-height: 3.4rem;
display: flex;
gap: 0.6rem;
align-items: flex-start;
}
.caption-face {
width: 44px;
height: 44px;
object-fit: cover;
border-radius: 3px;
border: 1.5px solid #43331f;
flex: none;
}
.caption-lines { min-width: 0; }
.replay-controls .speed {
font-size: 0.75rem;
opacity: 0.7;