327 lines
11 KiB
JavaScript
Executable File
327 lines
11 KiB
JavaScript
Executable File
import { TileMap } from "./ascii_tile_map.js";
|
|
import { AsciiWindow } from "./ascii_window.js";
|
|
|
|
export const Side = {
|
|
X_AXIS: 0,
|
|
Y_AXIS: 1,
|
|
};
|
|
|
|
export class FirstPersonRenderer {
|
|
/**
|
|
* @param {AsciiWindow} aWindow the window we render onto.
|
|
* @param {TileMap} map
|
|
* @param {number} fov field of view (in radians)
|
|
* @param {number} maxDist maximum view distance.
|
|
* @param {TexturePack} textures
|
|
*/
|
|
constructor(aWindow, map, fov, maxDist, textures) {
|
|
/** @constant @readonly @type {TileMap} */
|
|
this.map = map;
|
|
|
|
/** @constant @readonly @type {AsciiWindow} */
|
|
this.window = aWindow;
|
|
|
|
/** @constant @readonly @type {number} */
|
|
this.fov = fov;
|
|
|
|
/** @constant @readonly @type {number} */
|
|
this.maxDist = maxDist;
|
|
|
|
/** @constant @readonly @type {Texture[]} */
|
|
this.textures = textures;
|
|
}
|
|
|
|
renderFrame(posX, posY, dirAngle, commit = true) {
|
|
const screenWidth = this.window.width;
|
|
|
|
for (let x = 0; x < screenWidth; x++) {
|
|
const angleOffset = (x / screenWidth - 0.5) * this.fov; // in radians
|
|
const rayAngle = dirAngle + angleOffset;
|
|
const rayDirX = Math.cos(rayAngle);
|
|
const rayDirY = Math.sin(rayAngle);
|
|
|
|
// Cast ray using our DDA function
|
|
const hit = this.castRay(posX, posY, rayDirX, rayDirY, rayAngle);
|
|
|
|
//
|
|
// Did we hit something?
|
|
//
|
|
if (!hit) {
|
|
// we did not hit anything. Either the ray went out of bounds,
|
|
// or it went too far, so move on to next pseudo-pixel
|
|
this.renderNoHitCol(x);
|
|
continue;
|
|
}
|
|
|
|
//
|
|
// Our ray hit a wall, render it.
|
|
this.renderHitCol(x, hit, rayDirX, rayDirY, angleOffset);
|
|
}
|
|
|
|
if (commit) {
|
|
this.window.commitToDOM();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render a vertical column of pixels on the screen at the x coordinate.
|
|
* This occurs when the ray did not hit anything.
|
|
*
|
|
* @protected
|
|
*/
|
|
renderNoHitCol(x) {
|
|
const screenHeight = this.window.height;
|
|
const halfScreenHieght = screenHeight / 2;
|
|
const lineHeight = Math.floor(screenHeight / this.maxDist);
|
|
let minY = Math.floor(-lineHeight / 2 + halfScreenHieght);
|
|
let maxY = Math.floor(lineHeight / 2 + halfScreenHieght);
|
|
|
|
for (let y = 0; y < screenHeight; y++) {
|
|
if (y < minY) {
|
|
this.window.put(x, y, "c", "#333"); // ceiling
|
|
} else if (y > maxY) {
|
|
this.window.put(x, y, "f", "#b52"); // floor
|
|
} else {
|
|
const char = ["·", "÷", "'", "~"][(y + x) % 4];
|
|
this.window.put(x, y, char, "#222"); // the far distance
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render a column on the screen where the ray hit a wall.
|
|
* @protected
|
|
*/
|
|
renderHitCol(x, hit, rayDirX, rayDirY, angleOffset) {
|
|
const { rayLength, side, textureOffsetX, mapX, mapY } = hit;
|
|
|
|
const tile = this.map.get(mapX, mapY);
|
|
const safeDistance = Math.max(rayLength * Math.cos(angleOffset), 1e-9); // Avoid divide by zero
|
|
|
|
//
|
|
// Calculate perspective.
|
|
//
|
|
const screenHeight = this.window.height;
|
|
const halfScreenHieght = screenHeight / 2;
|
|
const lineHeight = Math.floor(screenHeight / safeDistance);
|
|
let minY = Math.floor(-lineHeight / 2 + halfScreenHieght);
|
|
let maxY = Math.floor(lineHeight / 2 + halfScreenHieght);
|
|
let unsafeMinY = minY; // can be lower than zero
|
|
|
|
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 % this.textures.length];
|
|
|
|
// X coord on texture
|
|
let sampleU = textureOffsetX;
|
|
|
|
if (side === 0 && rayDirX > 0) {
|
|
sampleU = 1 - sampleU;
|
|
}
|
|
if (side === 1 && rayDirY < 0) {
|
|
sampleU = 1 - sampleU;
|
|
}
|
|
|
|
for (let y = 0; y < screenHeight; y++) {
|
|
//
|
|
// Are we hitting the ceiling?
|
|
//
|
|
if (y < minY) {
|
|
this.window.put(x, y, "c", "#333");
|
|
continue;
|
|
}
|
|
|
|
if (y > maxY) {
|
|
this.window.put(x, y, "f", "#b52");
|
|
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);
|
|
|
|
//
|
|
// North-south walls are shaded differently from east-west walls
|
|
let shade = side === Side.X_AXIS ? 0.7 : 1.0; // MAGIC NUMBERS
|
|
|
|
//
|
|
// Dim walls that are far away
|
|
shade = shade / (1 + rayLength * 0.1);
|
|
|
|
//
|
|
// Darken the image
|
|
color.mulRGB(shade);
|
|
|
|
// const distancePalette = ["█", "▓", "▒", "░", " "];
|
|
const distancePalette = ["#", "#", "#", "%", "+", "÷", " ", " "];
|
|
const char = distancePalette[rayLength | 0];
|
|
|
|
this.window.put(x, y, char, color.toCSS());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {number} camX x-coordinate of the camera (is the same
|
|
* @param {number} camY y-coordinate of the camera
|
|
* @parma {number} dirX x-coordinate of the normalized vector of the viewing direction of the camera.
|
|
* @parma {number} dirX y-coordinate of the normalized vector of the viewing direction of the camera.
|
|
*/
|
|
castRay(camX, camY, dirX, dirY) {
|
|
// Current map square
|
|
let mapX = Math.floor(camX);
|
|
let mapY = Math.floor(camY);
|
|
|
|
if (dirX === 0 || dirY === 0) {
|
|
console.log("Divide by zero is incoming", { dirX, dirY });
|
|
}
|
|
|
|
Number.MAX_SAFE_INTEGER;
|
|
// 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) ?
|
|
|
|
//
|
|
// 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
|
|
}
|
|
|
|
//
|
|
// Did the ray hit a wall ?
|
|
//
|
|
let hit = false;
|
|
|
|
//
|
|
// Did the ray hit a wall on a horizontal edge or a vertical edge?
|
|
//
|
|
let side = Side.X_AXIS;
|
|
|
|
// DDA loop
|
|
while (!hit) {
|
|
//
|
|
// Check if ray is longer than maxDist
|
|
if (Math.min(sideDistX, sideDistY) > this.maxDist) {
|
|
return false; // ray got too long, no hit, exit early
|
|
}
|
|
|
|
//
|
|
// Check for out of bounds
|
|
if (mapX < 0 || mapX >= this.map.width || mapY < 0 || mapY >= this.map.height) {
|
|
return false; // ray got outside the map, no hit, exit early
|
|
}
|
|
|
|
//
|
|
// 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;
|
|
} else {
|
|
//
|
|
// Move vertically
|
|
//
|
|
sideDistY += deltaDistY;
|
|
mapY += stepY;
|
|
side = Side.Y_AXIS;
|
|
}
|
|
|
|
//
|
|
// Check if ray hit a wall
|
|
if (this.map.isWall(mapX, mapY)) {
|
|
//
|
|
// Ray hit a wall, proceed to the rest of the algorithm.
|
|
//
|
|
hit = true;
|
|
}
|
|
}
|
|
|
|
//
|
|
// The distance to the wall, measured perpendicularly to the viewing angle
|
|
// The perpendicular distance is used to avoid the fish-eye distortion
|
|
// that would occur if we measured the Euclidean distance from the camera
|
|
// to the where the ray impacted the wall. This makes sense when you realize
|
|
// that, when looking directly at a wall, the shortest rays would be right in
|
|
// front of the camera, making it seem as if the wall bowed outwards toward
|
|
// the camera.
|
|
//
|
|
let perpWallDist;
|
|
|
|
//
|
|
// Where did we hit the wall. Measured as a normalized x-coordinate only;
|
|
//
|
|
let textureOffsetX;
|
|
|
|
//
|
|
// Determine both the perpendicular distance to the wall
|
|
// and the x-coordinate (on the wall) where the ray hit it.
|
|
//
|
|
if (side === Side.X_AXIS) {
|
|
//
|
|
// Ray hit the left or right edge of the wall-tile
|
|
//
|
|
perpWallDist = (mapX - camX + (1 - stepX) / 2) / dirX;
|
|
textureOffsetX = camY + perpWallDist * dirY;
|
|
} else {
|
|
//
|
|
// Ray hit the upper or lower edge of the wall-tile
|
|
//
|
|
perpWallDist = (mapY - camY + (1 - stepY) / 2) / dirY;
|
|
textureOffsetX = camX + perpWallDist * dirX;
|
|
}
|
|
|
|
//
|
|
// Normalize textureOffsetX. We only want the fractional part.
|
|
//
|
|
textureOffsetX -= Math.floor(textureOffsetX);
|
|
|
|
const rayLength = Math.hypot(
|
|
perpWallDist * dirX, //
|
|
perpWallDist * dirY, //
|
|
);
|
|
|
|
return {
|
|
mapX,
|
|
mapY,
|
|
side,
|
|
rayLength,
|
|
textureOffsetX,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (Math.PI < 0 && AsciiWindow && TileMap) {
|
|
("STFU Linda");
|
|
}
|