290 lines
12 KiB
JavaScript
290 lines
12 KiB
JavaScript
import { NRGBA, Texture } from "./ascii_textureloader";
|
|
import { TileMap } from "./ascii_tile_map";
|
|
import { AsciiWindow } from "./ascii_window";
|
|
|
|
export class FirstPersonRenderer2 {
|
|
constructor(aWindow, map, wallTex, floorTex, ceilTex) {
|
|
if (!(aWindow instanceof AsciiWindow)) {
|
|
throw new Error("Invalid type for aWindow");
|
|
}
|
|
if (!(map instanceof TileMap)) {
|
|
throw new Error("Invalid type for map");
|
|
}
|
|
if (!(wallTex instanceof Texture && floorTex instanceof Texture && ceilTex instanceof Texture)) {
|
|
throw new Error("Invalid type for texture");
|
|
}
|
|
|
|
/** @type {AsciiWindow} */
|
|
this.window = aWindow;
|
|
|
|
/** @type {TileMap} */
|
|
this.map = map;
|
|
|
|
/** @type {Texture} */
|
|
this.wallTextures = wallTex;
|
|
/** @type {Texture} */
|
|
this.floorTexture = floorTex;
|
|
/** @type {Texture} */
|
|
this.ceilTexture = ceilTex;
|
|
|
|
/** @type {number} */
|
|
this.fov = Math.PI / 3; // 60 degrees
|
|
/** @type {number} */
|
|
this.viewDist = 5.0;
|
|
/** @type {NRGBA} */
|
|
this.fadeOutColor = new NRGBA(0.03, 0.03, 0.03);
|
|
}
|
|
|
|
renderFrame(map, px, py, pAngle, floorCtx, ceilCtx, wallCtx) {
|
|
const setPixel = (x, y, color, char = "#") => {
|
|
this.window.put(x, y, char, color);
|
|
};
|
|
|
|
const mapW = this.map.width;
|
|
const mapH = this.map.height;
|
|
const screenW = this.window.width;
|
|
const screenH = this.window.height;
|
|
const halfH = screenH / 2;
|
|
const nearZero = 1e-9;
|
|
|
|
const fov = this.fov;
|
|
const viewDist = this.viewDist;
|
|
const fadeOutColor = this.fadeOutColor;
|
|
|
|
//
|
|
// Texture image data and dimensions
|
|
//
|
|
const floorTex = floorCtx.canvas;
|
|
const ceilTex = ceilCtx.canvas;
|
|
const wallTex = wallCtx.canvas;
|
|
const floorImg = floorCtx.getImageData(0, 0, floorTex.width, floorTex.height).data;
|
|
const ceilImg = ceilCtx.getImageData(0, 0, ceilTex.width, ceilTex.height).data;
|
|
const wallImg = wallCtx.getImageData(0, 0, wallTex.width, wallTex.height).data;
|
|
|
|
//
|
|
// For each screen column, cast a ray
|
|
//
|
|
for (let x = 0; x < screenW; x++) {
|
|
//
|
|
// compute ray angle by linear interpolation across FOV (angle-based)
|
|
//
|
|
// The Chad Method
|
|
// const cameraX = (2 * x) / screenW - 1; // -1 .. 1
|
|
// const rayAngle = pAngle + Math.atan(cameraX * Math.tan(fov / 2)); // approximate steer by angle
|
|
//
|
|
//
|
|
// The Claude method - pretty sure it ONLY works when fov is 60º
|
|
const rayAngle = pAngle - fov / 2 + (x / screenW) * fov;
|
|
|
|
//
|
|
// Direction vector for rayAngle
|
|
//
|
|
const dirX = Math.cos(rayAngle);
|
|
const dirY = Math.sin(rayAngle);
|
|
|
|
//
|
|
// DDA init
|
|
//
|
|
let mapX = Math.floor(px);
|
|
let mapY = Math.floor(py);
|
|
let stepX;
|
|
let stepY;
|
|
let sideDistX;
|
|
let sideDistY;
|
|
const deltaDistX = Math.abs(1 / (dirX === 0 ? nearZero : dirX));
|
|
const deltaDistY = Math.abs(1 / (dirY === 0 ? nearZero : dirY));
|
|
|
|
//
|
|
// Calculate how far to step for each cell of progress with the DDA algorithm
|
|
// This depends on which quadrant of the coordinate system the ray is traversing
|
|
//
|
|
if (dirX < 0) {
|
|
stepX = -1;
|
|
sideDistX = (px - mapX) * deltaDistX;
|
|
} else {
|
|
stepX = 1;
|
|
sideDistX = (mapX + 1.0 - px) * deltaDistX;
|
|
}
|
|
if (dirY < 0) {
|
|
stepY = -1;
|
|
sideDistY = (py - mapY) * deltaDistY;
|
|
} else {
|
|
stepY = 1;
|
|
sideDistY = (mapY + 1.0 - py) * deltaDistY;
|
|
}
|
|
|
|
// DDA loop
|
|
let hit = false;
|
|
let side = 0;
|
|
let rayLen = 0; // The length of the ray in steps (t-units), not map coordinate units.
|
|
let steps = 0;
|
|
const maxSteps = Math.ceil(viewDist * Math.max(deltaDistX, deltaDistY)) + Math.max(mapW, mapH); // safe cap
|
|
|
|
while (steps++ < maxSteps) {
|
|
//
|
|
// Do the DDA thing
|
|
// Lengthen the ray in one step that takes it
|
|
// to the next tile border in either the x- or y-
|
|
// direction, depending on which distance
|
|
// is shorter.
|
|
//
|
|
if (sideDistX < sideDistY) {
|
|
sideDistX += deltaDistX;
|
|
mapX += stepX;
|
|
side = 0;
|
|
rayLen = sideDistX - deltaDistX;
|
|
} else {
|
|
sideDistY += deltaDistY;
|
|
mapY += stepY;
|
|
side = 1;
|
|
rayLen = sideDistY - deltaDistY;
|
|
}
|
|
|
|
//
|
|
// Stop if outside map
|
|
//
|
|
if (mapX < 0 || mapX >= mapW || mapY < 0 || mapY >= mapH) {
|
|
break;
|
|
}
|
|
|
|
//
|
|
// Check map to see if there's a wall
|
|
//
|
|
if (map[mapY][mapX]) {
|
|
hit = true;
|
|
break;
|
|
}
|
|
|
|
//
|
|
// If View Distance exceeded, break
|
|
//
|
|
if (steps++ >= maxSteps) {
|
|
break;
|
|
}
|
|
|
|
// // Chad's method for checking if view dist exceeded. Precision at the cost of computation
|
|
// const possibleWorldDist = rayLen * Math.sqrt(dirX * dirX + dirY * dirY); // rayLen already in "t" units, dir is unit-length so this is rayLen
|
|
// if (possibleWorldDist > viewDist) {
|
|
// break;
|
|
// }
|
|
}
|
|
|
|
//
|
|
// compute actual distance along ray (rayLen is the t along ray to grid boundary where hit occurred)
|
|
// If didn't hit or exceeded distance => paint near-black full column
|
|
//
|
|
if (!hit) {
|
|
for (let y = 0; y < screenH; y++) {
|
|
setPixel(x, y, fadeOutColor);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// ray length along ray to hit point
|
|
const adjustedRayLength = rayLen; // since dir is unit vector (cos,sin), rayLen matches distance along ray
|
|
|
|
if (adjustedRayLength > viewDist) {
|
|
for (let y = 0; y < screenH; y++) setPixel(x, y, fadeOutColor);
|
|
continue;
|
|
}
|
|
|
|
// Fish-eye correction: perpendicular distance to camera plane
|
|
const perpDist = Math.max(
|
|
adjustedRayLength * Math.cos(rayAngle - pAngle),
|
|
nearZero, // Avoid dividing by zero
|
|
);
|
|
|
|
// vertical wall slice height
|
|
const lineHeight = Math.floor(screenH / perpDist);
|
|
const halfLineHeight = lineHeight / 2;
|
|
|
|
// compute draw start and end
|
|
let drawStart = Math.floor(-halfLineHeight + halfH);
|
|
let drawEnd = Math.floor(halfLineHeight + halfH);
|
|
if (drawStart < 0) drawStart = 0;
|
|
if (drawEnd >= screenH) drawEnd = screenH - 1;
|
|
|
|
// exact hit point coordinates
|
|
const hitX = px + dirX * adjustedRayLength;
|
|
const hitY = py + dirY * adjustedRayLength;
|
|
|
|
// texture X coordinate (fractional part of the hit point along the wall)
|
|
let wallX;
|
|
if (side === 0) wallX = hitY - Math.floor(hitY);
|
|
else wallX = hitX - Math.floor(hitX);
|
|
if (wallX < 0) wallX += 1;
|
|
const texW = wallTex.width,
|
|
texH = wallTex.height;
|
|
let texX = Math.floor(wallX * texW);
|
|
if ((side === 0 && dirX > 0) || (side === 1 && dirY < 0)) {
|
|
// flip texture horizontally for some sides for nicer-looking mapping (optional)
|
|
texX = texW - texX - 1;
|
|
}
|
|
|
|
// draw wall vertical slice by sampling wall texture per-screen-pixel
|
|
for (let y = drawStart; y <= drawEnd; y++) {
|
|
const d = y - halfH + halfLineHeight; // position on texture
|
|
const texY = Math.floor((d * texH) / lineHeight);
|
|
const srcI = (Math.max(0, Math.min(texY, texH - 1)) * texW + Math.max(0, Math.min(texX, texW - 1))) * 4;
|
|
const color = [wallImg[srcI], wallImg[srcI + 1], wallImg[srcI + 2], wallImg[srcI + 3]];
|
|
setPixel(x, y, color);
|
|
}
|
|
|
|
//
|
|
// --- Floor & ceiling texturing (per-column), using Lodev method ---
|
|
//
|
|
// Points on the wall where the floor/ceiling start (the exact hit point)
|
|
const floorWallX = hitX;
|
|
const floorWallY = hitY;
|
|
// distance from camera to wall (we'll use perpDist for weight)
|
|
const distWall = perpDist;
|
|
|
|
// for each y row below the wall (floor)
|
|
for (let y = drawEnd + 1; y < screenH; y++) {
|
|
// current distance from the player to the row (rowDistance)
|
|
// formula based on projection geometry (Lodev): rowDistance = screenH / (2*y - screenH)
|
|
const rowDistance = screenH / (2.0 * y - screenH);
|
|
|
|
// weight for interpolation between player pos and floor wall hit
|
|
const weight = rowDistance / distWall;
|
|
|
|
// sample real world position (floorX, floorY) that corresponds to this pixel
|
|
const curFloorX = weight * floorWallX + (1.0 - weight) * px;
|
|
const curFloorY = weight * floorWallY + (1.0 - weight) * py;
|
|
|
|
// texture coordinates (wrap/repeat)
|
|
const fx = curFloorX - Math.floor(curFloorX);
|
|
const fy = curFloorY - Math.floor(curFloorY);
|
|
const tx = Math.floor(fx * floorTex.width) % floorTex.width;
|
|
const ty = Math.floor(fy * floorTex.height) % floorTex.height;
|
|
const floorI = (ty * floorTex.width + tx) * 4;
|
|
const ceilI = (ty * ceilTex.width + tx) * 4;
|
|
|
|
// floor pixel
|
|
setPixel(x, y, [floorImg[floorI], floorImg[floorI + 1], floorImg[floorI + 2], floorImg[floorI + 3]]);
|
|
// ceiling symmetric pixel
|
|
const cy = screenH - y - 1;
|
|
if (cy >= 0 && cy < screenH) {
|
|
setPixel(x, cy, [ceilImg[ceilI], ceilImg[ceilI + 1], ceilImg[ceilI + 2], ceilImg[ceilI + 3]]);
|
|
}
|
|
}
|
|
|
|
// Optional: draw ceiling above drawStart if there is any gap (the loop above writes symmetric ceiling).
|
|
for (let y = 0; y < drawStart; y++) {
|
|
// already partially filled by symmetric ceil writes; fill any remaining with ceiling texture via interpolation
|
|
// compute rowDistance for this y (same formula, but now y is in upper half)
|
|
const rowDistance = screenH / (2.0 * y - screenH);
|
|
const weight = rowDistance / distWall;
|
|
const curFloorX = weight * floorWallX + (1.0 - weight) * px;
|
|
const curFloorY = weight * floorWallY + (1.0 - weight) * py;
|
|
const fx = curFloorX - Math.floor(curFloorX);
|
|
const fy = curFloorY - Math.floor(curFloorY);
|
|
const tx = Math.floor(fx * ceilTex.width) % ceilTex.width;
|
|
const ty = Math.floor(fy * ceilTex.height) % ceilTex.height;
|
|
const ceilI = (ty * ceilTex.width + tx) * 4;
|
|
setPixel(x, y, [ceilImg[ceilI], ceilImg[ceilI + 1], ceilImg[ceilI + 2], ceilImg[ceilI + 3]]);
|
|
}
|
|
} // end columns loop
|
|
}
|
|
}
|