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
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 311 KiB After Width: | Height: | Size: 314 KiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 307 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 303 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 293 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 312 KiB After Width: | Height: | Size: 314 KiB |
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||