Files
wizwar6e/packages/web/src/Replay.svelte
T
Eric WagonerandClaude Fable 5.1 8582b7feb7 What you missed, in facts; a timeline on the reel; room for table talk
A returning player's slip now lists what happened while they were away
— "Automaton took your treasure.", "You lost 3 life.", "It's your
turn." — each fact opening the catch-up reel at its moment, read from
the missed steps as the server redacted them for that seat; when
nothing touched them it says what each wizard was up to instead. The
reel gains a timeline: a tick per move in the mover's color, taller
where a turn begins, a blow lands, or gold changes hands, and any tick
jumps the reel there.

The chronicle takes a filter — All, Game, Table talk — with an unread
count on the talk that arrived while the casting panel covered it (a
wizard's own words never count), a peek at the latest line above the
composer while the panel is up, and the composer itself no longer
leaves when a response is owed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 00:22:54 -04:00

1001 lines
40 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 { motion } from "./motion";
import Board from "./Board.svelte";
import { wizardColor } from "./colors";
import FirstPerson from "./fpv/FirstPerson.svelte";
import { untrack } from "svelte";
import { humanize, spellName } from "./net.svelte";
import { tokenArt } from "./art";
import { scheduleFx, type BoardFx } from "./fx";
import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d";
import { castRay, edgeMid } from "./fpv/raycast";
import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director";
import { cardDef, isPermanentDuration, stackSightTrace, castSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
let {
steps,
onclose,
moment = false,
pov = null,
onshare = null,
endLabel = null,
startAt = 0,
}: {
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;
/** The step the reel opens on. */
startAt?: number;
} = $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(untrack(() => Math.min(Math.max(0, startAt), Math.max(0, steps.length - 1))));
/** The timeline: one tick a step, a turn's first step marked, and the
* steps where blows landed or gold changed hands flagged, so a reel of
* a hundred moves can be read at a glance and jumped into. */
const ticks = $derived(steps.map((s, i) => {
let flag: "turn" | "hit" | "gold" | "end" | null = null;
for (const e of s.events) {
if (e.type === "gameWon" || e.type === "playerEliminated") { flag = "end"; break; }
if (e.type === "treasurePickedUp" || (e.type === "treasureDropped" && e.onHomeOf != null)) flag = "gold";
else if (!flag && (e.type === "damaged" || e.type === "attackResolved" || e.type === "punched")) flag = "hit";
else if (!flag && e.type === "turnStarted") flag = "turn";
}
return { i, actor: s.actor, flag, color: wizardColor(s.view, s.actor) };
}));
let playing = $state(true);
let speed = $state(1);
/** Watch the board from above, or relive it through your own eyes. */
let fp = $state(untrack(() => 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;
}
}
}
// No boundary yet: these are the FIRST turn's steps, whose opening
// turnStarted fired in the deal before any command. Its owner is
// whoever the reel's first turnEnded names.
for (const st of steps) {
for (const e of st.events) {
if (e.type === "turnEnded" && "player" in e) return (e as { player: string }).player;
if (e.type === "turnStarted" || e.type === "extraTurnStarted") break;
}
}
return pov ?? steps[0]!.view.you;
});
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
/** A step the die decided stops the reel for the roll: the stakes, the
* tumble, the face, and only then the outcome — a leap over a pit is
* not a teleport, and a viewer new to the game should see why. */
const DIE_MS = 2400;
const dieOf = (st: { events: GameEvent[] } | undefined) =>
st?.events.find((e): e is Extract<GameEvent, { type: "dieRolled" }> => e.type === "dieRolled");
/** How long this step's interlude holds the world before it moves. */
const dieMs = $derived(dieOf(step) && motion.effects ? DIE_MS / speed : 0);
let die = $state<{ idx: number; player: string; roll: number; purpose: string; face: number; landed: boolean } | null>(null);
$effect(() => {
const i = Math.min(idx, steps.length - 1);
const d = dieOf(steps[i]);
if (!d || !motion.effects) { die = null; return; }
const t0 = performance.now();
const dur = DIE_MS / speed;
const who = d.player ?? "The maze";
const show = (face: number, landed: boolean) =>
(die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face, landed });
show(1, false);
let raf = 0;
const tick = (now: number) => {
const p = Math.max(0, now - t0) / dur;
if (p >= 1) { die = null; return; }
if (p < 0.48) {
// The tumble: faces flick past, slowing as the die settles. A
// fixed sequence, so the harness films the same roll every time.
const n = Math.floor(p * 26 - p * p * 18);
show(((n * 3 + 1) % 4) + 1, false);
} else if (!die?.landed) {
show(d.roll, true);
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => { cancelAnimationFrame(raf); die = null; };
});
/** While the die is in the air the board shows the world as it was. */
const dieHeld = $derived(die && die.idx > 0 ? steps[die.idx - 1]!.view : null);
/** 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 captionLines = (events: GameEvent[], silent: Set<string>) =>
events.filter((e) => !silent.has(e.type)).map(humanize).filter((l): l is string => l !== null);
const lines = $derived(captionLines(step.events, CAPTION_SILENT));
const atEnd = $derived(idx >= steps.length - 1);
/** What the roll brought about, in the caption's own words — the roll
* itself is on the die card, so its line is left out. */
const VERDICT_SILENT = new Set([...CAPTION_SILENT, "dieRolled"]);
const dieVerdict = $derived(captionLines(step.events, VERDICT_SILENT).slice(0, 2).join(" "));
/** 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 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 strides = 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 { strides, 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 (!motion.effects) { lastPov = who; 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); fpFx = fpFx.filter((f) => f.id !== fx.id); };
}
lastPov = who;
});
// 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.
/** The attack's line, or the line a cast in this step was accepted on —
* traced after the fact, so the cast's own dust cloud does not blind it. */
const sightTrace = $derived.by(() => {
const onStack = stackSightTrace(step.view);
if (onStack) return onStack;
for (let i = step.events.length - 1; i >= 0; i--) {
const e = step.events[i]!;
if (e.type !== "spellCast") continue;
const line = castSightTrace(step.view, e, step.events, true);
if (line) return line;
}
return null;
});
/** 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 || !motion.effects) return;
let cancel = () => {};
const wait = setTimeout(() => {
cancel = scheduleFx(
step.events, step.view,
(fx) => (boardFx = [...boardFx, fx]),
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
);
}, dieMs);
return () => { clearTimeout(wait); cancel(); boardFx = []; };
});
/** The director's slate: the camera effect writes how long this step's
* outcome stays HIDDEN — the old world held on screen — while the eyes
* turn to face the recipient. The fx and the view reveal together. */
let camPlan = $state<{ idx: number; holdMs: number } | null>(null);
let heldView = $state<GameView | null>(null);
/** A cutaway frame shows the reel's own wizard too. */
let cutawayShot = $state(false);
/** In first person the same events become projectiles, impacts,
* flashes, and shakes — scheduled on this step's beat, after the
* camera's turn has brought the recipient into frame. */
let fpFx = $state<FpFx[]>([]);
$effect(() => {
const i = Math.min(idx, steps.length - 1);
const st = steps[i];
if (!fp || !st || !motion.effects) return;
const plan = camPlan;
if (plan?.idx !== i) return; // the camera has not set this step's slate yet
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));
}, plan.holdMs + delay / speed));
}
return () => {
timers.forEach(clearTimeout);
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
};
});
$effect(() => {
if (!playing) return;
// Each step gets the reel's beat, plus the die's interlude when
// one decided it. Reading idx here is what re-arms the timer on
// every advance.
const from = idx;
const t = setTimeout(() => {
if (idx !== from) return;
if (idx < steps.length - 1) idx += 1;
else playing = false;
}, 1500 / speed + dieMs);
return () => clearTimeout(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 (motion.reduced) { actorPos = {}; return; }
if (!before || before === v) return;
const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; }
const t0 = performance.now() + dieMs;
const dur = 400 / speed;
let raf = 0;
const tick = (now: number) => {
const w = Math.min(1, Math.max(0, (now - t0) / dur));
const ease = smoothstep(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, pitch: 0 });
/** How far the eyes drop to watch their own feet: the horizon rises
* to the pane's top third and the ground underfoot fills the rest. */
const LOOK_DOWN = 0.36;
/** Deeds on the ground of your own square, watched by looking down.
* A punch traded with a square-mate is not one: they stand in front
* of the eyes wherever those point. */
const GROUNDWORK = new Set(["spellCast", "squareFilled", "creatureCreated",
"treasurePickedUp", "treasureDropped", "objectDropped"]);
let camReady = false;
$effect(() => {
const reduced = motion.reduced;
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 camP = untrack(() => cam.pitch);
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 = hurledIn(step.events, 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 = aimOfEvents(step.events, v, povId, me.position, step.actor);
// A leap the legs cannot explain (first frame, teleport, the return
// from a cutaway) is a CUT, and a cut is not a stride: the direction
// of travel means nothing at the far end.
const willCut = !camReady || (dist > 1.6 && !hurled);
cutawayShot = false;
/** The broadcast cutaway: stand back down the target cell's deepest
* corridor, facing it, holding a beat of the world-before so the
* deed happens ON screen — with the reel's own wizard visible. */
const takeCutaway = (ax: number, ay: number) => {
const { facing: a, depth: d } = cutawayStand(v, ax, ay);
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
cam.x = ax + Math.cos(a) * back;
cam.y = ay + Math.sin(a) * back;
cam.facing = a + Math.PI;
camReady = true;
cutawayShot = true;
const i2 = Math.min(idx, steps.length - 1);
const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
if (reduced) { heldView = null; camPlan = { idx: i2, holdMs: 0 }; return; }
heldView = pv;
camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 };
if (pv) {
const t = setTimeout(() => (heldView = null), 420 / speed + dieMs);
return () => clearTimeout(t);
}
return;
};
let targetFacing = camF;
let aimed = false;
// The pit you fell down, or climbed from, is at your feet too; the
// eyes come back level with the next step.
const underfoot = step.events.some((e) =>
(e.type === "fellInPit" || e.type === "climbedFromPit") && e.player === povId);
let targetPitch = 0;
const groundwork = aim?.self && step.events.some((e) => GROUNDWORK.has(e.type));
/** A stride that ends with something to watch — an ambush that
* fired as the step landed — turns once more after the walk. */
let afterArc = 0;
if (camReady && !willCut && dist > 0.05 && !hurled) {
targetFacing = Math.atan2(dy, dx);
aimed = true;
if (aim && !aim.self) {
const h = sightline(v, tx, ty, aim.x + 0.5, aim.y + 0.5);
if (h !== null) afterArc = shortestArc(targetFacing, h);
}
}
else if (groundwork || underfoot) {
targetPitch = LOOK_DOWN;
aimed = true;
}
else if (aim && !aim.self) {
const ax = aim.x + 0.5, ay = aim.y + 0.5;
// The eyes watch their own magic land wherever they can see it —
// straight down a corridor or through a warp mouth. Only a target
// out of all sight earns the cutaway.
const heading = sightline(v, tx, ty, ax, ay);
if (heading === null) return takeCutaway(ax, ay);
targetFacing = heading;
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, straight or through a mouth.
const heading = sightline(v, tx, ty, actor.position.x + 0.5, actor.position.y + 0.5, 0.2);
if (heading !== null) {
targetFacing = heading;
aimed = true;
}
}
// How good is the view down heading `a` from (px,py)? Corridor depth,
// and at the reel's end, a bonus for visible subjects.
const viewScoreAt = (px: number, py: number, a: number): number => {
let score = Math.min(castRay(v, px, py, a).dist, 8);
if (!atEnd) return score;
const bonus = (cell: { x: number; y: number }, worth: number) => {
const bx = cell.x + 0.5, by = cell.y + 0.5;
const bearing = Math.atan2(by - py, bx - px);
if (Math.abs(shortestArc(a, bearing)) > 0.55) return 0;
const d = Math.hypot(bx - px, by - py);
if (d < 0.1) return 0;
const sight = castRay(v, px, py, bearing);
return !sight.warped && sight.dist >= d - 0.4 ? worth / (1 + d * 0.3) : 0;
};
for (const p of v.players) if (p.alive && p.id !== povId) score += bonus(p.position, 6);
for (const c of v.creatures) score += bonus(c.position, 5);
for (const t of v.treasures) if (t.position && !t.carriedBy) score += bonus(t.position, 3);
return score;
};
if (!aimed) {
// Nothing aims the eyes. If they'd idle nose-to-brick (the opening
// cut, a dead-end beat — or the reel's CLOSING shot, which gets a
// higher standard), find a better view: the deepest corridor, and
// on the final frame, preferably one with something in it.
const viewScore = (a: number): number => viewScoreAt(tx, ty, a);
// A cut lands with whatever stale facing it carried, so a cut with
// nothing aimed always re-picks its view from the new position.
const staring = willCut ||
castRay(v, tx, ty, targetFacing).dist < (atEnd ? 1.2 : 0.8);
if (staring) {
const current = viewScore(targetFacing);
let bestA = targetFacing, bestScore = current;
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const sc = viewScore(a);
if (sc > bestScore + 0.5) { bestScore = sc; bestA = a; }
}
// One evaluation, one turn — a wizard walled in on all sides
// keeps whatever view it has rather than hunting forever.
targetFacing = bestA;
}
}
// The reel's LAST step may be a stride into a corner: the walk keeps
// its own facing, then the camera turns once more to a view worth
// ending on.
let closingArc = afterArc;
if (closingArc === 0 && atEnd && aimed && castRay(v, tx, ty, targetFacing).dist < 1.2) {
const current = viewScoreAt(tx, ty, targetFacing);
let bestA = targetFacing, bestScore = current;
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const sc = viewScoreAt(tx, ty, a);
if (sc > bestScore + 0.5) { bestScore = sc; bestA = a; }
}
closingArc = shortestArc(targetFacing, bestA);
}
const stepIdx = Math.min(idx, steps.length - 1);
const prevView = stepIdx > 0 ? steps[stepIdx - 1]!.view : null;
/** Hold the OLD world on screen this long, so the outcome lands only
* once the eyes face its recipient: a cutaway gets a beat of the
* "before"; a turn holds until the turn is done. */
const setSlate = (holdMs: number) => {
// Locals only: reading heldView back here would register it as a
// dependency of this very effect, and the timer clearing it would
// re-trigger us into a ping-pong.
holdMs += dieMs;
const hold = holdMs > 60 && prevView ? prevView : null;
heldView = hold;
camPlan = { idx: stepIdx, holdMs: hold ? holdMs : 0 };
if (hold) {
const t = setTimeout(() => (heldView = null), holdMs);
return () => clearTimeout(t);
}
return () => {};
};
if (reduced) {
cam.x = tx; cam.y = ty; cam.facing = targetFacing + closingArc; cam.pitch = targetPitch;
camReady = true;
heldView = null;
camPlan = { idx: stepIdx, holdMs: 0 };
return;
}
if (willCut) {
// First frame, or a leap the legs cannot explain: cut. An aimed
// cut still holds a beat of the before-world once the reel is
// rolling; the opening frame reveals at once.
const wasReady = camReady;
const cut = () => { cam.x = tx; cam.y = ty; cam.facing = targetFacing; cam.pitch = targetPitch; };
camReady = true;
if (dieMs > 0 && wasReady) {
// The die decides where the eyes land: cut once it has.
const t = setTimeout(cut, dieMs);
const clear = setSlate(aim ? 380 / speed : 0);
return () => { clearTimeout(t); clear(); };
}
cut();
return setSlate(wasReady && aim ? 380 / speed : 0);
}
const fromX = camX, fromY = camY, fromF = camF, fromP = camP;
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 tiltMs = (Math.abs(targetPitch - fromP) > 0.01 ? 340 : 0) / speed;
const closingMs = Math.min(300, Math.abs(closingArc) * 200) / speed;
// The outcome waits until the eyes face it: after the turn (and,
// for a stride that turns at its end, after the walk and that turn).
const clearSlate = setSlate(aimed && (aim || underfoot)
? Math.max(turnMs, tiltMs) + (afterArc !== 0 ? walkMs + closingMs : 0) + 120 / speed
: 0);
const t0 = performance.now() + dieMs;
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 (tiltMs > 0) {
const w = Math.min(1, t / tiltMs);
cam.pitch = fromP + (targetPitch - fromP) * (smoothstep(w));
}
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 = smoothstep(w);
cam.x = fromX + (tx - fromX) * ease;
cam.y = fromY + (ty - fromY) * ease;
} else if (closingArc !== 0 && t < turnMs + walkMs + closingMs) {
cam.x = tx; cam.y = ty;
const w = (t - turnMs - walkMs) / closingMs;
cam.facing = fromF + arc + closingArc * smoothstep(w);
} else if (tiltMs > 0 && t < tiltMs) {
cam.facing = fromF + arc + closingArc;
cam.x = tx; cam.y = ty;
} else {
cam.facing = fromF + arc + closingArc;
cam.x = tx; cam.y = ty; cam.pitch = targetPitch;
return;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
clearSlate();
};
});
// --- 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.
idx = 0;
const comp = document.createElement("canvas");
comp.width = 1280;
comp.height = 720;
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;
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 — 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(), 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, textX, 720 - 32 + i * 26, 760));
if (status) {
g.textAlign = "right";
if (status.strides !== null) {
g.fillStyle = "#efe8d4";
g.font = "600 34px Oswald, sans-serif";
g.fillText(String(status.strides), 1280 - 28, 720 - 52);
g.fillStyle = "#a49c86";
g.font = "600 14px Oswald, sans-serif";
g.fillText(status.strides === 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.
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 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={heldView ?? step.view} povId={cutawayShot ? "" : povId}
x={cam.x} y={cam.y} facing={cam.facing} pitch={cam.pitch} width={640} height={360}
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
{:else}
<Board view={dieHeld ?? step.view} effects={boardFx} {sightTrace} />
{/if}
{#if die}
<div class="die-slate" class:landed={die.landed}>
<div class="die-card">
<div class="die-who">{die.player} rolls the die</div>
<div class="die-why">{die.purpose}</div>
<div class="die-cube" style:transform={`rotate(${die.landed ? 0 : [-14, 9, -5, 12][die.face - 1]}deg) scale(${die.landed ? 1.12 : 1})`}>
<span>{die.face}</span>
</div>
<div class="die-verdict">{die.landed ? dieVerdict || "…" : "\u00a0"}</div>
</div>
</div>
{/if}
</div>
<div class="replay-caption">
{#if actorArt}<img class="caption-face" src={actorArt} alt={step.actor} />{/if}
<div class="caption-lines">
<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>
{#if status}
<div class="caption-status">
{#if status.strides !== null}
<div class="caption-steps"><b>{status.strides}</b><span>{status.strides === 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>
{#if steps.length > 3}
<div class="timeline" role="group" aria-label="the moves, a tick each">
{#each ticks as t (t.i)}
<button class="tick" class:current={t.i === idx} class:turn={t.flag === "turn"} class:hit={t.flag === "hit"} class:gold={t.flag === "gold"} class:end={t.flag === "end"}
style:--who={t.color} title={`move ${t.i + 1} — ${t.actor}${t.flag ? ` · ${t.flag === "turn" ? "turn begins" : t.flag === "hit" ? "a blow" : t.flag === "gold" ? "treasure" : "the end"}` : ""}`}
onclick={() => { playing = false; idx = t.i; }} aria-label={`jump to move ${t.i + 1}`}></button>
{/each}
</div>
{/if}
<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;
position: relative;
}
/* The die's interlude: a card over the held world. */
.die-slate {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(10, 12, 16, 0.55);
}
.die-card {
background: #efe8d4;
color: #3a2f1f;
border: 2px solid #43331f;
border-radius: 6px;
padding: 0.7rem 1.4rem 0.8rem;
min-width: 17rem;
max-width: 80%;
text-align: center;
font-family: "Courier Prime", monospace;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
}
.die-who {
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.8rem;
color: #6b5a3e;
}
.die-why { font-size: 0.82rem; margin: 0.15rem 0 0.5rem; }
.die-cube {
width: 4.2rem;
height: 4.2rem;
margin: 0 auto;
display: grid;
place-items: center;
background: #e0b34a;
border: 2px solid #43331f;
border-radius: 0.6rem;
font-family: "Oswald", sans-serif;
font-size: 2.4rem;
color: #2b2218;
transition: none;
}
.die-slate.landed .die-cube { background: #f2d27a; box-shadow: 0 0 0 4px rgba(224, 179, 74, 0.35); }
.die-verdict { margin-top: 0.55rem; font-size: 0.82rem; min-height: 1.3em; }
.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;
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; 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 {
font-size: 0.75rem;
opacity: 0.7;
}
.replay-controls .speed.current { opacity: 1; text-decoration: underline; }
.timeline {
display: flex;
align-items: flex-end;
gap: 1px;
height: 14px;
margin: 0.4rem 0.2rem 0.2rem;
}
.tick {
flex: 1 1 0;
min-width: 2px;
height: 6px;
padding: 0;
border: 0;
border-radius: 1px;
background: var(--who, #8d8672);
opacity: 0.45;
cursor: pointer;
}
.tick.turn { height: 9px; opacity: 0.7; }
.tick.hit { height: 12px; opacity: 0.9; }
.tick.gold { height: 14px; opacity: 1; box-shadow: 0 0 0 1px #e0b34a; }
.tick.end { height: 14px; opacity: 1; background: #e9e1cb; }
.tick.current { opacity: 1; outline: 1px solid #fff; outline-offset: 1px; }
.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>