Files
wizwar6e/packages/web/src/fpv/raycast.ts
T
Eric WagonerandClaude Fable 5 cd9ce9fddf Sprites respect the window they are seen through
DUKK's floor-to-ceiling white sliver, decoded: the sprite gate only
asked "did this column's ray warp?" — so a REAL body standing in front
of a warp mouth was clipped out of the mouth's columns (leaving a
baffling sliver of a huge close sprite), and a body virtualized
through warp A could paint columns bent by warp B. Every column now
remembers WHICH warp bent its ray and how far away that mouth stood;
a real body paints a column only nearer than its mouth (in front of
the window), and a virtual one only through its own warp's columns,
beyond the mouth. Projectile legs carry the same identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 20:07:41 -04:00

435 lines
19 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;
/** Which warp (index in board.warps) the ray first bent through, and
* how far along the ray that mouth stood — the gate a sprite must be
* beyond (its own warp) or in front of (any) to paint this column. */
warpId?: number;
warpDist?: number;
/** 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" };
/** Unit vector along an edge's lane axis (the direction `along` grows). */
const LANE_AXIS: Record<Side, [number, number]> = {
E: [0, 1], W: [0, 1], N: [1, 0], S: [1, 0],
};
/** Does this warp pair MIRROR the lane? A traversal is a proper rigid
* rotation; for half the side pairings the rotated entry axis points
* AGAINST the exit edge's axis, and preserving the raw lane there would
* smuggle in a reflection — rays crossing over at the mouth, parallax
* inverted, the far room stretching as the eye moves. */
export function warpLaneMirrored(from: Side, to: Side): boolean {
const d = SIDE_ANGLE[OPPOSITE[to]] - SIDE_ANGLE[from];
const [x, y] = LANE_AXIS[from];
const rx = x * Math.cos(d) - y * Math.sin(d);
const ry = x * Math.sin(d) + y * Math.cos(d);
const [ex2, ey] = LANE_AXIS[to];
return rx * ex2 + ry * ey < 0;
}
/** 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 warpId: number | undefined;
let warpDist: number | undefined;
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, warpId, warpDist, 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 wIdx = view.board.warps.findIndex(
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
);
const warp = wIdx >= 0 ? view.board.warps[wIdx]! : undefined;
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, ghost, doorway };
if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; }
// Step through: re-enter at the paired mouth, heading inward, the
// lane carried by the same proper rotation the heading turns by —
// mirrored for the pairings whose axes land head-to-head.
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
angle = angle + (exitHeading - SIDE_ANGLE[warp.from.side]);
const c = warp.to.cell;
const lane = warpLaneMirrored(warp.from.side, warp.to.side) ? 1 - texU : 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, warpId, warpDist, ghost, doorway };
}
cx = nx;
cy = ny;
}
if (!warped || traversal === 2) break;
}
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, 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 —
* visible only through THAT warp's columns, beyond its mouth. */
warped?: boolean;
warpId?: number;
}
/** 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);
// Mirrored pairings anchor the far mouth at its OTHER corner — the
// lane runs backwards there, and this keeps the motion a pure
// rotation matching castRay's traversal.
const a2 = { ...mouthAnchor(w.to) };
if (warpLaneMirrored(w.from.side, w.to.side)) {
const [lx, ly] = LANE_AXIS[w.to.side];
a2.x += lx;
a2.y += ly;
}
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 (let wi = 0; wi < view.board.warps.length; wi++) {
const w = view.board.warps[wi]!;
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, warpId: wi });
}
}
return out;
}