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>