diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index c0234f0..3e08af7 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -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(); + 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,12 +637,15 @@ {/if}
- {step.actor} - {#each lines as line, i (i)}
{line}
{/each} - {#if lines.length === 0 && !step.chat?.length}
…considers the maze.
{/if} - {#each step.chat ?? [] as c, i (i)} -
💬 {c.player}: {c.text}
- {/each} + {#if actorArt}{step.actor}{/if} +
+ {step.actor} + {#each lines as line, i (i)}
{line}
{/each} + {#if lines.length === 0 && !step.chat?.length}
…considers the maze.
{/if} + {#each step.chat ?? [] as c, i (i)} +
💬 {c.player}: {c.text}
+ {/each} +
@@ -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;