The maze through a wizard's eyes: the first-person raycaster (/?fpv)

Stage one of the replay vision: a canvas raycaster over the GameView —
walls on edges marched by DDA, doors with lintels, firewalls that
flicker, rim warp mouths that shimmer violet, token art billboarded and
occluded per column, torchlit vignette. It renders the view a player
would be SENT, so an illusion this wizard has not seen through stands as
solid as stone in first person too. The /?fpv workshop free-flies a
dealt maze with wall collision, a minimap eye-cone, and ?seed=&x=&y=&dir=
to stand the camera anywhere. Next: drive it from the replay reel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 12:19:03 -04:00
co-authored by Claude Fable 5
parent a4f572a164
commit 3c479eddbb
5 changed files with 466 additions and 1 deletions
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
// The first-person workshop (/?fpv): a real dealt game, one wizard's
// eyes, free-fly controls. No rules run here — the camera walks where
// walls allow and the maze is exactly what viewFor would send that
// player at the table, beliefs and all.
import { createGame, viewFor, cellKey } from "@wizwar/engine";
import FirstPerson from "./FirstPerson.svelte";
import { canWalk } from "./raycast";
const q = new URLSearchParams(location.search);
const seed = Number(q.get("seed") ?? 42);
const { state: dealt } = createGame({
playerIds: ["Wanderer", "Rival", "Stranger"],
seed,
sets: ["basic", "expansion1"],
});
const povId = "Wanderer";
const view = viewFor(dealt, povId);
const start = view.players.find((p) => p.id === povId)!.position;
// ?x=&y=&dir= override the spawn — for standing the camera anywhere.
const DIR_ANGLE: Record<string, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
let x = $state((q.has("x") ? Number(q.get("x")) : start.x) + 0.5);
let y = $state((q.has("y") ? Number(q.get("y")) : start.y) + 0.5);
let facing = $state(DIR_ANGLE[q.get("dir") ?? ""] ?? 0);
// Held keys drive per-frame motion: turning is free, walking asks the
// board (workshop collision — walls and shut doors stop you).
const held = new Set<string>();
function onKey(e: KeyboardEvent, down: boolean) {
const k = e.key.toLowerCase();
if (["arrowup", "arrowdown", "arrowleft", "arrowright", "w", "a", "s", "d"].includes(k)) {
e.preventDefault();
if (down) held.add(k); else held.delete(k);
}
}
$effect(() => {
let raf = 0;
let last = performance.now();
const tick = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const turn = (held.has("arrowleft") || held.has("a") ? -1 : 0) +
(held.has("arrowright") || held.has("d") ? 1 : 0);
facing += turn * dt * 2.6;
const fwd = (held.has("arrowup") || held.has("w") ? 1 : 0) +
(held.has("arrowdown") || held.has("s") ? -1 : 0);
if (fwd !== 0) {
const speed = fwd * dt * 2.2;
const nx = x + Math.cos(facing) * speed;
const ny = y + Math.sin(facing) * speed;
const cell = { x: Math.floor(x), y: Math.floor(y) };
// Crossing a cell boundary asks the board's permission.
const crossX = Math.floor(nx) !== cell.x;
const crossY = Math.floor(ny) !== cell.y;
const okX = !crossX || canWalk(view, cell, nx > x ? "E" : "W");
const okY = !crossY || canWalk(view, cell, ny > y ? "S" : "N");
if (okX) x = nx;
if (okY) y = ny;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
// Minimap geometry (top-down, one small square per cell).
const MM = 9;
const cells = Object.keys(view.board.cells).map((k) => {
const [cx, cy] = k.split(",").map(Number) as [number, number];
return { cx, cy };
});
const mmWalls = Object.entries(view.board.edges)
.filter(([, e]) => e !== "open")
.map(([k]) => {
const [kind, coords] = k.split(":") as [string, string];
const [ex, ey] = coords.split(",").map(Number) as [number, number];
return { kind, ex, ey };
});
</script>
<svelte:window onkeydown={(e) => onKey(e, true)} onkeyup={(e) => onKey(e, false)} />
<div class="fpv-shop">
<h1>The maze, through {povId}'s eyes</h1>
<p class="hint">
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
<code>?fpv&seed=N</code>, stand anywhere with <code>&x=&y=&dir=</code>.
</p>
<div class="stage">
<FirstPerson {view} {povId} {x} {y} {facing} />
<svg class="minimap" viewBox="0 0 {view.board.width * MM} {view.board.height * MM}">
{#each cells as c (cellKey({ x: c.cx, y: c.cy }))}
<rect x={c.cx * MM} y={c.cy * MM} width={MM} height={MM} class="mm-cell" />
{/each}
{#each mmWalls as w (w.kind + w.ex + "," + w.ey)}
{#if w.kind === "V"}
<line x1={(w.ex + 1) * MM} y1={w.ey * MM} x2={(w.ex + 1) * MM} y2={(w.ey + 1) * MM} class="mm-wall" />
{:else}
<line x1={w.ex * MM} y1={(w.ey + 1) * MM} x2={(w.ex + 1) * MM} y2={(w.ey + 1) * MM} class="mm-wall" />
{/if}
{/each}
<g transform={`translate(${x * MM} ${y * MM}) rotate(${(facing * 180) / Math.PI})`}>
<polygon points="6,0 -3,4 -3,-4" class="mm-eye" />
</g>
</svg>
</div>
</div>
<style>
.fpv-shop {
min-height: 100vh;
background: #171a20;
color: #d8d2c0;
font-family: "Archivo Narrow", system-ui, sans-serif;
padding: 1.2rem;
}
h1 { font-size: 1.2rem; margin: 0 0 0.3rem; }
.hint { color: #8d8672; margin: 0 0 0.8rem; }
.hint code { color: #b7ae94; }
.stage { display: flex; gap: 1rem; align-items: flex-start; flex-wrap: wrap; }
.stage > :global(.fpv-canvas) { flex: 1 1 480px; }
.minimap { width: 220px; background: #101318; border: 1px solid #3a3428; border-radius: 4px; }
.mm-cell { fill: #1d212b; stroke: #262b36; stroke-width: 0.5; }
.mm-wall { stroke: #9a927c; stroke-width: 1.6; stroke-linecap: square; }
.mm-eye { fill: #e0b34a; }
</style>