import { NRGBA } from "./ascii_textureloader.js"; import { TileMap, Tile } from "./ascii_tile_map.js"; import { AsciiWindow } from "./ascii_window.js"; /** * Which side of a tile did the ray strike */ export const Side = { X_AXIS: 0, Y_AXIS: 1, }; class RayCollision { mapX = 0; mapY = 0; rayLength = 0; side = Side.X_AXIS; /** @type {Tile} */ tile; } class RayCastResult { hitWall = false; hitSprite = false; wallCollision = new RayCollision(); /** @type {RayCollision[]} */ collisions = []; } /** * @typedef {object} FirstPersonRendererOptions * @property {string} wallChar * @property {NRGBA} floorColor * @property {string} floorChar * @property {NRGBA} ceilingColor * @property {string} ceilingChar * @property {number} viewDistance * @property {number} fov */ /** * @type {FirstPersonRendererOptions} */ export const DefaultRendererOptions = { wallChar: "W", floorColor: new NRGBA(0.365, 0.165, 0.065), floorChar: "f", ceilingColor: new NRGBA(0.3, 0.3, 0.3), ceilingChar: "c", fadeOutColor: new NRGBA(0.3, 0.3, 0.3), viewDistance: 5, fov: Math.PI / 3, // 60 degrees - good for spooky }; export class FirstPersonRenderer { /** * @param {AsciiWindow} aWindow the window we render onto. * @param {TileMap} map * @param {Texture[]} textures * @param {FirstPersonRendererOptions} options */ constructor(aWindow, map, textures, options) { /** @constant @readonly @type {TileMap} */ this.map = map; /** @constant @readonly @type {AsciiWindow} */ this.window = aWindow; /** @constant @readonly @type {number} */ this.fov = options.fov ?? DefaultRendererOptions.fov; /** @constant @readonly @type {number} */ this.viewDistance = options.viewDistance ?? DefaultRendererOptions.viewDistance; /** @constant @readonly @type {Texture[]} */ this.textures = textures; /** @constant @readonly @type {string} */ this.wallChar = options.wallChar ?? DefaultRendererOptions.wallChar; /** @constant @readonly @type {NRGBA} */ this.floorColor = options.floorColor ?? DefaultRendererOptions.floorColor; /** @constant @readonly @type {string} */ this.floorChar = options.floorChar ?? DefaultRendererOptions.floorChar; /** @constant @readonly @type {NRGBA} */ this.ceilingColor = options.ceilingColor ?? DefaultRendererOptions.ceilingColor; /** @constant @readonly @type {string} */ this.ceilingChar = options.ceilingChar ?? DefaultRendererOptions.ceilingChar; /** * Pre-computed colors to use when drawing floors, ceilings and "fadeout" * * There is one entry for every screen row. * Each entry contains a color to use when drawing floors, ceilings, and "fadeout". * * @constant @readonly @type {Array>} */ this.shades = []; /** * Pre-compute the shades variable */ this.computeShades(); } computeShades() { const screenHeight = this.window.height; const halfScreenHeight = screenHeight / 2; const lineHeight = Math.floor(screenHeight / this.viewDistance); const minY = Math.floor(-lineHeight / 2 + halfScreenHeight); // if y lower than minY, then we're painting ceiling const maxY = Math.floor(lineHeight / 2 + halfScreenHeight); // if y higher than maxY then we're painting floor for (let y = 0; y < screenHeight; y++) { if (y < minY) { // // y is smaller than minY. This means we're painting above // the walls, i.e. painting the ceiling. // The closer y is to minY, the farther away this part of the // ceiling is. // // High diff => near // Low diff => far // const diff = minY - y; this.shades.push([this.ceilingChar, this.ceilingColor.mulledRGB(diff / minY).toCSS()]); } else if (y >= maxY) { // // Floor // const diff = y - maxY; this.shades.push([this.floorChar, this.floorColor.mulledRGB(diff / minY).toCSS()]); } else { // // The darkness at the end of the tunnel // this.shades.push([" ", "#000"]); } } } renderFrame(posX, posY, dirAngle, commit = true) { const benchmarkStart = performance.now(); const screenWidth = this.window.width; /** @type {Map 5) { console.log("Rendering took a long time", { renderTime }); } if (commit) { requestAnimationFrame(() => { const benchmarkStart = performance.now(); this.window.commitToDOM(); const commitTime = performance.now() - benchmarkStart; if (commitTime > 5) { console.log("Updating DOM took a long time:", { commitTime }); } }); } } /** * Render a column on the screen where the ray hit a wall. * @param {number} x * @param {RayCastResult} ray * @param {number} rayDirX * @param {number} rayDirY * @param {number} angleOffset for far (in radians) is this column from the middle of the screen * * @protected */ renderColumn(x, ray, rayDirX, rayDirY, angleOffset) { // // // // Check if we hit anything at all // if (ray.collisions.length === 0) { // // // // We didn't hit anything. Just paint floor, wall, and darkness // for (let y = 0; y < this.window.height; y++) { // const [char, color] = this.shades[y]; // this.window.put(x, y, char, color); // } // return; // } // // // ALTERNATIVE always paint floor and ceiling for (let y = 0; y < this.window.height; y++) { const [char, color] = this.shades[y]; this.window.put(x, y, char, color); } for (const { rayLength, side, sampleU, tile } of ray.collisions) { let distance = Math.max(rayLength * Math.cos(angleOffset), 1e-12); // Avoid divide by zero // // Calculate perspective. // const screenHeight = this.window.height; const lineHeight = Math.round(screenHeight / distance); // using round() because floor() gives aberrations when distance == (n + 0.500) const halfScreenHeight = screenHeight / 2; const halfLineHeight = lineHeight / 2; let minY = Math.floor(halfScreenHeight - halfLineHeight); let maxY = Math.floor(halfScreenHeight + halfLineHeight); let unsafeMinY = minY; // can be lower than zero - it happens when we get so close to a wall we cannot see top or bottom if (minY < 0) { minY = 0; } if (maxY >= screenHeight) { maxY = screenHeight - 1; } // // Pick texture (here grid value decides which texture) // const texture = this.textures[tile.textureId]; for (let y = 0; y < screenHeight; y++) { // // Are we hitting the ceiling? // if (y < minY || y > maxY) { const [char, color] = this.shades[y]; this.window.put(x, y, char, color); continue; } // // DEBUG LINES // if (y === minY) { // this.window.put(x, y, "m", "#0F0"); // continue; // } // if (y === maxY) { // this.window.put(x, y, "M", "#F00"); // continue; // } // // Map screen y to texture y let sampleV = (y - unsafeMinY) / lineHeight; // y- coordinate of the texture point to sample const color = texture.sample(sampleU, sampleV); if (!Number.isFinite(color.a)) { throw new Error("Waaat"); } if (color.a === 0) { continue; } // // North-south walls are shaded differently from east-west walls let shade = side === Side.X_AXIS ? 0.8 : 1.0; // MAGIC NUMBERS // // Dim walls that are far away const lightLevel = 1 - rayLength / this.viewDistance; // // Darken the image color.mulRGB(shade * lightLevel); this.window.put(x, y, tile.sprite ? "#" : this.wallChar, color.toCSS()); // MAGIC CONSTANT "S" } } } /** * @param {number} camX x-coordinate of the camera (is the same * @param {number} camY y-coordinate of the camera * @param {number} dirX x-coordinate of the normalized vector of the viewing direction of the camera. * @param {number} dirX y-coordinate of the normalized vector of the viewing direction of the camera. * @param {Set} coodsChecked * * @returns {RayCastResult} * */ castRay(camX, camY, dirX, dirY, coordsChecked) { // Current map square let mapX = Math.floor(camX); let mapY = Math.floor(camY); // Length of ray from one x or y-side to next x or y-side const deltaDistX = dirX === 0 ? 1e15 : Math.abs(1 / dirX); const deltaDistY = dirY === 0 ? 1e15 : Math.abs(1 / dirY); // Step direction (+1 or -1) and initial sideDist[XY] let stepX; // When DDA takes a horizontal step (on the map), how far should it move? let stepY; // When DDA takes a vertical step (on the map), how far should it move? let sideDistX; // How far has the ray moved horizontally (on the map) ? let sideDistY; // How far has the ray moved vertically (on the map) ? let side = Side.X_AXIS; // // Calculate how to move along the x-axis if (dirX < 0) { stepX = -1; // step left along the x-axis sideDistX = (camX - mapX) * deltaDistX; // we've moved from the camera to the left edge of the tile } else { stepX = 1; // step right along the x-axis sideDistX = (mapX + 1.0 - camX) * deltaDistX; // we've moved from the camera to the right edge of the tile } // // Calculate how to move along the y-axis if (dirY < 0) { stepY = -1; // // step down along the y-axis sideDistY = (camY - mapY) * deltaDistY; // we've move from the camera to the bottom edge of the tile } else { stepY = 1; // // step up along the y-axis sideDistY = (mapY + 1.0 - camY) * deltaDistY; // we've moved from the camera to the top edge of the tile } /** * Any sprites the ray has hit on its way. * They are ordered in reverse order of closeness to the camera, * so that if they are drawn in their array ordering, they will * appear in the correct order on the screen. * * @type {RayCastResult} */ const result = new RayCastResult(); // DDA loop while (!result.hitWall) { // // Check if ray is longer than viewDistance if (Math.min(sideDistX, sideDistY) > this.viewDistance) { return result; } // // Check for out of bounds if (mapX < 0 || mapX >= this.map.width || mapY < 0 || mapY >= this.map.height) { return result; } let wallDist, sampleU; // // Should we step in the x- or y-direction // DDA dictates we always move along the shortest vector if (sideDistX < sideDistY) { // // Move horizontally // sideDistX += deltaDistX; mapX += stepX; side = Side.X_AXIS; // Ray hit the east or west edge of the wall-tile wallDist = (mapX - camX + (1 - stepX) / 2) / dirX; sampleU = (camY + wallDist * dirY) % 1; if (dirX > 0) { sampleU = 1 - sampleU; } } else { // // Move vertically // sideDistY += deltaDistY; mapY += stepY; side = Side.Y_AXIS; // Ray hit the north or south edge of the wall-tile wallDist = (mapY - camY + (1 - stepY) / 2) / dirY; sampleU = (camX + wallDist * dirX) % 1; if (dirY < 0) { sampleU = 1 - sampleU; } } const tile = this.map.get(mapX, mapY); coordsChecked.set(this.map.tileIdx(mapX, mapY), tile); // // -------------------------- // No collision? Move on // -------------------------- if (!tile.collision) { continue; } const rayLength = Math.hypot( wallDist * dirX, // wallDist * dirY, // ); // // Prepend the element to the array so rear-most sprites // appear first in the array, // enabling us to simply draw from back to front const collision = new RayCollision(); collision.mapX = mapX; collision.mapY = mapY; collision.rayLength = rayLength; collision.tile = tile; collision.sampleU = sampleU; collision.side = side; result.collisions.unshift(collision); // // -------------------------------- // Algorithm stops if the ray hits // a wall. // ------------------------------- if (tile.wall) { result.hitWall = true; return result; } } } } if (Math.PI < 0 && AsciiWindow && TileMap && Tile) { ("STFU Linda"); }