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
+86 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import Board from "./Board.svelte";
import FirstPerson from "./fpv/FirstPerson.svelte";
import { humanize } from "./net.svelte";
import { scheduleFx, type BoardFx } from "./fx";
import { prefs } from "./prefs.svelte";
@@ -17,6 +18,8 @@
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(false);
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
const lines = $derived(
step.events.map(humanize).filter((l): l is string => l !== null),
@@ -49,6 +52,70 @@
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) {
if (e.key === "Escape") onclose();
if (e.key === "ArrowRight") { playing = false; idx = Math.min(idx + 1, steps.length - 1); }
@@ -64,10 +131,17 @@
<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-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>
</header>
<div class="replay-board">
<Board view={step.view} effects={boardFx} {sightTrace} />
{#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} />
{/if}
</div>
<div class="replay-caption">
<strong>{step.actor}</strong>
@@ -126,8 +200,18 @@
color: #e9e1cb;
}
.replay-count { font-size: 0.8rem; color: #8d8672; }
.replay-skip {
.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;