/** * The ray maths. Pure numbers in, pure numbers out: no canvas, no DOM, no * state — so every decision the first-person view makes can be tested * without a browser. * * The camera is a flat projection plane at `projection()` pixels from the * eye. A column maps to an angle through that plane, not by sweeping the * field of view evenly, which is what keeps straight walls straight. Depth * is always the perpendicular distance to the plane: the fish-eye correction * and the sprite z-clip both depend on the two being the same measure. * * Nothing here allocates. The DDA writes its answer into a result object the * caller owns, so a 900-column frame allocates nothing at all. */ export const FOV = (75 * Math.PI) / 180; /** Pixels from the eye to the projection plane for a screen this wide. */ export const projection = (width, fov = FOV) => width / 2 / Math.tan(fov / 2); /** The angle of the ray through screen column `col`, relative to the view. */ export function columnOffset(col, width, fov = FOV) { const cameraX = (2 * (col + 0.5)) / width - 1; return Math.atan(cameraX * Math.tan(fov / 2)); } /** The world angle of the ray through screen column `col`. */ export const columnAngle = (col, width, view, fov = FOV) => view + columnOffset(col, width, fov); /** The screen column a world angle falls on, fractional, off-screen allowed. */ export function angleColumn(offset, width, fov = FOV) { return (width / 2) * (1 + Math.tan(offset) / Math.tan(fov / 2)); } /** A wall `tall` units high at this depth covers this many pixels. */ export const sliceHeight = (perp, proj, tall) => (tall * proj) / Math.max(perp, 1e-6); /** A fresh result object for `castRay` to fill. One per renderer, not per ray. */ export const hitResult = () => ({ dist: 0, side: 0, col: 0, row: 0, wall: false }); /** * March a ray through the tile grid until it meets a solid tile. * * `solid(col, row)` answers for the grid; `out` is filled with the distance * travelled, which side of the tile was struck (0 = an east or west face, * 1 = a north or south face) and the tile itself. A ray that runs out of * range reports `wall: false` at `max`. */ export function castRay(solid, x, y, angle, tile, max, out) { const dx = Math.cos(angle); const dy = Math.sin(angle); let col = Math.floor(x / tile); let row = Math.floor(y / tile); const deltaX = dx === 0 ? Infinity : Math.abs(tile / dx); const deltaY = dy === 0 ? Infinity : Math.abs(tile / dy); const stepX = dx < 0 ? -1 : 1; const stepY = dy < 0 ? -1 : 1; let sideX = dx === 0 ? Infinity : dx < 0 ? (x - col * tile) / -dx : ((col + 1) * tile - x) / dx; let sideY = dy === 0 ? Infinity : dy < 0 ? (y - row * tile) / -dy : ((row + 1) * tile - y) / dy; if (solid(col, row)) { out.dist = 0; out.side = 0; out.col = col; out.row = row; out.wall = true; return out; } for (;;) { let dist; let side; if (sideX < sideY) { dist = sideX; col += stepX; sideX += deltaX; side = 0; } else { dist = sideY; row += stepY; sideY += deltaY; side = 1; } if (dist > max) { out.dist = max; out.side = side; out.col = col; out.row = row; out.wall = false; return out; } if (solid(col, row)) { out.dist = dist; out.side = side; out.col = col; out.row = row; out.wall = true; return out; } } } /** * How lit a wall slice is: one over the distance, floored so a far wall is * dim rather than black, and a north–south face darker than an east–west one * so a corner reads as a corner. */ export function wallLight(perp, side, tile) { const far = 1 / (1 + (perp / (tile * 7)) ** 2); const face = side === 1 ? 0.62 : 1; return Math.max(0.12, Math.min(1, 0.25 + 0.75 * far)) * face; } /** A floor or ceiling band dims the same way, by how far down the screen it is. */ export const bandLight = (rowFromHorizon, halfHeight) => Math.max(0.1, Math.min(1, rowFromHorizon / Math.max(1, halfHeight))); /** * Where a thing in the world lands on the screen. * * Returns the fractional column of its centre, its perpendicular depth and * the pixel height of something `tall` units high there. `behind` is true * when it is at or past the camera plane and must not be drawn at all. */ export function project(px, py, view, sx, sy, width, proj, tall, out, fov = FOV) { const dx = sx - px; const dy = sy - py; const dist = Math.hypot(dx, dy); let offset = Math.atan2(dy, dx) - view; while (offset > Math.PI) offset -= Math.PI * 2; while (offset < -Math.PI) offset += Math.PI * 2; const perp = dist * Math.cos(offset); out.dist = dist; out.offset = offset; out.perp = perp; out.behind = perp <= 1 || Math.abs(offset) > fov / 2 + 0.8; out.column = out.behind ? -1 : angleColumn(offset, width, fov); out.size = out.behind ? 0 : sliceHeight(perp, proj, tall); return out; } export const projectResult = () => ({ dist: 0, offset: 0, perp: 0, behind: true, column: -1, size: 0 }); /** * Is a sprite column in front of the wall drawn there? A sprite slightly * nearer than the wall still counts as hidden by a hair of slack, so a body * flush against a corner does not bleed through it. */ export const spriteColumnVisible = (spritePerp, wallPerp) => spritePerp < wallPerp - 2; /** * How much smoke a ray crosses between the eye and `dist`. * * The chord of the circle that falls inside the segment, as a fraction of the * circle's width: a ray that clips the edge of a smoke barely fogs, one that * goes through the middle fogs completely. That is what makes a smoke read as * a volume you are standing in rather than a disc painted on the floor. */ export function smokeAlong(x, y, dx, dy, cx, cy, r, dist) { const ox = x - cx; const oy = y - cy; const b = ox * dx + oy * dy; const c = ox * ox + oy * oy - r * r; const disc = b * b - c; if (disc <= 0) return 0; const root = Math.sqrt(disc); const enter = Math.max(0, -b - root); const leave = Math.min(dist, -b + root); if (leave <= enter) return 0; return Math.min(1, (leave - enter) / (r * 1.1)); }