The reel learns to shine: gliding bodies, warp-threaded shots, a clip worth sharing

Three polishes aimed at the instant-replay dream. Other wizards and
creatures now GLIDE between steps in first person instead of blinking
cell to cell — a stride tweens, a teleport still simply arrives. A
projectile whose sight line runs through a warp now flies as two
simultaneous legs under the mouths' rigid motion, so the fireball
plunges into the opening on one side and bursts from the pair on the
other, whichever room the camera stands in. And the saved video is no
longer a bare canvas grab: it composites into a shareable 1280x720
frame with the step's caption burned in — gold actor name, the reel's
own words — and the game's name in the corner, so a clip passed along
explains itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 15:20:13 -04:00
co-authored by Claude Fable 5
parent e8c6b93c71
commit b82d526a79
4 changed files with 167 additions and 25 deletions
+89 -3
View File
@@ -75,6 +75,59 @@
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 === v.you) 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
@@ -152,19 +205,51 @@
return () => cancelAnimationFrame(raf);
});
// --- Save a video: record the first-person canvas as it plays. --------
// --- 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;
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);
const mime = MediaRecorder.isTypeSupported("video/webm;codecs=vp9")
? "video/webm;codecs=vp9" : "video/webm";
const rec = new MediaRecorder(cv.captureStream(60), { mimeType: mime });
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: "video/webm" }));
const a = document.createElement("a");
a.href = url;
@@ -208,7 +293,8 @@
<div class="replay-board" bind:this={stageEl}>
{#if fp}
<FirstPerson view={step.view} povId={step.view.you}
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360} fx={fpFx} />
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
fx={fpFx} posOverride={actorPos} />
{:else}
<Board view={step.view} effects={boardFx} {sightTrace} />
{/if}
+4 -1
View File
@@ -18,6 +18,7 @@
width = 720,
height = 440,
fx = [],
posOverride,
}: {
view: GameView;
povId: string;
@@ -30,6 +31,8 @@
height?: number;
/** Live spell moments: projectiles, impacts, flashes, shakes. */
fx?: FpFx[];
/** Bodies mid-glide: world positions that override the view's cells. */
posOverride?: Record<string, { x: number; y: number }>;
} = $props();
const FOV = Math.PI / 2.9;
@@ -195,7 +198,7 @@
}
// Sprites, far to near, sliced against the depth buffer.
const sprites = billboards(view, povId, tokenArt)
const sprites = billboards(view, povId, tokenArt, posOverride)
.map((b) => project(b, ex, ey))
.filter((s): s is Projected => s !== null);
for (const f of fx) {
+40 -9
View File
@@ -6,6 +6,7 @@
import { fxForEvents } from "../fx";
import { CELL } from "../fx-sprites/geom";
import { castRay, warpMotion } from "./raycast";
import type { GameEvent, GameView } from "@wizwar/engine";
export type FpFx =
@@ -34,6 +35,33 @@ const IMPACT_ART: Record<string, string> = {
shield: "shield", absorb: "shield",
};
/** One flight, possibly seen from two rooms: a projectile whose straight
* line is broken but whose sight line runs through a warp flies as TWO
* simultaneous legs — one in the near mouth's virtual space (visible
* through the opening), one in the far room's real space. Each leg is
* clipped by the depth buffer to the side the camera stands on. */
function projectileLegs(
view: GameView, a: { x: number; y: number }, b: { x: number; y: number },
): { from: { x: number; y: number }; to: { x: number; y: number } }[] {
const len = Math.hypot(b.x - a.x, b.y - a.y);
if (len < 0.05) return [{ from: a, to: b }];
const direct = castRay(view, a.x, a.y, Math.atan2(b.y - a.y, b.x - a.x));
if (!direct.warped && direct.dist >= len - 0.4) return [{ from: a, to: b }];
for (const w of view.board.warps) {
const motion = warpMotion(w);
const vt = motion.toVirtual(b);
const vlen = Math.hypot(vt.x - a.x, vt.y - a.y);
const hit = castRay(view, a.x, a.y, Math.atan2(vt.y - a.y, vt.x - a.x));
if (hit.warped && hit.dist >= vlen - 0.4) {
return [
{ from: a, to: vt },
{ from: motion.toReal(a), to: b },
];
}
}
return [{ from: a, to: b }];
}
/** Translate one step's events into first-person moments with start delays. */
export function fpFxForEvents(
events: GameEvent[], view: GameView, povId: string,
@@ -42,15 +70,18 @@ export function fpFxForEvents(
for (const { fx, delay } of fxForEvents(events, view)) {
if (fx.kind === "fireball" || fx.kind === "bolt" || fx.kind === "waterbolt" || fx.kind === "streak") {
const art = fx.kind === "streak" ? "spark" : fx.kind;
out.push({
fx: {
id: nextId++, kind: "projectile", art,
from: { x: fx.a.x / CELL, y: fx.a.y / CELL },
to: { x: fx.b.x / CELL, y: fx.b.y / CELL },
t0: 0, dur: PROJECTILE_DUR[art]!,
},
delay,
});
for (const leg of projectileLegs(
view, { x: fx.a.x / CELL, y: fx.a.y / CELL }, { x: fx.b.x / CELL, y: fx.b.y / CELL },
)) {
out.push({
fx: {
id: nextId++, kind: "projectile", art,
from: leg.from, to: leg.to,
t0: 0, dur: PROJECTILE_DUR[art]!,
},
delay,
});
}
} else if (fx.kind === "portal") {
out.push({
fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: fx.cell.x + 0.5, y: fx.cell.y + 0.5 }, t0: 0, dur: 550 },
+34 -12
View File
@@ -196,23 +196,52 @@ function mouthAnchor(m: { cell: Cell; side: Side }): { x: number; y: number } {
return { x: c.x, y: c.y };
}
/** Everything standing in the maze that the reel should draw as a sprite. */
/** The rigid motion one warp applies to sight: far-region points map into
* the near mouth's virtual space (where a straight ray would put them),
* and back again. Anchored and rotated exactly as castRay re-enters. */
export function warpMotion(w: GameView["board"]["warps"][number]): {
toVirtual(p: { x: number; y: number }): { x: number; y: number };
toReal(p: { x: number; y: number }): { x: number; y: number };
} {
const delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side];
const a1 = mouthAnchor(w.from);
const a2 = mouthAnchor(w.to);
const c = Math.cos(delta), s = Math.sin(delta);
return {
toVirtual(p) {
const rx = p.x - a2.x, ry = p.y - a2.y;
return { x: a1.x + rx * c + ry * s, y: a1.y - rx * s + ry * c };
},
toReal(p) {
const rx = p.x - a1.x, ry = p.y - a1.y;
return { x: a2.x + rx * c - ry * s, y: a2.y + rx * s + ry * c };
},
};
}
/** Everything standing in the maze that the reel should draw as a sprite.
* `posOverride` maps a player or creature id to an in-flight world
* position — the reel glides bodies between steps instead of blinking
* them from cell to cell. */
export function billboards(
view: GameView,
povId: string,
art: (file: string, cat: "players" | "creatures" | "objects" | "terrain") => string,
posOverride?: Record<string, { x: number; y: number }>,
): Billboard[] {
const out: Billboard[] = [];
for (const p of view.players) {
if (!p.alive || p.id === povId) continue;
const o = posOverride?.[p.id];
out.push({
x: p.position.x + 0.5, y: p.position.y + 0.5,
x: o?.x ?? p.position.x + 0.5, y: o?.y ?? p.position.y + 0.5,
src: art(`wizard-${p.colorIndex}`, "players"), scale: 0.85, rise: 0, label: p.id,
});
}
for (const c of view.creatures) {
const o = posOverride?.[c.id];
out.push({
x: c.position.x + 0.5, y: c.position.y + 0.5,
x: o?.x ?? c.position.x + 0.5, y: o?.y ?? c.position.y + 0.5,
src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind,
});
}
@@ -250,21 +279,14 @@ export function billboards(
// opening, since everything around the mouth is nearer wall.
const real = out.slice();
for (const w of view.board.warps) {
const delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side];
const a1 = mouthAnchor(w.from);
const motion = warpMotion(w);
const a2 = mouthAnchor(w.to);
const cosD = Math.cos(-delta), sinD = Math.sin(-delta);
const inward = SIDE_ANGLE[OPPOSITE[w.to.side]];
const inX = Math.cos(inward), inY = Math.sin(inward);
for (const b of real) {
const rx = b.x - a2.x, ry = b.y - a2.y;
if (rx * inX + ry * inY < -0.2 || Math.hypot(rx, ry) > 8) continue;
out.push({
...b,
x: a1.x + rx * cosD - ry * sinD,
y: a1.y + rx * sinD + ry * cosD,
warped: true,
});
out.push({ ...b, ...motion.toVirtual(b), warped: true });
}
}
return out;