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:
co-authored by
Claude Fable 5
parent
f5151a0c99
commit
252d10711d
@@ -3,6 +3,7 @@
|
|||||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { humanize } from "./net.svelte";
|
import { humanize } from "./net.svelte";
|
||||||
|
import { tokenArt } from "./art";
|
||||||
import { scheduleFx, type BoardFx } from "./fx";
|
import { scheduleFx, type BoardFx } from "./fx";
|
||||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||||
import { castRay, edgeMid } from "./fpv/raycast";
|
import { castRay, edgeMid } from "./fpv/raycast";
|
||||||
@@ -91,6 +92,12 @@
|
|||||||
);
|
);
|
||||||
const atEnd = $derived(idx >= steps.length - 1);
|
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
|
// 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.
|
// attack in progress, so a replay-watcher can see how a spell reached them.
|
||||||
const sightTrace = $derived(stackSightTrace(step.view));
|
const sightTrace = $derived(stackSightTrace(step.view));
|
||||||
@@ -517,22 +524,41 @@
|
|||||||
comp.width = 1280;
|
comp.width = 1280;
|
||||||
comp.height = 720;
|
comp.height = 720;
|
||||||
const g = comp.getContext("2d")!;
|
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;
|
let compRaf = 0;
|
||||||
const drawComp = () => {
|
const drawComp = () => {
|
||||||
g.imageSmoothingEnabled = false;
|
g.imageSmoothingEnabled = false;
|
||||||
g.drawImage(cv, 0, 0, 1280, 720);
|
g.drawImage(cv, 0, 0, 1280, 720);
|
||||||
g.imageSmoothingEnabled = true;
|
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.fillStyle = "rgba(12, 10, 8, 0.78)";
|
||||||
g.fillRect(0, 720 - 96, 1280, 96);
|
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.fillStyle = "#e0b34a";
|
||||||
g.font = "600 22px Oswald, sans-serif";
|
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.fillStyle = "#efe8d4";
|
||||||
g.font = "21px 'Courier Prime', monospace";
|
g.font = "21px 'Courier Prime', monospace";
|
||||||
const said = lines.slice(0, 2);
|
const said = lines.slice(0, 2);
|
||||||
if (said.length === 0) said.push("…considers the maze.");
|
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.
|
// The colophon corner.
|
||||||
g.fillStyle = "rgba(224, 179, 74, 0.6)";
|
g.fillStyle = "rgba(224, 179, 74, 0.6)";
|
||||||
g.font = "600 20px Oswald, sans-serif";
|
g.font = "600 20px Oswald, sans-serif";
|
||||||
@@ -611,12 +637,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="replay-caption">
|
<div class="replay-caption">
|
||||||
<strong>{step.actor}</strong>
|
{#if actorArt}<img class="caption-face" src={actorArt} alt={step.actor} />{/if}
|
||||||
{#each lines as line, i (i)}<div>{line}</div>{/each}
|
<div class="caption-lines">
|
||||||
{#if lines.length === 0 && !step.chat?.length}<div>…considers the maze.</div>{/if}
|
<strong>{step.actor}</strong>
|
||||||
{#each step.chat ?? [] as c, i (i)}
|
{#each lines as line, i (i)}<div>{line}</div>{/each}
|
||||||
<div class="reel-talk">💬 {c.player}: {c.text}</div>
|
{#if lines.length === 0 && !step.chat?.length}<div>…considers the maze.</div>{/if}
|
||||||
{/each}
|
{#each step.chat ?? [] as c, i (i)}
|
||||||
|
<div class="reel-talk">💬 {c.player}: {c.text}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="replay-controls">
|
<div class="replay-controls">
|
||||||
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move">◀</button>
|
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move">◀</button>
|
||||||
@@ -707,7 +736,19 @@
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
min-height: 3.4rem;
|
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 {
|
.replay-controls .speed {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|||||||
Reference in New Issue
Block a user