// 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 = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 }; export const OPPOSITE: Record = { 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 = { 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, ): 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, ): 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; }