wroomba
This commit is contained in:
@@ -87,9 +87,9 @@
|
||||
############################################################
|
||||
## ################# ########################
|
||||
## # # ################# # ## ########################
|
||||
## # ################# # ## ################
|
||||
## # # S ################# # ## #### ####
|
||||
## # # ## # #### # # ####
|
||||
## #S ################# # ## ################
|
||||
## # # ################# # ## #### ####
|
||||
## M # # ## # #### # # ####
|
||||
###### #################### ## #### # ####
|
||||
###### #################### # ## # # #### ####
|
||||
###### #################### # ####
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { Vector2i, Orientation, RelativeMovement, PI_OVER_TWO } from "./ascii_types.js";
|
||||
import { FirstPersonRenderer } from "./ascii_first_person_renderer.js";
|
||||
import { DefaultRendererOptions, FirstPersonRenderer } from "./ascii_first_person_renderer.js";
|
||||
import { MiniMapRenderer } from "../ascii_minimap_renderer.js";
|
||||
import { Texture } from "./ascii_textureloader.js";
|
||||
import { AsciiWindow } from "./ascii_window.js";
|
||||
import { TileMap } from "./ascii_tile_map.js";
|
||||
import eobWallUrl1 from "./eob1.png";
|
||||
import eobWallUrl2 from "./eob2.png";
|
||||
import gnollSpriteUrl from "./gnoll.png";
|
||||
import { sprintf } from "sprintf-js";
|
||||
|
||||
class Player {
|
||||
/** @protected */
|
||||
_posV = new Vector2i();
|
||||
|
||||
/** @protected */
|
||||
_directionV = new Vector2i(0, 1);
|
||||
|
||||
/** @type {number} number of milliseconds to sleep before next gameLoop. */
|
||||
delay = 0;
|
||||
|
||||
get x() {
|
||||
return this._posV.x;
|
||||
}
|
||||
@@ -75,11 +81,6 @@ class DungeonCrawler {
|
||||
}
|
||||
|
||||
constructor() {
|
||||
/** @type {number} Number of times per second we poll for controller inputs */
|
||||
this.pollsPerSec = 60;
|
||||
/** @type {number} */
|
||||
this.debounce = 0;
|
||||
|
||||
/** @constant @readonly */
|
||||
this.keys = {
|
||||
/** @constant @readonly */
|
||||
@@ -106,84 +107,87 @@ class DungeonCrawler {
|
||||
|
||||
/** @readonly */
|
||||
this.rendering = {
|
||||
enabled: true,
|
||||
ticker: 0,
|
||||
maxDepth: 5,
|
||||
fov: Math.PI / 3, // 60 degrees, increase maybe?
|
||||
view: new AsciiWindow(document.getElementById("viewport"), 120, 50),
|
||||
/** @type {FirstPersonRenderer} */ firstPersonRenderer: null,
|
||||
/** @type {MiniMapRenderer} */ miniMapRenderer: null,
|
||||
|
||||
/** @type {FirstPersonRenderer} */
|
||||
renderer: null,
|
||||
firstPersonWindow: new AsciiWindow(document.getElementById("viewport"), 100, 45), // MAGIC CONSTANTS
|
||||
minimapWindow: new AsciiWindow(document.getElementById("minimap"), 9, 9), // MAGIC CONSTANT
|
||||
|
||||
options: DefaultRendererOptions,
|
||||
};
|
||||
|
||||
/** @readonly @type {MiniMapRenderer} */
|
||||
this.minimap;
|
||||
|
||||
/**
|
||||
* @typedef Player
|
||||
* @type {object}
|
||||
* @property {number} x integer. Player's x-coordinate on the grid.
|
||||
* @property {number} y integer. Player's y-coordinate on the grid.
|
||||
*/
|
||||
this.player = new Player();
|
||||
|
||||
this.setupControls();
|
||||
|
||||
this.loadMap();
|
||||
this.updateCompass();
|
||||
this.rendering.view.commitToDOM();
|
||||
this.render(this.player.x, this.player.y, this.player.orientation * PI_OVER_TWO);
|
||||
this.renderCompass();
|
||||
|
||||
//
|
||||
// Start the game loop
|
||||
//
|
||||
this.gameLoop();
|
||||
}
|
||||
|
||||
render(posX = this.player.x, posY = this.player.y, angle = this.player.angle) {
|
||||
if (!this.rendering.renderer) {
|
||||
/**
|
||||
* Render a first person view of the camera in a given position and orientation.
|
||||
*
|
||||
* @param {number} camX the x-coordinate of the camera (in map coordinates)
|
||||
* @param {number} camY the y-coordinate of the camera (in map coordinates)
|
||||
* @param {number} angle the orientation of the camera in radians around the unit circle.
|
||||
*/
|
||||
render(camX = this.player.x, camY = this.player.y, angle = this.player.angle) {
|
||||
if (!this.rendering.firstPersonRenderer) {
|
||||
console.log("Renderer not ready yet");
|
||||
return;
|
||||
}
|
||||
this.rendering.renderer.renderFrame(
|
||||
posX + 0.5, // add .5 to get camera into center of cell
|
||||
posY + 0.5, // add .5 to get camera into center of cell
|
||||
this.rendering.firstPersonRenderer.renderFrame(
|
||||
camX + 0.5, // add .5 to get camera into center of cell
|
||||
camY + 0.5, // add .5 to get camera into center of cell
|
||||
angle,
|
||||
);
|
||||
}
|
||||
|
||||
renderMinimap() {
|
||||
this.rendering.miniMapRenderer.draw(this.player.x, this.player.y, this.player.orientation);
|
||||
}
|
||||
|
||||
loadMap() {
|
||||
const mapString = document.getElementById("mapText").value;
|
||||
|
||||
this.map = TileMap.fromText(mapString);
|
||||
|
||||
this.player._posV = this.map.findFirst({ startLocation: true });
|
||||
|
||||
if (!this.player._posV) {
|
||||
throw new Error("Could not find a start location for the player");
|
||||
}
|
||||
console.log(this.map.getAreaAround(this.player.x, this.player.y, 5).toString());
|
||||
|
||||
const minimapElement = document.getElementById("minimap");
|
||||
const minimapWindow = new AsciiWindow(minimapElement, 9, 9); // MAGIC NUMBERS: width and height of the minimap
|
||||
this.minimap = new MiniMapRenderer(minimapWindow, this.map);
|
||||
this.rendering.miniMapRenderer = new MiniMapRenderer(this.rendering.minimapWindow, this.map);
|
||||
|
||||
const textureUrls = [eobWallUrl1, eobWallUrl2];
|
||||
const textureCount = textureUrls.length;
|
||||
const textures = [];
|
||||
const textureUrls = [eobWallUrl1, gnollSpriteUrl];
|
||||
const textures = new Array(textureUrls.length).fill();
|
||||
let textureLoadCount = 0;
|
||||
|
||||
textureUrls.forEach((url) => {
|
||||
textureUrls.forEach((url, textureId) => {
|
||||
Texture.fromSource(url).then((texture) => {
|
||||
textures.push(texture);
|
||||
textures[textureId] = texture;
|
||||
|
||||
if (textures.length < textureCount) {
|
||||
if (textureLoadCount > textureUrls.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rendering.renderer = new FirstPersonRenderer(
|
||||
this.rendering.view,
|
||||
textureLoadCount++;
|
||||
|
||||
this.rendering.firstPersonRenderer = new FirstPersonRenderer(
|
||||
this.rendering.firstPersonWindow,
|
||||
this.map,
|
||||
this.rendering.fov,
|
||||
this.rendering.maxDepth,
|
||||
textures,
|
||||
this.rendering.options,
|
||||
);
|
||||
this.render();
|
||||
this.minimap.draw(this.player.x, this.player.y, this.player.orientation);
|
||||
this.renderMinimap();
|
||||
|
||||
console.debug("renderer ready", texture);
|
||||
});
|
||||
@@ -213,7 +217,6 @@ class DungeonCrawler {
|
||||
|
||||
//
|
||||
this.player._directionV.rotateCCW(quarterTurns);
|
||||
this.updateCompass();
|
||||
}
|
||||
|
||||
/** @type {RelativeMovement} Direction the player is going to move */
|
||||
@@ -231,14 +234,14 @@ class DungeonCrawler {
|
||||
//
|
||||
// We cant move into walls
|
||||
if (this.map.isWall(targetV.x, targetV.y)) {
|
||||
this.debounce = (this.pollsPerSec / 5) | 0;
|
||||
console.info(
|
||||
"bumped into wall at %s (mypos: %s), direction=%d",
|
||||
targetV,
|
||||
this.player._posV,
|
||||
this.player.angle,
|
||||
);
|
||||
return;
|
||||
this.delay += 250; // MAGIC NUMBER: Pause for a tenth of a second after hitting a wall
|
||||
return false;
|
||||
}
|
||||
|
||||
this.animation = {
|
||||
@@ -254,7 +257,7 @@ class DungeonCrawler {
|
||||
};
|
||||
this.player._posV = targetV;
|
||||
|
||||
this.updateCompass(); // technically not necessary, but Im anticipating the need + compensating for my bad memory.
|
||||
return true;
|
||||
}
|
||||
|
||||
setupControls() {
|
||||
@@ -291,50 +294,39 @@ class DungeonCrawler {
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const ticks = Math.round(1000 / this.pollsPerSec);
|
||||
this.keys.interval = setInterval(() => {
|
||||
this.handleKeyboardInput();
|
||||
}, ticks);
|
||||
}
|
||||
|
||||
handleKeyboardInput() {
|
||||
if (this.debounce > 0) {
|
||||
this.debounce--;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isAnimating) {
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// Check each key we can handle.
|
||||
for (let key of this.keys.names) {
|
||||
if (this.keys.pressed[key]) {
|
||||
this.debounce = Math.floor(this.animation.fps * this.animation.animationDuration) - 1;
|
||||
const keyHandler = this.keys.handlers[key];
|
||||
keyHandler();
|
||||
return;
|
||||
return keyHandler();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} true if an animation is in progress
|
||||
*/
|
||||
handleAnimation() {
|
||||
//
|
||||
// Guard: only animate if called for
|
||||
if (!this.isAnimating) {
|
||||
this.animation = {};
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
//
|
||||
// Guard, stop animation if it took too long
|
||||
// Guard: stop animation if it took too long
|
||||
if (this.animation.targetTime <= performance.now()) {
|
||||
this.render(this.player.x, this.player.y, this.player.angle);
|
||||
this.renderMinimap();
|
||||
this.renderCompass();
|
||||
this.animation = {};
|
||||
this.minimap.draw(this.player.x, this.player.y, this.player.orientation);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const a = this.animation;
|
||||
@@ -344,44 +336,68 @@ class DungeonCrawler {
|
||||
const animX = a.targetX - a.startX; // how much this animation causes us to move in the x-direction
|
||||
const animA = a.targetAngle - a.startAngle; // how much this animation causes us to rotate in total
|
||||
const animT = a.targetTime - a.startTime; // how long (in ms) this animation is supposed to take.
|
||||
|
||||
const deltaT = (nowT - a.startTime) / animT;
|
||||
if (deltaT > 1) {
|
||||
throw new Error("Not supposed to happen!");
|
||||
}
|
||||
const progress = Math.min((nowT - a.startTime) / animT, 1);
|
||||
|
||||
// render
|
||||
this.render(
|
||||
a.startX + animX * deltaT, //
|
||||
a.startY + animY * deltaT, //
|
||||
a.startAngle + animA * deltaT, //
|
||||
a.startX + animX * progress, //
|
||||
a.startY + animY * progress, //
|
||||
a.startAngle + animA * progress, //
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
gameLoop() {
|
||||
//
|
||||
// We're not animating, so we chill out for 50 msec
|
||||
if (!this.isAnimating) {
|
||||
setTimeout(() => this.gameLoop(), 50); // MAGIC NUMBER
|
||||
// Has something in the game logic told us to chill out?
|
||||
//
|
||||
if (this.delay) {
|
||||
setTimeout(() => this.gameLoop(), this.delay);
|
||||
this.delay = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleAnimation();
|
||||
//
|
||||
// Are we animating ?
|
||||
// Then render a single frame, and then chill out for 20ms.
|
||||
// Do not process keyboard input while animating
|
||||
//
|
||||
if (this.handleAnimation()) {
|
||||
setTimeout(() => this.gameLoop(), 20);
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.gameLoop());
|
||||
//
|
||||
// Has a key been pressed that we need to react to?
|
||||
// Then queue up a new gameLoop call to be executed
|
||||
// as soon as possible.
|
||||
//
|
||||
// NOTE: this happens inside a microtask to ensure
|
||||
// that the call stack does not get too big and that
|
||||
// each single call to gameLoop does not take too
|
||||
// long
|
||||
//
|
||||
if (this.handleKeyboardInput()) {
|
||||
queueMicrotask(() => this.gameLoop());
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// Are we idling?
|
||||
// Then only check for new events every 20ms to use less power
|
||||
//
|
||||
setTimeout(() => this.gameLoop(), 50); // MAGIC NUMBER
|
||||
}
|
||||
|
||||
updateCompass() {
|
||||
renderCompass() {
|
||||
//
|
||||
//
|
||||
// Update the compass
|
||||
document.getElementById("compass").textContent = sprintf(
|
||||
"%s %s (%d --> %.2f [%dº])",
|
||||
document.getElementById("compass").innerHTML = sprintf(
|
||||
"<div>%s</div><div>%s</div>",
|
||||
this.player._posV,
|
||||
Object.keys(Orientation)[this.player.orientation].toLowerCase(),
|
||||
this.player.orientation,
|
||||
this.player.orientation * PI_OVER_TWO,
|
||||
this.player.orientation * 90,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,65 @@
|
||||
import { TileMap } from "./ascii_tile_map.js";
|
||||
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 {number} fov field of view (in radians)
|
||||
* @param {number} maxDist maximum view distance.
|
||||
* @param {TexturePack} textures
|
||||
* @param {Texture[]} textures
|
||||
* @param {FirstPersonRendererOptions} options
|
||||
*/
|
||||
constructor(aWindow, map, fov, maxDist, textures) {
|
||||
constructor(aWindow, map, textures, options) {
|
||||
/** @constant @readonly @type {TileMap} */
|
||||
this.map = map;
|
||||
|
||||
@@ -22,91 +67,161 @@ export class FirstPersonRenderer {
|
||||
this.window = aWindow;
|
||||
|
||||
/** @constant @readonly @type {number} */
|
||||
this.fov = fov;
|
||||
this.fov = options.fov ?? DefaultRendererOptions.fov;
|
||||
|
||||
/** @constant @readonly @type {number} */
|
||||
this.maxDist = maxDist;
|
||||
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<Array<string>>}
|
||||
*/
|
||||
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<number,Tile} The coordinates of all the tiles checked while rendering this frame*/
|
||||
const coordsCheckedFrame = new Map();
|
||||
|
||||
for (let x = 0; x < screenWidth; x++) {
|
||||
/** @type {Map<number,Tile} The coordinates of all the tiles checked while casting this single ray*/
|
||||
const coordsCheckedRay = new Map();
|
||||
|
||||
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);
|
||||
const ray = this.castRay(posX, posY, rayDirX, rayDirY, coordsCheckedRay);
|
||||
|
||||
coordsCheckedRay.forEach((tile, idx) => {
|
||||
coordsCheckedFrame.set(idx, tile);
|
||||
});
|
||||
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
// Render a single screen column
|
||||
this.renderColumn(x, ray, rayDirX, rayDirY, angleOffset);
|
||||
}
|
||||
|
||||
//
|
||||
// Our ray hit a wall, render it.
|
||||
this.renderHitCol(x, hit, rayDirX, rayDirY, angleOffset);
|
||||
const renderTime = performance.now() - benchmarkStart;
|
||||
|
||||
// Did it take more than 5ms to render the scene?
|
||||
if (renderTime > 5) {
|
||||
console.log("Rendering took a long time", { renderTime });
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
*/
|
||||
renderHitCol(x, hit, rayDirX, rayDirY, angleOffset) {
|
||||
const { rayLength, side, textureOffsetX, mapX, mapY } = hit;
|
||||
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;
|
||||
}
|
||||
|
||||
const tile = this.map.get(mapX, mapY);
|
||||
const safeDistance = Math.max(rayLength * Math.cos(angleOffset), 1e-9); // Avoid divide by zero
|
||||
const { rayLength, side, sampleU, tile: wallTile } = ray.collisions[0];
|
||||
|
||||
const distance = Math.max(rayLength * Math.cos(angleOffset), 1e-12); // 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
|
||||
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;
|
||||
@@ -115,30 +230,26 @@ export class FirstPersonRenderer {
|
||||
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;
|
||||
}
|
||||
//
|
||||
const wallTexture = this.textures[wallTile.textureId];
|
||||
|
||||
for (let y = 0; y < screenHeight; y++) {
|
||||
//
|
||||
// Are we hitting the ceiling?
|
||||
//
|
||||
if (y < minY) {
|
||||
this.window.put(x, y, "c", "#333");
|
||||
if (y < minY || y > maxY) {
|
||||
const [char, color] = this.shades[y];
|
||||
this.window.put(x, y, char, color);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (y > maxY) {
|
||||
this.window.put(x, y, "f", "#b52");
|
||||
if (y === minY) {
|
||||
this.window.put(x, y, "m", "#0F0");
|
||||
continue;
|
||||
}
|
||||
if (y === maxY) {
|
||||
this.window.put(x, y, "M", "#F00");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -146,44 +257,39 @@ export class FirstPersonRenderer {
|
||||
// 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);
|
||||
const color = wallTexture.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
|
||||
let shade = side === Side.X_AXIS ? 0.8 : 1.0; // MAGIC NUMBERS
|
||||
|
||||
//
|
||||
// Dim walls that are far away
|
||||
shade = shade / (1 + rayLength * 0.1);
|
||||
const lightLevel = 1 - rayLength / this.viewDistance;
|
||||
|
||||
//
|
||||
// Darken the image
|
||||
color.mulRGB(shade);
|
||||
color.mulRGB(shade * lightLevel);
|
||||
|
||||
// const distancePalette = ["█", "▓", "▒", "░", " "];
|
||||
const distancePalette = ["#", "#", "#", "%", "+", "÷", " ", " "];
|
||||
const char = distancePalette[rayLength | 0];
|
||||
|
||||
this.window.put(x, y, char, color.toCSS());
|
||||
this.window.put(x, y, this.wallChar, 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.
|
||||
* @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<number>} coodsChecked
|
||||
*
|
||||
* @returns {RayCastResult}
|
||||
*
|
||||
*/
|
||||
castRay(camX, camY, dirX, dirY) {
|
||||
castRay(camX, camY, dirX, dirY, coordsChecked) {
|
||||
// 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);
|
||||
@@ -193,6 +299,7 @@ export class FirstPersonRenderer {
|
||||
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
|
||||
@@ -214,30 +321,32 @@ export class FirstPersonRenderer {
|
||||
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;
|
||||
/**
|
||||
* 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 (!hit) {
|
||||
while (!result.hitWall) {
|
||||
//
|
||||
// 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 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 false; // ray got outside the map, no hit, exit early
|
||||
return result;
|
||||
}
|
||||
|
||||
let wallDist, sampleU;
|
||||
|
||||
//
|
||||
// Should we step in the x- or y-direction
|
||||
// DDA dictates we always move along the shortest vector
|
||||
@@ -248,6 +357,13 @@ export class FirstPersonRenderer {
|
||||
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
|
||||
@@ -255,72 +371,73 @@ export class FirstPersonRenderer {
|
||||
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);
|
||||
|
||||
const rayLength = Math.hypot(
|
||||
wallDist * dirX, //
|
||||
wallDist * dirY, //
|
||||
);
|
||||
|
||||
//
|
||||
// --------------------------
|
||||
// Add a Sprite to the result
|
||||
// --------------------------
|
||||
if (tile.sprite || tile.wall) {
|
||||
//
|
||||
// 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();
|
||||
result.collisions.unshift(collision);
|
||||
|
||||
collision.mapX = mapX;
|
||||
collision.mapY = mapY;
|
||||
collision.rayLength = rayLength;
|
||||
collision.tile = tile;
|
||||
collision.sampleU = sampleU;
|
||||
collision.side = side;
|
||||
if (result.sprite) {
|
||||
collision.sprite = true;
|
||||
}
|
||||
if (result.wall) {
|
||||
collision.wall = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// 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;
|
||||
// --------------------------
|
||||
// Add a Wall to the result
|
||||
// (and return)
|
||||
// --------------------------
|
||||
if (tile.wall) {
|
||||
result.hitWall = true;
|
||||
|
||||
// <todo>
|
||||
// DELETE BELOW
|
||||
result.wallCollision.tile = tile;
|
||||
result.wallCollision.side = side;
|
||||
|
||||
result.wallCollision.mapX = mapX;
|
||||
result.wallCollision.mapY = mapY;
|
||||
result.wallCollision.rayLength = rayLength;
|
||||
result.wallCollision.sampleU = sampleU;
|
||||
// </todo>
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// 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) {
|
||||
if (Math.PI < 0 && AsciiWindow && TileMap && Tile) {
|
||||
("STFU Linda");
|
||||
}
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,6 @@
|
||||
/**
|
||||
* @typedef {object} NormalizedPixel
|
||||
* @property {number} r value [0...1]
|
||||
* @property {number} g value [0...1]
|
||||
* @property {number} b value [0...1]
|
||||
* @property {number} a value [0...1]
|
||||
*
|
||||
* @typedef {object} Pixel
|
||||
* @property {number} r value [0...255]
|
||||
* @property {number} g value [0...255]
|
||||
* @property {number} b value [0...255]
|
||||
* @property {number} a value [0...255]
|
||||
*/
|
||||
|
||||
export class NRGBA {
|
||||
//
|
||||
constructor(r = 0, g = 0, b = 0, a = 0) {
|
||||
constructor(r = 0, g = 0, b = 0, a = 1) {
|
||||
this.r = r;
|
||||
this.g = g;
|
||||
this.b = b;
|
||||
@@ -27,6 +13,10 @@ export class NRGBA {
|
||||
this.b *= factor;
|
||||
}
|
||||
|
||||
mulledRGB(factor) {
|
||||
return new NRGBA(this.r * factor, this.g * factor, this.b * factor, this.a);
|
||||
}
|
||||
|
||||
get dR() {
|
||||
return ((this.r * 255) | 0) % 256;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,29 @@ import { Vector2i, Orientation } from "./ascii_types.js";
|
||||
import { AsciiWindow } from "./ascii_window.js";
|
||||
import { Texture } from "./ascii_textureloader.js";
|
||||
|
||||
class Tile {
|
||||
export class Tile {
|
||||
/** @type {string} How should this tile be rendered on the minimap.*/
|
||||
minimap = " ";
|
||||
minimapChar = " ";
|
||||
|
||||
/** @type {string} How should this tile be rendered on the minimap.*/
|
||||
minimapColor = "#fff";
|
||||
|
||||
/** @type {boolean} Should this be rendered as a wall? */
|
||||
wall = false;
|
||||
|
||||
/** @type {boolean} is this tile occupied by a sprite? */
|
||||
sprite = false;
|
||||
|
||||
/** @type {boolean} Can the player walk here? */
|
||||
traversable = true;
|
||||
|
||||
/** @type {boolean} Is this where they player starts? */
|
||||
startLocation = false;
|
||||
|
||||
/** @type {boolean} Is this where they player starts? */
|
||||
textureId = 0;
|
||||
|
||||
/** @type {Tile} options */
|
||||
constructor(options) {
|
||||
for (let [k, v] of Object.entries(options)) {
|
||||
if (this[k] !== undefined) {
|
||||
@@ -24,40 +35,50 @@ class Tile {
|
||||
}
|
||||
|
||||
export const defaultLegend = Object.freeze({
|
||||
//
|
||||
// "" is the Unknown Tile - if we encounter a tile that we don't know how to parse,
|
||||
// the it will be noted here as the empty string
|
||||
"": new Tile({
|
||||
minimapChar: " ",
|
||||
traversable: true,
|
||||
wall: false,
|
||||
}),
|
||||
|
||||
//
|
||||
// default floor
|
||||
" ": new Tile({
|
||||
minimap: " ",
|
||||
minimapChar: " ",
|
||||
traversable: true,
|
||||
wall: false,
|
||||
}),
|
||||
//
|
||||
// Default wall
|
||||
"#": new Tile({
|
||||
minimap: "#",
|
||||
minimapChar: "#",
|
||||
traversable: false,
|
||||
wall: true,
|
||||
textureId: 0,
|
||||
}),
|
||||
|
||||
"M": new Tile({
|
||||
textureId: 1,
|
||||
minimapChar: "M",
|
||||
minimapColor: "#f00",
|
||||
traversable: false,
|
||||
wall: false,
|
||||
}),
|
||||
|
||||
//
|
||||
//secret door (looks like wall, but is traversable)
|
||||
"Ω": new Tile({
|
||||
minimap: "#",
|
||||
minimapChar: "#",
|
||||
traversable: true,
|
||||
wall: true,
|
||||
}),
|
||||
//
|
||||
// "" is the Unknown Tile - if we encounter a tile that we don't know how to parse,
|
||||
// the it will be noted here as the empty string
|
||||
"": new Tile({
|
||||
minimap: " ",
|
||||
traversable: true,
|
||||
wall: false,
|
||||
}),
|
||||
//
|
||||
// where the player starts
|
||||
"S": new Tile({
|
||||
minimap: "S", // "Š",
|
||||
minimapChar: "S", // "Š",
|
||||
traversable: true,
|
||||
wall: false,
|
||||
startLocation: true,
|
||||
@@ -101,6 +122,16 @@ export class TileMap {
|
||||
return new TileMap(longestLine, lines.length, tiles);
|
||||
}
|
||||
|
||||
tileIdx(x, y) {
|
||||
return y * this.width + x;
|
||||
}
|
||||
|
||||
getByIdx(idx) {
|
||||
const y = Math.floor(idx / this.width);
|
||||
const x = idx % this.width;
|
||||
return this.tiles[y][x];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
@@ -122,7 +153,7 @@ export class TileMap {
|
||||
for (let y = 0; y < this.height; y++) {
|
||||
for (let x = 0; x < this.width; x++) {
|
||||
const tile = this.tiles[y][x];
|
||||
result += tile.minimap;
|
||||
result += tile.minimapChar;
|
||||
}
|
||||
result += "\n";
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export class Pixel {
|
||||
export class PseudoPixel {
|
||||
/**
|
||||
* @param {HTMLElement} el
|
||||
* @param {HTMLElement} htmlElement
|
||||
* @param {string} char
|
||||
* @param {number|string} color text/foreground color
|
||||
*/
|
||||
constructor(el, char = " ", color = "#fff") {
|
||||
constructor(htmlElement, char = " ", color = "#fff") {
|
||||
//
|
||||
/** @type {HTMLElement} el the html element that makes up this cell*/
|
||||
this.el = el;
|
||||
this.htmlElement = htmlElement;
|
||||
|
||||
/** @type {string} char */
|
||||
this.char = char;
|
||||
@@ -15,25 +15,28 @@ export class Pixel {
|
||||
/** @type {number|string} fg color color */
|
||||
this.color = color;
|
||||
|
||||
/** @type {boolean} Has this pixel been updated since it was flushed to DOM ? */
|
||||
this.dirty = true;
|
||||
/** @type {boolean} Has this pixel's text content been updated since it was flushed to DOM ? */
|
||||
this.dirtyChar = true;
|
||||
|
||||
/** @type {boolean} Has this pixel's color been updated since it was flushed to DOM ? */
|
||||
this.dirtyColor = true;
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new Pixel(this.el, this.car, this.color);
|
||||
return new PseudoPixel(this.htmlElement, this.car, this.color);
|
||||
}
|
||||
}
|
||||
|
||||
export class AsciiWindow {
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {HTMLElement} htmlElement the html element that contains all the pseudo-pixel elements
|
||||
* @param {number} width Canvas width (in pseudo-pixels)
|
||||
* @param {number} height Canvas height (in pseudo-pixels)
|
||||
*/
|
||||
constructor(container, width, height) {
|
||||
constructor(htmlElement, width, height) {
|
||||
//
|
||||
/** @type {HTMLElement} Paren element that contains all the pseudo-pixels */
|
||||
this.container = container;
|
||||
/** @type {HTMLElement} the html element that contains all the pseudo-pixels */
|
||||
this.htmlElement = htmlElement;
|
||||
|
||||
/** @type {number} width Canvas width (in pseudo-pixels) */
|
||||
this.width = width;
|
||||
@@ -41,8 +44,8 @@ export class AsciiWindow {
|
||||
/** @type {number} height Canvas height (in pseudo-pixels) */
|
||||
this.height = height;
|
||||
|
||||
/** @type {Pixel[]} */
|
||||
this.canvas = [];
|
||||
/** @type {PseudoPixel[]} */
|
||||
this.pseudoPixels = [];
|
||||
|
||||
this.initializeCanvaas();
|
||||
}
|
||||
@@ -56,18 +59,18 @@ export class AsciiWindow {
|
||||
const w = this.width;
|
||||
const h = this.height;
|
||||
|
||||
this.canvas = new Array(w * h).fill();
|
||||
this.pseudoPixels = new Array(w * h).fill();
|
||||
|
||||
let i = 0;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const rowEl = document.createElement("div");
|
||||
this.container.appendChild(rowEl);
|
||||
this.htmlElement.appendChild(rowEl);
|
||||
|
||||
for (let x = 0; x < w; x++) {
|
||||
const pixelEl = document.createElement("code");
|
||||
rowEl.appendChild(pixelEl);
|
||||
pixelEl.textContent = " ";
|
||||
this.canvas[i] = new Pixel(pixelEl, " ", "#fff");
|
||||
this.pseudoPixels[i] = new PseudoPixel(pixelEl, " ", "#fff");
|
||||
i++;
|
||||
}
|
||||
}
|
||||
@@ -88,17 +91,17 @@ export class AsciiWindow {
|
||||
this.mustBeWithinBounds(x, y);
|
||||
const idx = this.width * y + x;
|
||||
|
||||
const pixel = this.canvas[idx];
|
||||
const pixel = this.pseudoPixels[idx];
|
||||
|
||||
// Check for changes in text contents
|
||||
if (char !== undefined && char !== null && char !== pixel.char) {
|
||||
pixel.char = char;
|
||||
pixel.dirty = true;
|
||||
pixel.dirtyChar = true;
|
||||
}
|
||||
|
||||
if (color !== undefined && color !== null && color !== pixel.color) {
|
||||
pixel.color = color;
|
||||
pixel.dirty = true;
|
||||
pixel.dirtyColor = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,14 +111,15 @@ export class AsciiWindow {
|
||||
* @return {number} number of DOM updates made
|
||||
*/
|
||||
commitToDOM() {
|
||||
this.canvas.forEach((pixel) => {
|
||||
if (!pixel.dirty) {
|
||||
return;
|
||||
this.pseudoPixels.forEach((pixel) => {
|
||||
if (pixel.dirtyChar) {
|
||||
pixel.htmlElement.textContent = pixel.char;
|
||||
pixel.dirtyChar = false;
|
||||
}
|
||||
if (pixel.dirtyColor) {
|
||||
pixel.htmlElement.style.color = pixel.color;
|
||||
pixel.dirtyColor = false;
|
||||
}
|
||||
|
||||
pixel.el.textContent = pixel.char;
|
||||
pixel.el.style.color = pixel.color;
|
||||
pixel.dirty = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
BIN
frontend/gnoll.png
Executable file
BIN
frontend/gnoll.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Reference in New Issue
Block a user