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
+180
View File
@@ -0,0 +1,180 @@
<script lang="ts">
// The maze through one wizard's eyes: a canvas raycaster over the
// GameView. Columns of wall shaded by distance and facing; token art
// billboarded for whatever stands in the corridors, occluded per column
// by the same depth buffer the walls wrote.
import { castRay, billboards, type Billboard } from "./raycast";
import { tokenArt } from "../art";
import type { GameView } from "@wizwar/engine";
let {
view,
povId,
x,
y,
facing,
width = 720,
height = 440,
}: {
view: GameView;
povId: string;
/** Eye position in world units (cell centers are n + 0.5). */
x: number;
y: number;
/** Radians; 0 faces east, matching the board's +x. */
facing: number;
width?: number;
height?: number;
} = $props();
const FOV = Math.PI / 2.9;
let canvas: HTMLCanvasElement;
// Token art loads lazily; a sprite draws once its image has arrived.
const images = new Map<string, HTMLImageElement>();
function imageFor(src: string): HTMLImageElement | null {
let img = images.get(src);
if (!img) {
img = new Image();
img.src = src;
images.set(src, img);
}
return img.complete && img.naturalWidth > 0 ? img : null;
}
/** Base colors per face; distance-shading multiplies them down. */
const FACE: Record<string, [number, number, number]> = {
wall: [126, 118, 100],
stone: [96, 96, 104],
door: [130, 92, 48],
rim: [82, 76, 66],
firewall: [214, 92, 28],
warp: [96, 60, 160],
};
function draw(time: number) {
const ctx = canvas?.getContext("2d");
if (!ctx) return;
const W = width, H = height, half = H / 2;
// Sky and floor: torchlight fading to the dark of the maze.
const sky = ctx.createLinearGradient(0, 0, 0, half);
sky.addColorStop(0, "#0d0c12");
sky.addColorStop(1, "#2a2620");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, half);
const floor = ctx.createLinearGradient(0, half, 0, H);
floor.addColorStop(0, "#241f18");
floor.addColorStop(1, "#0e0c09");
ctx.fillStyle = floor;
ctx.fillRect(0, half, W, H - half);
// Walls, one ray per column; remember each column's depth for sprites.
const zbuf = new Float64Array(W);
for (let col = 0; col < W; col++) {
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
const hit = castRay(view, x, y, rayAngle);
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
zbuf[col] = depth;
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
const [r, g, b] = FACE[hit.kind] ?? FACE.wall!;
let shade = Math.min(1, 1.35 / (1 + depth * 0.45));
if (hit.axis === "y") shade *= 0.8; // N/S faces sit in shadow
let [cr, cg, cb] = [r * shade, g * shade, b * shade];
if (hit.kind === "firewall") {
const flicker = 0.85 + 0.15 * Math.sin(time / 90 + hit.u * 17 + col * 0.15);
[cr, cg, cb] = [cr * flicker, cg * flicker * 0.9, cb * flicker * 0.6];
}
if (hit.kind === "warp") {
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)];
}
ctx.fillStyle = `rgb(${cr | 0},${cg | 0},${cb | 0})`;
ctx.fillRect(col, half - wallH / 2, 1, wallH);
// Doors wear a lintel and panel seam so they read as doors.
if (hit.kind === "door") {
ctx.fillStyle = `rgba(0,0,0,${0.35 * shade})`;
ctx.fillRect(col, half - wallH / 2, 1, Math.max(1, wallH * 0.06));
if (Math.abs(hit.u - 0.5) < 0.015) ctx.fillRect(col, half - wallH / 2, 1, wallH);
}
}
// Sprites, far to near, sliced against the depth buffer.
const sprites = billboards(view, povId, tokenArt)
.map((b) => project(b))
.filter((s): s is Projected => s !== null)
.sort((a, b) => b.depth - a.depth);
for (const s of sprites) {
const img = imageFor(s.src);
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
if (s.depth >= zbuf[col]!) continue;
const texX = ((col - s.left) / (s.right - s.left));
if (img) {
ctx.drawImage(
img,
texX * img.naturalWidth, 0, Math.max(1, img.naturalWidth / (s.right - s.left)), img.naturalHeight,
col, s.top, 1, s.bottom - s.top,
);
} else {
ctx.fillStyle = "rgba(200,190,160,0.6)";
ctx.fillRect(col, s.top, 1, s.bottom - s.top);
}
}
}
// A whisper of vignette holds the torchlit mood together.
const vig = ctx.createRadialGradient(W / 2, half, H * 0.35, W / 2, half, H * 0.95);
vig.addColorStop(0, "rgba(0,0,0,0)");
vig.addColorStop(1, "rgba(0,0,0,0.45)");
ctx.fillStyle = vig;
ctx.fillRect(0, 0, W, H);
}
interface Projected {
src: string;
depth: number;
left: number;
right: number;
top: number;
bottom: number;
}
function project(b: Billboard): Projected | null {
const relX = b.x - x, relY = b.y - y;
const depth = relX * Math.cos(facing) + relY * Math.sin(facing);
if (depth < 0.15) return null;
const side = -relX * Math.sin(facing) + relY * Math.cos(facing);
const W = width, H = height, half = H / 2;
const screenX = W / 2 + (side / depth) * (W / 2) / Math.tan(FOV / 2);
const wallH = H / depth;
const size = wallH * b.scale;
const bottom = half + wallH / 2 - b.rise * wallH;
return {
src: b.src, depth,
left: screenX - size / 2, right: screenX + size / 2,
top: bottom - size, bottom,
};
}
// Redraw every frame: the fire flickers and the warps swirl even when
// the camera holds still.
$effect(() => {
let raf = 0;
const loop = (t: number) => { draw(t); raf = requestAnimationFrame(loop); };
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
});
</script>
<canvas bind:this={canvas} {width} {height} class="fpv-canvas"></canvas>
<style>
.fpv-canvas {
display: block;
width: 100%;
max-width: 100%;
image-rendering: pixelated;
border: 1px solid #3a3428;
border-radius: 4px;
background: #0d0c12;
}
</style>
+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>
+153
View File
@@ -0,0 +1,153 @@
// First-person raycasting over the maze. The world is the GameView's board
// — one unit per cell, walls living on EDGES between cells rather than in
// them — so a ray marches cell boundaries (DDA) and asks each crossing
// what stands there. Crucially the board arrives from viewFor(), which has
// already rendered this wizard's BELIEFS: an illusion they have not seen
// through is a wall here too, and the deception carries into first person.
import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine";
import type { GameView } from "@wizwar/engine";
export interface Hit {
/** Distance along the ray (perpendicular-corrected by the caller). */
dist: number;
/** What the ray struck. */
kind: "wall" | "door" | "firewall" | "stone" | "rim" | "warp";
/** 0..1 across the struck face (texture coordinate). */
u: number;
/** Vertical faces get a different shade than horizontal ones. */
axis: "x" | "y";
}
/** Is this edge passable to the EYE (rays), and if not, what is it? */
function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
const e = view.board.edges[key] ?? "open";
if (e === "open") return null;
if (e === "door") {
// An open or held door is a doorway; the eye passes through the gap.
if (view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key)) return null;
return "door";
}
if (e === "firewall") return "firewall";
return "wall";
}
/**
* March one ray from (ox, oy) at `angle` and return the first thing that
* stops the eye. Off-board is the maze's rim: a wall, unless the crossing
* is a warp mouth (then a shimmering opening).
*/
export function castRay(view: GameView, ox: number, oy: number, angle: number): Hit {
const dx = Math.cos(angle);
const dy = Math.sin(angle);
let cx = Math.floor(ox);
let cy = Math.floor(oy);
const stepX = dx > 0 ? 1 : -1;
const stepY = dy > 0 ? 1 : -1;
// Distance along the ray between successive x / y grid lines.
const dDistX = Math.abs(1 / (dx || 1e-9));
const dDistY = Math.abs(1 / (dy || 1e-9));
let sideDistX = (dx > 0 ? cx + 1 - ox : ox - cx) * dDistX;
let sideDistY = (dy > 0 ? cy + 1 - oy : oy - cy) * dDistY;
for (let i = 0; i < 64; i++) {
const crossingX = sideDistX < sideDistY;
const dist = crossingX ? sideDistX : sideDistY;
// The edge being crossed lives between the current cell and the next.
let key: string;
let nx = cx, ny = cy;
if (crossingX) {
key = edgeKey({ x: stepX > 0 ? cx : cx - 1, y: cy }, "E");
nx = cx + stepX;
sideDistX += dDistX;
} else {
key = edgeKey({ x: cx, y: stepY > 0 ? cy : cy - 1 }, "S");
ny = cy + stepY;
sideDistY += dDistY;
}
const axis: Hit["axis"] = crossingX ? "x" : "y";
const u = crossingX
? (oy + dist * dy) % 1
: (ox + dist * dx) % 1;
const texU = u < 0 ? u + 1 : u;
const blocked = edgeObstacle(view, key);
if (blocked) return { dist, kind: blocked, u: texU, axis };
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
if (offBoard) {
const mouth = view.board.warps.some(
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) &&
w.from.side === (crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N")),
);
return { dist, kind: mouth ? "warp" : "rim", u: texU, axis };
}
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
return { dist, kind: "stone", u: texU, axis };
}
cx = nx;
cy = ny;
}
return { dist: 64, kind: "rim", u: 0, axis: "x" };
}
/** Can a wizard's body (not just their eye) cross this edge? Workshop
* collision: walls and closed doors stop you, fire and warps do not. */
export function canWalk(view: GameView, from: Cell, side: Side): boolean {
const key = edgeKey(from, side);
const e = view.board.edges[key] ?? "open";
if (e === "wall") return false;
if (e === "door" &&
!view.openDoorEdges.includes(key) && !view.heldDoorEdges.includes(key)) return false;
const to = {
x: from.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
y: from.y + (side === "S" ? 1 : side === "N" ? -1 : 0),
};
if (!view.board.cells[cellKey(to)]) return false; // the rim (warp-walk later)
if (view.squareContents[cellKey(to)]?.kind === "stone") return false;
return true;
}
export interface Billboard {
/** World position (cell-centered). */
x: number;
y: number;
/** Art URL, resolved by the caller (token art respects preferences). */
src: string;
/** Fraction of wall height (a wizard stands taller than a chest). */
scale: number;
/** Lifted off the floor (0 = feet on the ground). */
rise: number;
label: string;
}
/** Everything standing in the maze that the reel should draw as a sprite. */
export function billboards(
view: GameView,
povId: string,
art: (file: string, cat: "players" | "creatures" | "objects" | "terrain") => string,
): Billboard[] {
const out: Billboard[] = [];
for (const p of view.players) {
if (!p.alive || p.id === povId) continue;
out.push({
x: p.position.x + 0.5, 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) {
out.push({
x: c.position.x + 0.5, y: c.position.y + 0.5,
src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind,
});
}
for (const t of view.treasures) {
if (!t.position || t.carriedBy) continue;
out.push({
x: t.position.x + 0.5, y: t.position.y + 0.5,
src: art(`treasure-${(view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0) % 6}`, "objects"),
scale: 0.4, rise: 0, label: "treasure",
});
}
return out;
}