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:
co-authored by
Claude Fable 5
parent
a4f572a164
commit
3c479eddbb
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user