Files
wizwar6e/packages/web/src/Replay.svelte
T
Eric WagonerandClaude Fable 5 765828700d Reel captions keep the count, not the cards
"You draw Waterbolt" belongs in the chronicle, not on a replay whose
canvas gets recorded and passed around — the reel's captions now skip
the private-draw events (drawn, dealt, stolen) and say only how many.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:11:09 -04:00

574 lines
22 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import Board from "./Board.svelte";
import FirstPerson from "./fpv/FirstPerson.svelte";
import { untrack } from "svelte";
import { humanize } from "./net.svelte";
import { scheduleFx, type BoardFx } from "./fx";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
import { castRay, edgeMid } from "./fpv/raycast";
import { prefs } from "./prefs.svelte";
import { stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
let {
steps,
onclose,
moment = false,
pov = null,
onshare = null,
endLabel = null,
}: {
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
onclose: () => void;
/** An instant replay of one turn: open straight into first person,
* through the eyes of the wizard whose turn it is. */
moment?: boolean;
/** The turn-owner, when the server already knows it (moment reels):
* the fallback when no boundary has scrolled past yet. */
pov?: string | null;
/** Mint a public link to this turn; resolves to the URL. */
onshare?: (() => Promise<string>) | null;
/** What the leave button says once the reel has run out. */
endLabel?: string | null;
} = $props();
/** The share button's little life: offer, mint, report. */
let shareState = $state<"idle" | "minting" | "copied" | "failed">("idle");
async function doShare() {
if (!onshare || shareState === "minting") return;
shareState = "minting";
try {
const url = await onshare();
await navigator.clipboard.writeText(url);
shareState = "copied";
} catch {
shareState = "failed";
}
setTimeout(() => (shareState = "idle"), 4000);
}
let idx = $state(0);
let playing = $state(true);
let speed = $state(1);
/** Watch the board from above, or relive it through your own eyes. */
let fp = $state(moment);
/** Whose eyes the first-person camera wears: the wizard whose turn it
* is, ALWAYS — their position seen with the viewer's knowledge of the
* maze, so nothing private leaks. The owner is whoever the last turn
* boundary at or before the current step named; before any boundary
* scrolls past, the server-known owner (moment reels) or the viewer. */
const povId = $derived.by(() => {
for (let i = Math.min(idx, steps.length - 1); i >= 0; i--) {
const evs = steps[i]!.events;
for (let j = evs.length - 1; j >= 0; j--) {
const e = evs[j]!;
if (e.type === "turnStarted" || e.type === "extraTurnStarted") {
return e.player as string;
}
}
}
return pov ?? steps[0]!.view.you;
});
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
/** What you drew belongs in the chronicle, not on a reel that may be
* recorded and passed around — captions keep the count, not the cards. */
const CAPTION_SILENT = new Set(["cardsDrawnPrivate", "cardsDealtPrivate", "cardsStolenPrivate"]);
const lines = $derived(
step.events
.filter((e) => !CAPTION_SILENT.has(e.type))
.map(humanize)
.filter((l): l is string => l !== null),
);
const atEnd = $derived(idx >= steps.length - 1);
// 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));
/** Every wall destroyed so far in the reel leaves a mound of rubble on
* the first-person floor for the rest of it. */
const rubbleSpots = $derived.by(() => {
const spots: { x: number; y: number }[] = [];
const seen = new Set<string>();
for (let i = 0; i <= Math.min(idx, steps.length - 1); i++) {
for (const e of steps[i]!.events) {
if (e.type !== "wallDestroyed" || !("edge" in e)) continue;
const edge = (e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge;
const key = `${edge.cell.x},${edge.cell.y}:${edge.side}`;
if (seen.has(key)) continue;
seen.add(key);
spots.push(edgeMid(edge.cell, edge.side));
}
}
return spots;
});
/** Each step's spells flare on the reel exactly as they did at the table. */
let boardFx = $state<BoardFx[]>([]);
$effect(() => {
const step = steps[Math.min(idx, steps.length - 1)];
if (!step || !prefs.flourishes) return;
const cancel = scheduleFx(
step.events, step.view,
(fx) => (boardFx = [...boardFx, fx]),
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
);
return () => { cancel(); boardFx = []; };
});
/** In first person the same events become projectiles, impacts,
* flashes, and shakes — scheduled on this step's beat. */
let fpFx = $state<FpFx[]>([]);
$effect(() => {
const st = steps[Math.min(idx, steps.length - 1)];
if (!fp || !st || !prefs.flourishes) return;
const timers: ReturnType<typeof setTimeout>[] = [];
const started: number[] = [];
for (const { fx, delay } of fpFxForEvents(st.events, st.view, povId)) {
timers.push(setTimeout(() => {
started.push(fx.id);
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
}, delay / speed));
}
return () => {
timers.forEach(clearTimeout);
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
};
});
$effect(() => {
if (!playing) return;
const t = setInterval(() => {
if (idx < steps.length - 1) idx += 1;
else playing = false;
}, 1500 / speed);
return () => clearInterval(t);
});
/** Other bodies glide between steps instead of blinking cell to cell:
* each step, anyone whose position changed a walkable distance tweens
* from where the previous step's view had them. */
let actorPos = $state<Record<string, { x: number; y: number }>>({});
let prevView: GameView | null = null;
$effect(() => {
const st = steps[Math.min(idx, steps.length - 1)];
if (!fp || !st) { prevView = null; actorPos = {}; return; }
const v = st.view;
const before = prevView;
prevView = v;
if (!before || before === v) return;
const moves: { id: string; fx: number; fy: number; tx: number; ty: number }[] = [];
const gather = (
id: string,
now: { x: number; y: number },
was: { x: number; y: number } | undefined,
) => {
if (!was) return;
const d = Math.hypot(now.x - was.x, now.y - was.y);
// A single stride or shove glides; a leap across the maze is a
// teleport and should simply be there.
if (d > 0.05 && d <= 3.5) {
moves.push({ id, fx: was.x + 0.5, fy: was.y + 0.5, tx: now.x + 0.5, ty: now.y + 0.5 });
}
};
for (const p of v.players) {
if (!p.alive || p.id === povId) continue;
const was = before.players.find((q) => q.id === p.id && q.alive);
gather(p.id, p.position, was?.position);
}
for (const c of v.creatures) {
gather(c.id, c.position, before.creatures.find((q) => q.id === c.id)?.position);
}
if (moves.length === 0) { actorPos = {}; return; }
const t0 = performance.now();
const dur = 400 / speed;
let raf = 0;
const tick = (now: number) => {
const w = Math.min(1, Math.max(0, (now - t0) / dur));
const ease = w * w * (3 - 2 * w);
const next: Record<string, { x: number; y: number }> = {};
for (const m of moves) {
next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease };
}
actorPos = next;
if (w < 1) raf = requestAnimationFrame(tick);
else actorPos = {};
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
// --- The first-person camera: your wizard's walk, relived. -------------
// Each step the camera settles on your position. A step away tweens —
// turn first, then stride; a leap (teleport, warp) cuts. When you stood
// still, the eye turns toward whoever acted.
const cam = $state({ x: 0, y: 0, facing: 0 });
let camReady = false;
function shortestArc(from: number, to: number): number {
let d = (to - from) % (2 * Math.PI);
if (d > Math.PI) d -= 2 * Math.PI;
if (d < -Math.PI) d += 2 * Math.PI;
return d;
}
$effect(() => {
if (!fp) { camReady = false; return; }
const v = step.view;
const me = v.players.find((p) => p.id === povId);
if (!me) return;
const tx = me.position.x + 0.5;
const ty = me.position.y + 0.5;
// Read the camera WITHOUT tracking it: this effect's own tween writes
// cam every frame, and a tracked read would re-trigger the effect per
// frame — each restart resetting the ease to zero, so the walk decays
// into a slow drift. Untracked, one step runs one tween.
const camX = untrack(() => cam.x);
const camY = untrack(() => cam.y);
const camF = untrack(() => cam.facing);
const dx = tx - camX;
const dy = ty - camY;
const dist = Math.hypot(dx, dy);
// Where should the eye end up pointing? Along its own stride; at the
// actor, when someone else moved the world; wherever it was, otherwise.
// A blow that hurls the body glides fast and straight, however far,
// eyes still where they were; only unexplained leaps (teleports) cut.
const hurled = step.events.some((e) =>
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
e.type === "retreatedInHorror") && e.player === povId);
const actor = v.players.find((p) => p.id === step.actor);
// Where the eyes' own magic went this step: a wizard looks where
// they aim, so the camera turns to watch its own spells land.
const aim = (() => {
for (const e of step.events) {
let cell: { x: number; y: number } | null = null;
if (e.type === "spellCast" && e.caster === povId) {
cell = e.targetCell ?? v.players.find((p) => p.id === e.target)?.position ?? null;
} else if (e.type === "attackResolved" && e.attacker === povId) {
cell = v.players.find((p) => p.id === e.defender)?.position ?? null;
} else if (e.type === "objectThrown" && e.attacker === povId) {
cell = e.landedAt;
}
if (cell && (cell.x !== me.position.x || cell.y !== me.position.y)) return cell;
}
return null;
})();
let targetFacing = camF;
let aimed = false;
// A stride turns the eyes along it — but only a REAL stride: on the
// reel's first run the camera sits at its uninitialized origin, and
// that phantom distance must not pick the opening shot's direction.
if (camReady && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; }
else if (aim) {
const ax = aim.x + 0.5, ay = aim.y + 0.5;
const toAim = Math.hypot(ax - tx, ay - ty);
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
if (sight.warped || sight.dist < toAim - 0.4) {
// The deed lands beyond these eyes — a summon across the maze, a
// spell through walls. Cut away to the spot like a broadcast
// camera: stand back down its deepest corridor, facing it, for
// this one beat; the next step cuts home to the wizard.
let best = { d: -1, a: 0 };
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const h = castRay(v, ax, ay, a);
if (h.dist > best.d) best = { d: h.dist, a };
}
const back = Math.min(1.6, Math.max(0.35, best.d - 0.4));
cam.x = ax + Math.cos(best.a) * back;
cam.y = ay + Math.sin(best.a) * back;
cam.facing = best.a + Math.PI;
camReady = true;
return;
}
targetFacing = Math.atan2(ay - ty, ax - tx);
aimed = true;
} else if (actor && actor.id !== povId &&
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
// Turn toward the actor — but only if these eyes could actually see
// them: an unbroken straight sight line, no warps bending it.
const ax = actor.position.x + 0.5, ay = actor.position.y + 0.5;
const toActor = Math.hypot(ax - tx, ay - ty);
const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
if (!ray.warped && ray.dist > toActor - 0.2) {
targetFacing = Math.atan2(ay - ty, ax - tx);
aimed = true;
}
}
if (!aimed && castRay(v, tx, ty, targetFacing).dist < 0.8) {
// Nothing aims the eyes and they'd idle nose-to-brick (the opening
// cut, or a beat after pacing into a dead end): face the deepest
// corridor from where the wizard stands instead.
let deepest = -1;
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const h = castRay(v, tx, ty, a);
if (h.dist > deepest) { deepest = h.dist; targetFacing = a; }
}
}
if (!camReady || (dist > 1.6 && !hurled)) {
// First frame, or a leap the legs cannot explain: cut.
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
camReady = true;
return;
}
const fromX = camX, fromY = camY, fromF = camF;
const arc = shortestArc(fromF, targetFacing);
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
const t0 = performance.now();
let raf = 0;
const tick = (now: number) => {
// rAF hands frame-start time, which can precede t0; and a
// zero-length phase must never divide. Either poisons the facing
// with NaN, which no later frame can wash out.
const t = Math.max(0, now - t0);
if (turnMs > 0 && t < turnMs) {
cam.facing = fromF + arc * (t / turnMs);
} else if (walkMs > 0 && t < turnMs + walkMs) {
cam.facing = fromF + arc;
const w = (t - turnMs) / walkMs;
const ease = w * w * (3 - 2 * w);
cam.x = fromX + (tx - fromX) * ease;
cam.y = fromY + (ty - fromY) * ease;
} else {
cam.facing = fromF + arc;
cam.x = tx; cam.y = ty;
return;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
// --- Save a video: the first-person view composited into a shareable
// 1280x720 frame — the step's caption burned in at the bottom, the
// game's name in the corner — so a downloaded clip explains itself.
let stageEl: HTMLDivElement | undefined = $state();
let recorder = $state<MediaRecorder | null>(null);
function toggleRecord() {
if (recorder) { recorder.stop(); return; }
const cv = stageEl?.querySelector("canvas");
if (!cv) return;
// Recording means capturing the WHOLE reel: rewind to the top and
// roll. (Hitting record at the end used to auto-stop a beat later —
// a one-second clip of the final frame.)
idx = 0;
const comp = document.createElement("canvas");
comp.width = 1280;
comp.height = 720;
const g = comp.getContext("2d")!;
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.
g.fillStyle = "rgba(12, 10, 8, 0.78)";
g.fillRect(0, 720 - 96, 1280, 96);
g.fillStyle = "#e0b34a";
g.font = "600 22px Oswald, sans-serif";
g.fillText(step.actor.toUpperCase(), 28, 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));
// The colophon corner.
g.fillStyle = "rgba(224, 179, 74, 0.6)";
g.font = "600 20px Oswald, sans-serif";
g.textAlign = "right";
g.fillText("W I Z - W A R", 1280 - 24, 40);
g.textAlign = "left";
compRaf = requestAnimationFrame(drawComp);
};
compRaf = requestAnimationFrame(drawComp);
// MP4 travels everywhere (iMessage, QuickTime, every platform);
// WebM is the fallback where the browser can't mux it.
const mime = [
"video/mp4;codecs=avc1.42E01E", "video/mp4",
"video/webm;codecs=vp9", "video/webm",
].find((m) => MediaRecorder.isTypeSupported(m)) ?? "video/webm";
const container = mime.startsWith("video/mp4") ? "video/mp4" : "video/webm";
const ext = container === "video/mp4" ? "mp4" : "webm";
const rec = new MediaRecorder(comp.captureStream(60), { mimeType: mime });
const chunks: Blob[] = [];
rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
rec.onstop = () => {
cancelAnimationFrame(compRaf);
const url = URL.createObjectURL(new Blob(chunks, { type: container }));
const a = document.createElement("a");
a.href = url;
a.download = `wizwar-moves-${steps[0]?.seq ?? 0}-${steps[steps.length - 1]?.seq ?? 0}.${ext}`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 2000);
recorder = null;
};
rec.start();
recorder = rec;
playing = true;
}
// The reel running out ends the recording and hands over the file.
$effect(() => {
if (recorder && !playing && atEnd) recorder.stop();
});
function onkeydown(e: KeyboardEvent) {
if (e.key === "Escape") onclose();
if (e.key === "ArrowRight") { playing = false; idx = Math.min(idx + 1, steps.length - 1); }
if (e.key === "ArrowLeft") { playing = false; idx = Math.max(idx - 1, 0); }
if (e.key === " ") { e.preventDefault(); playing = !playing; }
}
</script>
<svelte:window {onkeydown} />
<div class="replay-scrim">
<div class="replay" role="dialog" aria-modal="true" aria-label="what happened while you were away">
<header class="replay-head">
<span class="replay-title">{moment ? `Instant replay — ${povId}'s turn` : steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span>
<span class="replay-count">move {idx + 1} of {steps.length}</span>
<button class="replay-eyes" class:lit={fp} onclick={() => (fp = !fp)}>
{fp ? "⬒ the board" : "👁 your eyes"}</button>
{#if fp}
<button class="replay-eyes" class:lit={recorder !== null} onclick={toggleRecord}>
{recorder ? "⏹ stop & save" : "⏺ save video"}</button>
{/if}
{#if moment && onshare}
<button class="replay-eyes" class:lit={shareState === "copied"} onclick={doShare}>
{shareState === "idle" ? "🔗 share link"
: shareState === "minting" ? "…"
: shareState === "copied" ? "✓ link copied" : "share failed"}</button>
{/if}
<button class="replay-skip" onclick={onclose}>{atEnd ? (endLabel ?? "back to the game") : "skip to now"}</button>
</header>
<div class="replay-board" bind:this={stageEl}>
{#if fp}
<FirstPerson view={step.view} {povId}
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
{:else}
<Board view={step.view} effects={boardFx} {sightTrace} />
{/if}
</div>
<div class="replay-caption">
<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}
{#each step.chat ?? [] as c, i (i)}
<div class="reel-talk">💬 {c.player}: {c.text}</div>
{/each}
</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"}>
{playing ? "❚❚" : "▶"}
</button>
<button onclick={() => { playing = false; idx = Math.min(steps.length - 1, idx + 1); }} aria-label="next move"></button>
{#each [1, 2, 4] as x (x)}
<button class="speed" class:current={speed === x} onclick={() => (speed = x)}>{x}×</button>
{/each}
</div>
</div>
</div>
<style>
.replay-scrim {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.88);
display: grid;
place-items: center;
z-index: 50;
padding: 1rem;
}
.replay {
background: #171a20;
border: 1px solid rgba(233, 225, 203, 0.25);
border-radius: 8px;
width: min(46rem, 100%);
max-height: calc(100dvh - 2rem);
display: flex;
flex-direction: column;
padding: 0.8rem 1rem 1rem;
color: #d8d2c0;
font-family: "Archivo Narrow", sans-serif;
}
.replay-head {
display: flex;
align-items: baseline;
gap: 0.8rem;
margin-bottom: 0.6rem;
}
.replay-title {
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.85rem;
color: #e9e1cb;
}
.replay-count { font-size: 0.8rem; color: #8d8672; }
.replay-eyes {
margin-left: auto;
background: none;
border: 1px solid #5a5342;
border-radius: 3px;
color: #a49c86;
cursor: pointer;
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
}
.replay-eyes.lit { color: #e9e1cb; border-color: #a49c86; }
.replay-skip {
background: none;
border: none;
color: #a49c86;
text-decoration: underline;
cursor: pointer;
font-size: 0.85rem;
}
.replay-board {
min-height: 0;
display: flex;
justify-content: center;
}
.replay-board :global(svg.board) {
max-height: calc(100dvh - 15rem);
width: auto;
max-width: 100%;
}
.reel-talk { font-style: italic; opacity: 0.85; }
.replay-caption {
background: #efe8d4;
color: #3a2f1f;
border-radius: 3px;
padding: 0.5rem 0.75rem;
margin-top: 0.7rem;
font-family: "Courier Prime", monospace;
font-size: 0.78rem;
line-height: 1.45;
min-height: 3.4rem;
}
.replay-controls .speed {
font-size: 0.75rem;
opacity: 0.7;
}
.replay-controls .speed.current { opacity: 1; text-decoration: underline; }
.replay-controls {
display: flex;
justify-content: center;
gap: 0.6rem;
margin-top: 0.6rem;
}
.replay-controls button {
background: #e9e1cb;
color: #43331f;
border: 1.5px solid #43331f;
border-radius: 3px;
min-width: 2.6rem;
padding: 0.35rem 0.6rem;
cursor: pointer;
font-size: 0.9rem;
}
.playpause { min-width: 3.4rem; }
</style>