The reel through your own eyes: first-person replay (stage two)

Sight now runs THROUGH the warps as the rules say: a ray reaching a
mouth re-enters at its pair and marches on, the far side swimming in
violet haze. The floor's furniture joins the scene — bushes stand tall,
safes squat, slime and tacks and pits lie low, dropped daggers glint.
And the replay reel gains a 👁 toggle: relive the catch-up or the whole
tale through your wizard's eyes, the camera turning before it strides,
easing between cells, cutting on teleports, and turning toward whoever
acted while you stood still. The /?fpv workshop grows &demo=1 — two
automatons play a stretch and the real Replay component reruns it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 12:29:11 -04:00
co-authored by Claude Fable 5
parent dae01969b0
commit 65092f50af
4 changed files with 223 additions and 51 deletions
+85 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Board from "./Board.svelte"; import Board from "./Board.svelte";
import FirstPerson from "./fpv/FirstPerson.svelte";
import { humanize } from "./net.svelte"; import { humanize } from "./net.svelte";
import { scheduleFx, type BoardFx } from "./fx"; import { scheduleFx, type BoardFx } from "./fx";
import { prefs } from "./prefs.svelte"; import { prefs } from "./prefs.svelte";
@@ -17,6 +18,8 @@
let idx = $state(0); let idx = $state(0);
let playing = $state(true); let playing = $state(true);
let speed = $state(1); let speed = $state(1);
/** Watch the board from above, or relive it through your own eyes. */
let fp = $state(false);
const step = $derived(steps[Math.min(idx, steps.length - 1)]!); const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
const lines = $derived( const lines = $derived(
step.events.map(humanize).filter((l): l is string => l !== null), step.events.map(humanize).filter((l): l is string => l !== null),
@@ -49,6 +52,70 @@
return () => clearInterval(t); return () => clearInterval(t);
}); });
// --- 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 === v.you);
if (!me) return;
const tx = me.position.x + 0.5;
const ty = me.position.y + 0.5;
const dx = tx - cam.x;
const dy = ty - cam.y;
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.
const actor = v.players.find((p) => p.id === step.actor);
let targetFacing = cam.facing;
if (dist > 0.05) targetFacing = Math.atan2(dy, dx);
else if (actor && actor.id !== v.you &&
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
targetFacing = Math.atan2(actor.position.y + 0.5 - ty, actor.position.x + 0.5 - tx);
}
if (!camReady || dist > 1.6) {
// First frame, or a leap the legs cannot explain: cut.
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
camReady = true;
return;
}
const fromX = cam.x, fromY = cam.y, fromF = cam.facing;
const arc = shortestArc(fromF, targetFacing);
const turnMs = Math.min(260, Math.abs(arc) * 180) / speed;
const walkMs = (dist > 0.05 ? 420 : 0) / speed;
const t0 = performance.now();
let raf = 0;
const tick = (now: number) => {
const t = now - t0;
if (t < turnMs) {
cam.facing = fromF + arc * (t / turnMs);
} else if (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);
});
function onkeydown(e: KeyboardEvent) { function onkeydown(e: KeyboardEvent) {
if (e.key === "Escape") onclose(); if (e.key === "Escape") onclose();
if (e.key === "ArrowRight") { playing = false; idx = Math.min(idx + 1, steps.length - 1); } if (e.key === "ArrowRight") { playing = false; idx = Math.min(idx + 1, steps.length - 1); }
@@ -64,10 +131,17 @@
<header class="replay-head"> <header class="replay-head">
<span class="replay-title">{steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span> <span class="replay-title">{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> <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>
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button> <button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
</header> </header>
<div class="replay-board"> <div class="replay-board">
{#if fp}
<FirstPerson view={step.view} povId={step.view.you}
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360} />
{:else}
<Board view={step.view} effects={boardFx} {sightTrace} /> <Board view={step.view} effects={boardFx} {sightTrace} />
{/if}
</div> </div>
<div class="replay-caption"> <div class="replay-caption">
<strong>{step.actor}</strong> <strong>{step.actor}</strong>
@@ -126,8 +200,18 @@
color: #e9e1cb; color: #e9e1cb;
} }
.replay-count { font-size: 0.8rem; color: #8d8672; } .replay-count { font-size: 0.8rem; color: #8d8672; }
.replay-skip { .replay-eyes {
margin-left: auto; 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; background: none;
border: none; border: none;
color: #a49c86; color: #a49c86;
+5
View File
@@ -89,6 +89,11 @@
const swirl = 0.75 + 0.25 * Math.sin(time / 240 + hit.u * 9); const swirl = 0.75 + 0.25 * Math.sin(time / 240 + hit.u * 9);
[cr, cg, cb] = [cr * swirl, cg * swirl, cb * (0.9 + 0.3 * swirl)]; [cr, cg, cb] = [cr * swirl, cg * swirl, cb * (0.9 + 0.3 * swirl)];
} }
if (hit.warped) {
// Seen through a warp: the far side swims in violet haze.
const haze = 0.82 + 0.08 * Math.sin(time / 300 + col * 0.05);
[cr, cg, cb] = [cr * 0.7 * haze, cg * 0.6 * haze, Math.min(255, cb * 1.1 + 26)];
}
ctx.fillStyle = `rgb(${cr | 0},${cg | 0},${cb | 0})`; ctx.fillStyle = `rgb(${cr | 0},${cg | 0},${cb | 0})`;
ctx.fillRect(col, half - wallH / 2, 1, wallH); ctx.fillRect(col, half - wallH / 2, 1, wallH);
// Doors wear a lintel and panel seam so they read as doors. // Doors wear a lintel and panel seam so they read as doors.
+40 -2
View File
@@ -3,12 +3,17 @@
// eyes, free-fly controls. No rules run here — the camera walks where // eyes, free-fly controls. No rules run here — the camera walks where
// walls allow and the maze is exactly what viewFor would send that // walls allow and the maze is exactly what viewFor would send that
// player at the table, beliefs and all. // player at the table, beliefs and all.
import { createGame, viewFor, cellKey } from "@wizwar/engine"; import { createGame, viewFor, cellKey, applyCommand, automatonCommand, automatonFallback, redactEvent } from "@wizwar/engine";
import type { GameEvent, GameState, GameView } from "@wizwar/engine";
import FirstPerson from "./FirstPerson.svelte"; import FirstPerson from "./FirstPerson.svelte";
import Replay from "../Replay.svelte";
import { canWalk } from "./raycast"; import { canWalk } from "./raycast";
const q = new URLSearchParams(location.search); const q = new URLSearchParams(location.search);
const seed = Number(q.get("seed") ?? 42); const seed = Number(q.get("seed") ?? 42);
/** ?demo=1: two automatons play a stretch, then the reel replays it —
* the real Replay component, toggleable into first person. */
const demoReel = q.has("demo");
const { state: dealt } = createGame({ const { state: dealt } = createGame({
playerIds: ["Wanderer", "Rival", "Stranger"], playerIds: ["Wanderer", "Rival", "Stranger"],
seed, seed,
@@ -64,6 +69,34 @@
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf);
}); });
// The demo reel: drive the clockwork for a while, keeping a step per
// command exactly as the server's catch-up would build it.
type Step = { seq: number; actor: string; events: GameEvent[]; view: GameView };
function buildDemoSteps(): Step[] {
let s: GameState = dealt;
const actingSeat = (g: GameState) =>
g.stack?.waitingOn ?? g.pendingDiscard ?? g.chaosPending?.queue[0] ??
g.outOfTurnWindow?.playerId ?? g.players[g.turn.activeIndex]!.id;
const steps: Step[] = [];
for (let i = 0; i < 80 && s.phase === "playing"; i++) {
const seat = actingSeat(s);
const cmd = automatonCommand(viewFor(s, seat), "hunter", "adept")
?? automatonFallback(viewFor(s, seat), "adept");
let r = applyCommand(s, seat, cmd);
if (!r.ok) r = applyCommand(s, seat, automatonFallback(viewFor(s, seat), "adept"));
if (!r.ok) r = applyCommand(s, seat, { type: "endTurn", draw: 0 });
if (!r.ok) break;
s = r.state;
steps.push({
seq: i, actor: seat,
events: r.events.map((e) => redactEvent(e, povId)).filter((e): e is GameEvent => e !== null),
view: viewFor(s, povId),
});
}
return steps;
}
const demoSteps = demoReel ? buildDemoSteps() : [];
// Minimap geometry (top-down, one small square per cell). // Minimap geometry (top-down, one small square per cell).
const MM = 9; const MM = 9;
const cells = Object.keys(view.board.cells).map((k) => { const cells = Object.keys(view.board.cells).map((k) => {
@@ -81,12 +114,17 @@
<svelte:window onkeydown={(e) => onKey(e, true)} onkeyup={(e) => onKey(e, false)} /> <svelte:window onkeydown={(e) => onKey(e, true)} onkeyup={(e) => onKey(e, false)} />
{#if demoReel}
<Replay steps={demoSteps} onclose={() => (location.search = "?fpv")} />
{/if}
<div class="fpv-shop"> <div class="fpv-shop">
<h1>The maze, through {povId}'s eyes</h1> <h1>The maze, through {povId}'s eyes</h1>
<p class="hint"> <p class="hint">
Arrows / WASD walk and turn. Walls and shut doors refuse you; fire and Arrows / WASD walk and turn. Walls and shut doors refuse you; fire and
stranger things do not — this workshop has eyes, not rules. Seed with stranger things do not — this workshop has eyes, not rules. Seed with
<code>?fpv&seed=N</code>, stand anywhere with <code>&x=&y=&dir=</code>. <code>?fpv&seed=N</code>, stand anywhere with <code>&x=&y=&dir=</code>,
or watch the clockwork's reel with <code>&demo=1</code> (toggle 👁).
</p> </p>
<div class="stage"> <div class="stage">
<FirstPerson {view} {povId} {x} {y} {facing} /> <FirstPerson {view} {povId} {x} {y} {facing} />
+55 -10
View File
@@ -7,6 +7,7 @@
import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine"; import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine";
import type { GameView } from "@wizwar/engine"; import type { GameView } from "@wizwar/engine";
import { objectArt, TERRAIN_ART } from "../art";
export interface Hit { export interface Hit {
/** Distance along the ray (perpendicular-corrected by the caller). */ /** Distance along the ray (perpendicular-corrected by the caller). */
@@ -17,8 +18,13 @@ export interface Hit {
u: number; u: number;
/** Vertical faces get a different shade than horizontal ones. */ /** Vertical faces get a different shade than horizontal ones. */
axis: "x" | "y"; axis: "x" | "y";
/** The eye reached this through a warp: haze it other-worldly. */
warped?: boolean;
} }
const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
const OPPOSITE: Record<Side, Side> = { E: "W", W: "E", N: "S", S: "N" };
/** Is this edge passable to the EYE (rays), and if not, what is it? */ /** Is this edge passable to the EYE (rays), and if not, what is it? */
function edgeObstacle(view: GameView, key: string): Hit["kind"] | null { function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
const e = view.board.edges[key] ?? "open"; const e = view.board.edges[key] ?? "open";
@@ -38,6 +44,11 @@ function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
* is a warp mouth (then a shimmering opening). * is a warp mouth (then a shimmering opening).
*/ */
export function castRay(view: GameView, ox: number, oy: number, angle: number): Hit { export function castRay(view: GameView, ox: number, oy: number, angle: number): Hit {
// Sight runs THROUGH warps as the rules say it does: a ray that reaches
// a mouth re-enters at the paired mouth and marches on, its hits hazed.
let baseDist = 0;
let warped = false;
for (let traversal = 0; traversal < 3; traversal++) {
const dx = Math.cos(angle); const dx = Math.cos(angle);
const dy = Math.sin(angle); const dy = Math.sin(angle);
let cx = Math.floor(ox); let cx = Math.floor(ox);
@@ -66,29 +77,43 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
sideDistY += dDistY; sideDistY += dDistY;
} }
const axis: Hit["axis"] = crossingX ? "x" : "y"; const axis: Hit["axis"] = crossingX ? "x" : "y";
const u = crossingX const u = crossingX ? (oy + dist * dy) % 1 : (ox + dist * dx) % 1;
? (oy + dist * dy) % 1
: (ox + dist * dx) % 1;
const texU = u < 0 ? u + 1 : u; const texU = u < 0 ? u + 1 : u;
const blocked = edgeObstacle(view, key); const blocked = edgeObstacle(view, key);
if (blocked) return { dist, kind: blocked, u: texU, axis }; if (blocked) return { dist: baseDist + dist, kind: blocked, u: texU, axis, warped };
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })]; const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
if (offBoard) { if (offBoard) {
const mouth = view.board.warps.some( const side: Side = crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N");
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && const warp = view.board.warps.find(
w.from.side === (crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N")), (w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
); );
return { dist, kind: mouth ? "warp" : "rim", u: texU, axis }; if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, warped };
// Step through: re-enter at the paired mouth, heading inward, the
// offset along the edge preserved (a wraparound keeps its lane).
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
angle = angle + (exitHeading - SIDE_ANGLE[warp.from.side]);
const c = warp.to.cell;
const along = texU;
if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + along; }
else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + along; }
else if (warp.to.side === "S") { ox = c.x + along; oy = c.y + 1 - 1e-4; }
else { ox = c.x + along; oy = c.y + 1e-4; }
baseDist += dist;
warped = true;
i = 64; // restart the DDA from the far mouth
break;
} }
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") { if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
return { dist, kind: "stone", u: texU, axis }; return { dist: baseDist + dist, kind: "stone", u: texU, axis, warped };
} }
cx = nx; cx = nx;
cy = ny; cy = ny;
} }
return { dist: 64, kind: "rim", u: 0, axis: "x" }; if (!warped || traversal === 2) break;
}
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", warped };
} }
/** Can a wizard's body (not just their eye) cross this edge? Workshop /** Can a wizard's body (not just their eye) cross this edge? Workshop
@@ -149,5 +174,25 @@ export function billboards(
scale: 0.4, rise: 0, label: "treasure", scale: 0.4, rise: 0, label: "treasure",
}); });
} }
// The floor's furniture: bushes stand tall, hazards squat low. Stone is
// a wall to the rays and needs no sprite.
const TERRAIN_SCALE: Record<string, number> = {
thornbush: 0.7, rosebush: 0.7, safe: 0.55, ooze: 0.35, slime: 0.3,
tacks: 0.25, pit: 0.35, dust: 0.6,
};
for (const [k, content] of Object.entries(view.squareContents)) {
if (content.kind === "stone") continue;
const file = TERRAIN_ART[content.kind];
const scale = TERRAIN_SCALE[content.kind];
if (!file || !scale) continue;
const [tx, ty] = k.split(",").map(Number) as [number, number];
out.push({ x: tx + 0.5, y: ty + 0.5, src: art(file, "terrain"), scale, rise: 0, label: content.kind });
}
for (const [k, cards] of Object.entries(view.groundObjects)) {
const file = cards[0] ? objectArt(cards[0].cardId) : null;
if (!file) continue;
const [tx, ty] = k.split(",").map(Number) as [number, number];
out.push({ x: tx + 0.5, y: ty + 0.5, src: art(file, "objects"), scale: 0.25, rise: 0, label: cards[0]!.cardId });
}
return out; return out;
} }