Third pass, scoped from 7a32370. Three blind reviewers (engine, web,
server/tools) each concluded the work is coherent engineering with
seam-level tells; every finding was verified before touching a line.
Session biography left the comments: the bot brain's heuristics no
longer cite the opponent who taught them, the RNRX coma parenthetical
and the thief-chase citation are gone, the seat-wallet comments state
their invariants without the war stories, and process-named test
groups now name the behaviors they pin. The incident record lives
where history belongs — commit messages and the ledgers.
Structural dedup: one VISIONSTONE one-edge-sight loop serves both
LOS paths; one creature-arrival touch handler serves walking and
warp-stepping (error text aligned); one facingWedge helper draws both
keymap ribbons; one spriteVisibleInCol rule serves the draw pass and
the hover test (which also stops re-sorting per pointermove); and
deepestFacing joins the director, replacing four copied scans.
Test hardening exposed real rot the tells were hiding: the tight
CreatureState cast caught two literals with a bogus field masking
three missing ones; the number-hoarding rig had NEVER run (its
column didn't exist on seed 42 — it now carves its own geometry);
the bank-guard rig now drives the whole table to an arrival
assertion; the bent-trace test walls off straight sight so the bend
must answer. Silent `return`-on-rig-failure became loud throws, and
can-never-fail assertions were removed.
Sweep-up: the eyeTurn ghost comment, the stacked leave() doc
comments (leave now delegates to leaveLocal), the dead ternary in
the seat client, the kick handler's name-coercion drift, kick ledger
lines gain timestamps, archiveRoomFile reuses fileFor, the RULES_REV
alias retires in favor of the engine constant, hitTest un-exports,
the NUL-sentinel hover shape becomes an honest "none" variant, and
the steering holds get named constants. The bezel's stride cluster
also centers per the table's note.
288 tests, 24 ledgers verified, all workspaces typecheck.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
682 lines
27 KiB
Svelte
682 lines
27 KiB
Svelte
<script lang="ts">
|
||
import Board from "./Board.svelte";
|
||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||
import { untrack } from "svelte";
|
||
import { humanize } from "./net.svelte";
|
||
import { tokenArt } from "./art";
|
||
import { scheduleFx, type BoardFx } from "./fx";
|
||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||
import { castRay, edgeMid } from "./fpv/raycast";
|
||
import { deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
// 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)]!);
|
||
/** 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 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
|
||
// 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 = []; };
|
||
});
|
||
|
||
/** 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 || !prefs.flourishes) 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;
|
||
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 = gatherGlides(before, v, povId);
|
||
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;
|
||
$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 = 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 a = deepestFacing(v, ax, ay);
|
||
const d = castRay(v, ax, ay, a).dist;
|
||
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;
|
||
heldView = pv;
|
||
camPlan = { idx: i2, holdMs: pv ? 420 / speed : 0 };
|
||
if (pv) {
|
||
const t = setTimeout(() => (heldView = null), 420 / speed);
|
||
return () => clearTimeout(t);
|
||
}
|
||
return;
|
||
};
|
||
let targetFacing = camF;
|
||
let aimed = false;
|
||
if (camReady && !willCut && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; }
|
||
else if (aim?.self) {
|
||
// Summoned at your own feet: no turn can show it — step outside
|
||
// yourself and watch it grow beside you.
|
||
return takeCutaway(me.position.x + 0.5, me.position.y + 0.5);
|
||
}
|
||
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) {
|
||
return takeCutaway(ax, ay);
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
// 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 = 0;
|
||
if (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.
|
||
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 (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;
|
||
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
||
camReady = true;
|
||
return setSlate(wasReady && aim ? 380 / speed : 0);
|
||
}
|
||
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 clearSlate = setSlate(aimed && aim ? turnMs + 120 / speed : 0);
|
||
const closingMs = Math.min(300, Math.abs(closingArc) * 200) / 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 if (closingArc !== 0 && t < turnMs + walkMs + closingMs) {
|
||
cam.x = tx; cam.y = ty;
|
||
const w = (t - turnMs - walkMs) / closingMs;
|
||
cam.facing = fromF + arc + closingArc * w * w * (3 - 2 * w);
|
||
} else {
|
||
cam.facing = fromF + arc + closingArc;
|
||
cam.x = tx; cam.y = ty;
|
||
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, 1224 - (textX - 28)));
|
||
// 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} width={640} height={360}
|
||
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
|
||
{:else}
|
||
<Board view={step.view} effects={boardFx} {sightTrace} />
|
||
{/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>
|
||
</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;
|
||
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 {
|
||
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>
|