Files
wizwar6e/packages/web/src/fpv/raycast.ts
T
Eric WagonerandClaude Fable 5 700db70d4e A cell-wide sprite is now actually a cell wide
Rubble drawn to span its gap stopped short of the posts, and the
ledger of blame is arithmetic: billboard widths scaled by the vertical
pixel ratio (H/depth) when horizontal pixels-per-world-unit is the
focal length over depth — about 1.4x larger. Every "one cell wide"
volume rendered at seventy percent. Sprites that declare an aspect now
mean WORLD width and use the horizontal scale, so rubble reaches post
to post and hedges brush both walls; plain square token sprites keep
their familiar pixel shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:39:26 -04:00

395 lines
17 KiB
TypeScript

// 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";
import { objectArt } from "../art";
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";
/** The un-wrapped along-face coordinate: u plus the world cell it lies
* in, so multi-cell surfaces (a long wall of fire) can read as one. */
worldU: number;
/** The edge struck, for state the renderer dresses (jammed locks,
* untested illusions). */
edge?: string;
/** The eye reached this through a warp: haze it other-worldly. */
warped?: boolean;
/** The first known-illusion edge the ray crossed before its solid hit:
* the eye passes, but a translucent ghost of a wall stands there. */
ghost?: { dist: number; u: number; worldU: number; axis: "x" | "y" };
/** The first OPEN doorway the ray passed through: the renderer hangs
* the lintel of the frame across it. */
doorway?: { dist: number; u: number };
/** The struck slab is a doorway's jamb post, dressed in frame stone. */
frame?: boolean;
}
export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
export const OPPOSITE: Record<Side, Side> = { E: "W", W: "E", N: "S", S: "N" };
/** Is this edge passable to the EYE (rays), and if not, what is it?
* Doors always report as doors; how far each stands open — fully for an
* open or held door, mid-swing during an animation — is castRay's call. */
function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
const e = view.board.edges[key] ?? "open";
if (e === "open") return null;
if (e === "door") 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).
*/
/** Half-thickness per material: walls are masonry, doors joinery, fire a
* sheet. The slab gives every wall END a visible cap face, so corners and
* doorways read as three-dimensional stone rather than paper. */
const HALF_THICK: Record<string, number> = { wall: 0.07, rim: 0.07, door: 0.05, firewall: 0.025 };
/** Ray vs axis-aligned box; returns entry distance and the axis of the
* face struck, or null for a miss. */
function slabHit(
ox: number, oy: number, dx: number, dy: number,
minX: number, maxX: number, minY: number, maxY: number,
): { t: number; axis: "x" | "y" } | null {
const ix = 1 / (dx || 1e-9);
const iy = 1 / (dy || 1e-9);
let tx1 = (minX - ox) * ix, tx2 = (maxX - ox) * ix;
if (tx1 > tx2) [tx1, tx2] = [tx2, tx1];
let ty1 = (minY - oy) * iy, ty2 = (maxY - oy) * iy;
if (ty1 > ty2) [ty1, ty2] = [ty2, ty1];
const tNear = Math.max(tx1, ty1);
const tFar = Math.min(tx2, ty2);
if (tNear > tFar || tFar < 0) return null;
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
}
export function castRay(
view: GameView, ox: number, oy: number, angle: number,
/** Transient door openness (0 shut - 1 wide), keyed by edge; doors the
* view holds open stand at 1 without an entry. Wolf3D-style: an open
* door has slid along its own edge, the gap growing from u=0. */
doors?: Record<string, number>,
): Hit {
// Sight runs THROUGH warps as the rules say it does: a ray that reaches
// a mouth re-enters at the paired mouth and marches on, its hits hazed.
let baseDist = 0;
let warped = false;
let ghost: Hit["ghost"];
let doorway: Hit["doorway"];
for (let traversal = 0; traversal < 3; traversal++) {
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;
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++) {
// Every blocked edge of the current cell stands as a slab; the
// nearest strike inside this cell's span wins.
const exit = Math.min(sideDistX, sideDistY);
let best: { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean } | null = null;
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
const key = edgeKey({ x: cx, y: cy }, side);
const kind = edgeObstacle(view, key);
if (!kind) return;
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY);
if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return;
let slide = 0;
let frame = false;
if (kind === "door") {
const open = doors?.[key] ??
(view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key) ? 1 : 0);
if (open >= 0.98) {
// A door standing open is a DOORWAY: posts at both ends, a
// lintel hung across, and the way through open to the eye.
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
const u = along - Math.floor(along);
const JAMB = 0.08;
if (u > JAMB && u < 1 - JAMB) {
if (!doorway) doorway = { dist: baseDist + h.t, u };
return;
}
frame = true;
} else if (open > 0) {
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
const u = along - Math.floor(along);
if (u < open - 0.02) return; // the ray sails through the opening
slide = open;
}
}
best = { ...h, kind, slide, edge: key, frame };
};
const hw = (side: Side) =>
HALF_THICK[edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)) ?? "wall"] ?? 0.07;
trySide("E", cx + 1 - hw("E"), cx + 1 + hw("E"), cy, cy + 1);
trySide("W", cx - hw("W"), cx + hw("W"), cy, cy + 1);
trySide("S", cx, cx + 1, cy + 1 - hw("S"), cy + 1 + hw("S"));
trySide("N", cx, cx + 1, cy - hw("N"), cy + hw("N"));
if (best !== null) {
const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean };
const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
const u = along - Math.floor(along);
// A sliding door carries its texture with it: sample past the gap.
return {
dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide),
axis: b.axis, worldU: along, edge: b.edge, warped, ghost, doorway,
...(b.frame ? { frame: true } : {}),
};
}
// No slab in this cell: advance through the open boundary.
const crossingX = sideDistX < sideDistY;
const dist = crossingX ? sideDistX : sideDistY;
let nx = cx, ny = cy;
if (crossingX) { nx = cx + stepX; sideDistX += dDistX; }
else { ny = cy + stepY; sideDistY += dDistY; }
const axis: Hit["axis"] = crossingX ? "x" : "y";
const along = crossingX ? oy + dist * dy : ox + dist * dx;
const u0 = along % 1;
const texU = u0 < 0 ? u0 + 1 : u0;
const crossSide: Side = crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N");
if (!ghost && view.knownIllusionEdges.includes(edgeKey({ x: cx, y: cy }, crossSide))) {
ghost = { dist: baseDist + dist, u: texU, worldU: along, axis };
}
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
if (offBoard) {
const side: Side = crossSide;
const warp = view.board.warps.find(
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
);
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, ghost, doorway };
// Step through: re-enter at the paired mouth, heading inward, the
// offset along the edge preserved (a wraparound keeps its lane).
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
angle = angle + (exitHeading - SIDE_ANGLE[warp.from.side]);
const c = warp.to.cell;
const lane = texU;
if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + lane; }
else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + lane; }
else if (warp.to.side === "S") { ox = c.x + lane; oy = c.y + 1 - 1e-4; }
else { ox = c.x + lane; oy = c.y + 1e-4; }
baseDist += dist;
warped = true;
i = 64; // restart the DDA from the far mouth
break;
}
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, ghost, doorway };
}
cx = nx;
cy = ny;
}
if (!warped || traversal === 2) break;
}
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, ghost, doorway };
}
/** Can a wizard's body (not just their eye) cross this edge? Workshop
* collision: walls and closed doors stop you; fire does not, and an open
* rim edge carries you only where a warp mouth waits. */
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 view.board.warps.some(
(w) => cellKey(w.from.cell) === cellKey(from) && w.from.side === side,
);
}
if (view.squareContents[cellKey(to)]?.kind === "stone") return false;
return true;
}
/** The midpoint of one cell edge, in world units. */
export function edgeMid(cell: Cell, side: Side): { x: number; y: number } {
if (side === "E") return { x: cell.x + 1, y: cell.y + 0.5 };
if (side === "W") return { x: cell.x, y: cell.y + 0.5 };
if (side === "S") return { x: cell.x + 0.5, y: cell.y + 1 };
return { x: cell.x + 0.5, y: cell.y };
}
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;
/** Width as a multiple of height (1 = square; 2 = a cell-wide hedge). */
aspect?: number;
/** Translucency (jello, dust); 1 or absent is opaque. */
alpha?: number;
/** Drawn additively, as light (warp pillars). */
glow?: boolean;
/** Pulled this much nearer for draw order: a hedge wins the tie
* against the wizard standing in its own square, who then peeks over. */
bias?: number;
/** Procedural stand-in name while the painted file loads. */
fallback?: string;
/** A reflection seen through a warp mouth, not the thing itself. */
warped?: boolean;
}
/** The along=0 corner of a warp mouth — the anchor castRay's lane
* preservation measures from. */
function mouthAnchor(m: { cell: Cell; side: Side }): { x: number; y: number } {
const c = m.cell;
if (m.side === "E") return { x: c.x + 1, y: c.y };
if (m.side === "W") return { x: c.x, y: c.y };
if (m.side === "S") return { x: c.x, y: c.y + 1 };
return { x: c.x, y: c.y };
}
/** The rigid motion one warp applies to sight: far-region points map into
* the near mouth's virtual space (where a straight ray would put them),
* and back again. Anchored and rotated exactly as castRay re-enters. */
export function warpMotion(w: GameView["board"]["warps"][number]): {
toVirtual(p: { x: number; y: number }): { x: number; y: number };
toReal(p: { x: number; y: number }): { x: number; y: number };
} {
const delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side];
const a1 = mouthAnchor(w.from);
const a2 = mouthAnchor(w.to);
const c = Math.cos(delta), s = Math.sin(delta);
return {
toVirtual(p) {
const rx = p.x - a2.x, ry = p.y - a2.y;
return { x: a1.x + rx * c + ry * s, y: a1.y - rx * s + ry * c };
},
toReal(p) {
const rx = p.x - a1.x, ry = p.y - a1.y;
return { x: a2.x + rx * c - ry * s, y: a2.y + rx * s + ry * c };
},
};
}
/** Everything standing in the maze that the reel should draw as a sprite.
* `posOverride` maps a player or creature id to an in-flight world
* position — the reel glides bodies between steps instead of blinking
* them from cell to cell. */
export function billboards(
view: GameView,
povId: string,
art: (file: string, cat: "players" | "creatures" | "objects" | "terrain") => string,
posOverride?: Record<string, { x: number; y: number }>,
): Billboard[] {
const out: Billboard[] = [];
for (const p of view.players) {
if (!p.alive || p.id === povId) continue;
const o = posOverride?.[p.id];
out.push({
x: o?.x ?? p.position.x + 0.5, y: o?.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) {
const o = posOverride?.[c.id];
out.push({
x: o?.x ?? c.position.x + 0.5, y: o?.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",
});
}
// The floor's furniture, standing in the room. Bushes fill the square
// to half height (a wizard peeks over); the killer ooze is a whole
// translucent cube of jello; dust billows. Pits, slime, tacks, and
// dimensional warps are painted INTO the floor by the decal pass, and
// stone is a wall to the rays — none of those need a sprite.
for (const [k, content] of Object.entries(view.squareContents)) {
const [tx, ty] = k.split(",").map(Number) as [number, number];
const at = { x: tx + 0.5, y: ty + 0.5 };
if (content.kind === "thornbush" || content.kind === "rosebush") {
out.push({
...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind,
scale: 0.5, aspect: 2.1, rise: 0, bias: 0.18, label: content.kind,
});
} else if (content.kind === "ooze") {
out.push({
...at, src: "/terrain3d/jello.png", fallback: "jello",
scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze",
});
} else if (content.kind === "dust") {
out.push({
...at, src: "/terrain3d/dust.png", fallback: "dust",
scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust",
});
} else if (content.kind === "safe") {
out.push({
...at, src: "/terrain3d/safe.png", fallback: "safe",
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe",
});
}
}
// Dimensional warp mouths throw pillars of light from their floor rings.
for (const w of view.dimWarps) {
for (const cellAt of [w.a, w.b]) {
out.push({
x: cellAt.x + 0.5, y: cellAt.y + 0.5,
src: "/terrain3d/warpglow.png", fallback: "warpglow",
scale: 0.95, aspect: 0.5, rise: 0, alpha: 0.6, glow: true, label: "dimwarp",
});
}
}
for (const [k, cards] of Object.entries(view.groundObjects)) {
const file = cards[0] ? objectArt(cards[0].cardId) : null;
if (!file) continue;
const [tx, ty] = k.split(",").map(Number) as [number, number];
out.push({ x: tx + 0.5, y: ty + 0.5, src: art(file, "objects"), scale: 0.25, rise: 0, label: cards[0]!.cardId });
}
// Rays see through warp mouths; the standing world should follow. Each
// sprite near a far mouth also appears in the near mouth's frame, under
// the same rigid motion the rays use — the zbuffer clips it to the
// opening, since everything around the mouth is nearer wall.
const real = out.slice();
for (const w of view.board.warps) {
const motion = warpMotion(w);
const a2 = mouthAnchor(w.to);
const inward = SIDE_ANGLE[OPPOSITE[w.to.side]];
const inX = Math.cos(inward), inY = Math.sin(inward);
for (const b of real) {
const rx = b.x - a2.x, ry = b.y - a2.y;
if (rx * inX + ry * inY < -0.2 || Math.hypot(rx, ry) > 8) continue;
out.push({ ...b, ...motion.toVirtual(b), warped: true });
}
}
return out;
}