The reel slates a change of eyes and captions the wizard's state

Two things for a viewer who has never seen the game. When the reel's
pov passes to another wizard, the cut used to be instant — one wizard
apparently teleporting. Now a slate wipes across the pane: a dark band
sweeps in over the old sight, holds with the new wizard's standee and
THROUGH THE EYES OF <name>, and sweeps off to reveal theirs. It is an
FpFx ("slate") drawn by the renderer on the reel's own clock, so the
harness and the film see the same frames.

The caption grows a right-hand column: strides left to spend this turn
as a countdown, ♥ life, and chips for what the wizard carries,
displays, or suffers — a treasure, displayed cards, spells on them
with turns remaining, lost turns, banked Pass Wall, attack spent. The
save-video composite carries the same column.

All thirty scene goldens re-blessed: the caption is inside every
frame. The 3D panes are pixel-identical to the previous goldens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-02 13:21:04 -04:00
co-authored by Claude Fable 5.1
parent 6a933f030c
commit 7223f8eb0d
33 changed files with 158 additions and 5 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 314 KiB

+105 -4
View File
@@ -2,14 +2,14 @@
import Board from "./Board.svelte"; import Board from "./Board.svelte";
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, spellName } from "./net.svelte";
import { tokenArt } from "./art"; 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";
import { cutawayStand, deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director"; import { cutawayStand, deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
import { prefs } from "./prefs.svelte"; import { prefs } from "./prefs.svelte";
import { stackSightTrace } from "@wizwar/engine"; import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine";
let { let {
@@ -99,6 +99,51 @@
return p ? tokenArt(`wizard-${p.colorIndex}`, "players") : null; return p ? tokenArt(`wizard-${p.colorIndex}`, "players") : null;
}); });
/** The caption's right-hand column — the reel's viewer may never have
* seen the game, so the acting wizard's state stands beside their
* deed: strides still to spend this turn, life, and what they carry,
* display, or suffer. */
const status = $derived.by(() => {
const v = step.view;
const p = v.players.find((x) => x.id === step.actor);
if (!p) return null;
const acting = v.activePlayerId === p.id;
const steps = acting ? Math.max(0, v.turn.movementAllowance - v.turn.movementUsed) : null;
const chips: string[] = [`♥ ${p.life}`];
if (p.carriedTreasureId) chips.push("carrying a treasure");
for (const c of p.displayed) chips.push(cardDef(c.cardId).name.toLowerCase());
for (const e of v.sustained) {
if (e.targetId !== p.id) continue;
chips.push(`✦ ${spellName(e.cardId).toLowerCase()}${isPermanentDuration(e.remainingTurns) ? "" : ` · ${e.remainingTurns}`}`);
}
if (p.lostTurns > 0) chips.push(`loses ${p.lostTurns} turn${p.lostTurns === 1 ? "" : "s"}`);
if (p.passWallCharges > 0) chips.push(`pass wall × ${p.passWallCharges}`);
if (acting && v.turn.attackUsed) chips.push("attack spent");
return { steps, chips };
});
/** A change of eyes wipes a slate across the pane: the cut to another
* wizard's sight must read as a cut, never as a wizard teleporting. */
let lastPov: string | null = null;
let slateSeq = 0;
$effect(() => {
const who = povId;
if (!fp) { lastPov = null; return; }
if (lastPov !== null && lastPov !== who) {
const p = step.view.players.find((x) => x.id === who);
const fx: FpFx = {
id: -(++slateSeq), kind: "slate", name: who, // negative: never a stager id
art: p ? tokenArt(`wizard-${p.colorIndex}`, "players") : null,
t0: performance.now(), dur: 1400 / speed,
};
fpFx = [...untrack(() => fpFx), fx];
const t = setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80);
lastPov = who;
return () => clearTimeout(t);
}
lastPov = who;
});
// 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));
@@ -464,7 +509,22 @@
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, textX, 720 - 32 + i * 26, 1224 - (textX - 28))); said.forEach((line, i) => g.fillText(line, textX, 720 - 32 + i * 26, 760));
if (status) {
g.textAlign = "right";
if (status.steps !== null) {
g.fillStyle = "#efe8d4";
g.font = "600 34px Oswald, sans-serif";
g.fillText(String(status.steps), 1280 - 28, 720 - 52);
g.fillStyle = "#a49c86";
g.font = "600 14px Oswald, sans-serif";
g.fillText(status.steps === 1 ? "STEP LEFT" : "STEPS LEFT", 1280 - 28, 720 - 34);
}
g.fillStyle = "#d8d2c0";
g.font = "16px 'Courier Prime', monospace";
g.fillText(status.chips.join(" · "), 1280 - 28, 720 - 12, 420);
g.textAlign = "left";
}
// 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";
@@ -552,6 +612,16 @@
<div class="reel-talk">💬 {c.player}: {c.text}</div> <div class="reel-talk">💬 {c.player}: {c.text}</div>
{/each} {/each}
</div> </div>
{#if status}
<div class="caption-status">
{#if status.steps !== null}
<div class="caption-steps"><b>{status.steps}</b><span>{status.steps === 1 ? "step" : "steps"} left</span></div>
{/if}
<div class="caption-chips">
{#each status.chips as chip, i (i)}<span class="chip">{chip}</span>{/each}
</div>
</div>
{/if}
</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>
@@ -654,7 +724,38 @@
border: 1.5px solid #43331f; border: 1.5px solid #43331f;
flex: none; flex: none;
} }
.caption-lines { min-width: 0; } .caption-lines { min-width: 0; flex: 1; }
.caption-status {
flex: none;
max-width: 38%;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
text-align: right;
}
.caption-steps {
display: flex;
align-items: baseline;
gap: 0.35rem;
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.62rem;
color: #6b5a3e;
}
.caption-steps b { font-size: 1.35rem; line-height: 1; color: #3a2f1f; letter-spacing: 0; }
.caption-chips { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.25rem; }
.chip {
border: 1px solid #b9ad92;
border-radius: 3px;
padding: 0 0.35rem;
font-size: 0.68rem;
line-height: 1.5;
color: #4d3f2a;
background: rgba(255, 255, 255, 0.35);
white-space: nowrap;
}
.replay-controls .speed { .replay-controls .speed {
font-size: 0.75rem; font-size: 0.75rem;
opacity: 0.7; opacity: 0.7;
+38 -1
View File
@@ -5,7 +5,7 @@
// by the same depth buffer the walls wrote. // by the same depth buffer the walls wrote.
import { billboards, castRay, warpMotion, type FpvTarget } from "./raycast"; import { billboards, castRay, warpMotion, type FpvTarget } from "./raycast";
import { materialTextures } from "./textures"; import { materialTextures } from "./textures";
import { doorOpenness, fxFallback, growProgress, surgeIntensity, type FpFx } from "./fx3d"; import { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d";
import { terrainFallback, TERRAIN3D } from "./terrain3d"; import { terrainFallback, TERRAIN3D } from "./terrain3d";
import { tokenArt } from "../art"; import { tokenArt } from "../art";
import { PLAYER_COLORS } from "../colors"; import { PLAYER_COLORS } from "../colors";
@@ -709,6 +709,43 @@
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
// A change of eyes: the slate wipes across, names the wizard whose
// sight this now is, and wipes away to reveal it.
for (const f of fx) {
if (f.kind !== "slate") continue;
const [l, r] = slateBand((time - f.t0) / f.dur);
if (r <= l) continue;
const x0 = Math.round(l * W), x1 = Math.round(r * W);
ctx.save();
ctx.beginPath();
ctx.rect(x0, 0, x1 - x0, H);
ctx.clip();
ctx.fillStyle = "#171a20";
ctx.fillRect(x0, 0, x1 - x0, H);
ctx.fillStyle = "#e0b34a";
ctx.fillRect(x0, half - 1, x1 - x0, 2);
// The card's face: standee, then the name — composed on the full
// pane so it lands where it will sit once the band is complete.
const face = f.art ? imageFor(f.art) : null;
const size = Math.round(H * 0.24);
const cx = W / 2;
if (face) {
ctx.drawImage(face, cx - size / 2, half - size - 18, size, size);
ctx.strokeStyle = "#43331f";
ctx.lineWidth = 2;
ctx.strokeRect(cx - size / 2, half - size - 18, size, size);
}
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = "#a49c86";
ctx.font = `600 ${Math.round(H * 0.045)}px Oswald, "Archivo Narrow", sans-serif`;
ctx.fillText("T H R O U G H T H E E Y E S O F", cx, half + Math.round(H * 0.09));
ctx.fillStyle = "#e9e1cb";
ctx.font = `600 ${Math.round(H * 0.13)}px Oswald, "Archivo Narrow", sans-serif`;
ctx.fillText(f.name.toUpperCase(), cx, half + Math.round(H * 0.23));
ctx.restore();
}
// The crosshair's answer, before the click commits: what the pane // The crosshair's answer, before the click commits: what the pane
// would target here, outlined in the table's gold with its name. // would target here, outlined in the table's gold with its name.
if (ontarget && mouse) drawHover(ctx, W, H, half); if (ontarget && mouse) drawHover(ctx, W, H, half);
+15
View File
@@ -25,6 +25,10 @@ export type FpFx =
* veil hung in that opening surges the warp's own face animates, * veil hung in that opening surges the warp's own face animates,
* no billboard over whoever stands in it. */ * no billboard over whoever stands in it. */
| { id: number; kind: "surge"; warp: number; t0: number; dur: number } | { id: number; kind: "surge"; warp: number; t0: number; dur: number }
/** The reel changes eyes: a title card wipes across the pane naming
* whose turn and whose sight this now is, so a cut between
* wizards never reads as one wizard teleporting. */
| { id: number; kind: "slate"; name: string; art: string | null; t0: number; dur: number }
| { id: number; kind: "grow"; slot: "solid" | "sprite"; key: string; t0: number; dur: number }; | { id: number; kind: "grow"; slot: "solid" | "sprite"; key: string; t0: number; dur: number };
/** How hard a surging veil glows at progress p: a snap to full as the /** How hard a surging veil glows at progress p: a snap to full as the
@@ -36,6 +40,17 @@ export function surgeIntensity(p: number): number {
return (1 - q) * (1 - q); return (1 - q) * (1 - q);
} }
/** The slate's wipe: how much of the pane its band covers at progress
* p, as [left, right] fractions sweeping in from the left, holding,
* then sweeping off to the right. */
export function slateBand(p: number): [number, number] {
const ease = (w: number) => w * w * (3 - 2 * w);
if (p < 0 || p >= 1) return [0, 0];
if (p < 0.28) return [0, ease(p / 0.28)];
if (p < 0.68) return [0, 1];
return [ease((p - 0.68) / 0.32), 1];
}
/** Eased growth 0..1 for a conjuration's rise. */ /** Eased growth 0..1 for a conjuration's rise. */
export function growProgress(p: number): number { export function growProgress(p: number): number {
if (p <= 0) return 0.01; if (p <= 0) return 0.01;