wroomba
This commit is contained in:
@@ -3,60 +3,60 @@ import { Vector2i } from "./frontend/ascii_types.js";
|
|||||||
import { AsciiWindow } from "./frontend/ascii_window.js";
|
import { AsciiWindow } from "./frontend/ascii_window.js";
|
||||||
|
|
||||||
export class MiniMapRenderer {
|
export class MiniMapRenderer {
|
||||||
/**
|
/**
|
||||||
* @param {AsciiWindow} aWindow
|
* @param {AsciiWindow} aWindow
|
||||||
* @param {TileMap} map
|
* @param {TileMap} map
|
||||||
*/
|
*/
|
||||||
constructor(aWindow, map) {
|
constructor(aWindow, map) {
|
||||||
if (aWindow.width !== aWindow.height) {
|
if (aWindow.width !== aWindow.height) {
|
||||||
console.log("Window now square", { width: aWindow.width, height: aWindow.height });
|
console.log("Window now square", { width: aWindow.width, height: aWindow.height });
|
||||||
throw new Error("Window must be square");
|
throw new Error("Window must be square");
|
||||||
}
|
}
|
||||||
if (aWindow.width % 2 === 0) {
|
if (aWindow.width % 2 === 0) {
|
||||||
console.log("Window dimension must be uneven", { width: aWindow.width, height: aWindow.height });
|
console.log("Window dimension must be uneven", { width: aWindow.width, height: aWindow.height });
|
||||||
throw new Error("Window dimension is even, it must be uneven");
|
throw new Error("Window dimension is even, it must be uneven");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {AsciiWindow} */
|
/** @type {AsciiWindow} */
|
||||||
this.window = aWindow;
|
this.window = aWindow;
|
||||||
|
|
||||||
/** @type {TileMap} */
|
/** @type {TileMap} */
|
||||||
this.map = map;
|
this.map = map;
|
||||||
|
|
||||||
/** @type {number} how far we can see on the minimap */
|
/** @type {number} how far we can see on the minimap */
|
||||||
this.distance = (aWindow.width - 1) / 2;
|
this.distance = (aWindow.width - 1) / 2;
|
||||||
|
|
||||||
this.fg = undefined; // Let the CSS of the parent element control the colors of the tiles
|
this.fg = undefined; // Let the CSS of the parent element control the colors of the tiles
|
||||||
this.bg = undefined; // let the CSS of the parent element control the background colors of the tiles
|
this.bg = undefined; // let the CSS of the parent element control the background colors of the tiles
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {number} centerX
|
* @param {number} centerX
|
||||||
* @param {number} centerY
|
* @param {number} centerY
|
||||||
* @param {Orientation} orientation
|
* @param {Orientation} orientation
|
||||||
*/
|
*/
|
||||||
draw(centerX, centerY, orientation) {
|
draw(centerX, centerY, orientation) {
|
||||||
// these variables are the coordinates of the
|
// these variables are the coordinates of the
|
||||||
// area of the map (not minimap) we are looking at
|
// area of the map (not minimap) we are looking at
|
||||||
const minX = centerX - this.distance;
|
const minX = centerX - this.distance;
|
||||||
const maxX = centerX + this.distance;
|
const maxX = centerX + this.distance;
|
||||||
const minY = centerY - this.distance;
|
const minY = centerY - this.distance;
|
||||||
const maxY = centerY + this.distance;
|
const maxY = centerY + this.distance;
|
||||||
|
|
||||||
const distanceV = new Vector2i(this.distance, this.distance);
|
const distanceV = new Vector2i(this.distance, this.distance);
|
||||||
|
|
||||||
for (let y = minY; y <= maxY; y++) {
|
for (let y = minY; y <= maxY; y++) {
|
||||||
for (let x = minX; x <= maxX; x++) {
|
for (let x = minX; x <= maxX; x++) {
|
||||||
const wndPosV = new Vector2i(x - centerX, y - centerY).rotateCW(orientation + 1).add(distanceV);
|
const wndPosV = new Vector2i(x - centerX, y - centerY).rotateCW(orientation + 1).add(distanceV);
|
||||||
|
|
||||||
this.window.put(wndPosV.x, wndPosV.y, this.map.get(x, y).minimap, this.fg, this.bg);
|
this.window.put(wndPosV.x, wndPosV.y, this.map.get(x, y).minimapChar, this.fg, this.bg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.window.put(this.distance, this.distance, "@", "#44F");
|
this.window.put(this.distance, this.distance, "@", "#44F");
|
||||||
this.window.commitToDOM();
|
this.window.commitToDOM();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Math.PI < 0 && AsciiWindow && TileMap && Vector2i) {
|
if (Math.PI < 0 && AsciiWindow && TileMap && Vector2i) {
|
||||||
("STFU Linda");
|
("STFU Linda");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,9 +87,9 @@
|
|||||||
############################################################
|
############################################################
|
||||||
## ################# ########################
|
## ################# ########################
|
||||||
## # # ################# # ## ########################
|
## # # ################# # ## ########################
|
||||||
## # ################# # ## ################
|
## #S ################# # ## ################
|
||||||
## # # S ################# # ## #### ####
|
## # # ################# # ## #### ####
|
||||||
## # # ## # #### # # ####
|
## M # # ## # #### # # ####
|
||||||
###### #################### ## #### # ####
|
###### #################### ## #### # ####
|
||||||
###### #################### # ## # # #### ####
|
###### #################### # ## # # #### ####
|
||||||
###### #################### # ####
|
###### #################### # ####
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { Vector2i, Orientation, RelativeMovement, PI_OVER_TWO } from "./ascii_types.js";
|
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 { MiniMapRenderer } from "../ascii_minimap_renderer.js";
|
||||||
import { Texture } from "./ascii_textureloader.js";
|
import { Texture } from "./ascii_textureloader.js";
|
||||||
import { AsciiWindow } from "./ascii_window.js";
|
import { AsciiWindow } from "./ascii_window.js";
|
||||||
import { TileMap } from "./ascii_tile_map.js";
|
import { TileMap } from "./ascii_tile_map.js";
|
||||||
import eobWallUrl1 from "./eob1.png";
|
import eobWallUrl1 from "./eob1.png";
|
||||||
import eobWallUrl2 from "./eob2.png";
|
import gnollSpriteUrl from "./gnoll.png";
|
||||||
import { sprintf } from "sprintf-js";
|
import { sprintf } from "sprintf-js";
|
||||||
|
|
||||||
class Player {
|
class Player {
|
||||||
|
/** @protected */
|
||||||
_posV = new Vector2i();
|
_posV = new Vector2i();
|
||||||
|
|
||||||
|
/** @protected */
|
||||||
_directionV = new Vector2i(0, 1);
|
_directionV = new Vector2i(0, 1);
|
||||||
|
|
||||||
|
/** @type {number} number of milliseconds to sleep before next gameLoop. */
|
||||||
|
delay = 0;
|
||||||
|
|
||||||
get x() {
|
get x() {
|
||||||
return this._posV.x;
|
return this._posV.x;
|
||||||
}
|
}
|
||||||
@@ -75,11 +81,6 @@ class DungeonCrawler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
/** @type {number} Number of times per second we poll for controller inputs */
|
|
||||||
this.pollsPerSec = 60;
|
|
||||||
/** @type {number} */
|
|
||||||
this.debounce = 0;
|
|
||||||
|
|
||||||
/** @constant @readonly */
|
/** @constant @readonly */
|
||||||
this.keys = {
|
this.keys = {
|
||||||
/** @constant @readonly */
|
/** @constant @readonly */
|
||||||
@@ -106,84 +107,87 @@ class DungeonCrawler {
|
|||||||
|
|
||||||
/** @readonly */
|
/** @readonly */
|
||||||
this.rendering = {
|
this.rendering = {
|
||||||
enabled: true,
|
/** @type {FirstPersonRenderer} */ firstPersonRenderer: null,
|
||||||
ticker: 0,
|
/** @type {MiniMapRenderer} */ miniMapRenderer: null,
|
||||||
maxDepth: 5,
|
|
||||||
fov: Math.PI / 3, // 60 degrees, increase maybe?
|
|
||||||
view: new AsciiWindow(document.getElementById("viewport"), 120, 50),
|
|
||||||
|
|
||||||
/** @type {FirstPersonRenderer} */
|
firstPersonWindow: new AsciiWindow(document.getElementById("viewport"), 100, 45), // MAGIC CONSTANTS
|
||||||
renderer: null,
|
minimapWindow: new AsciiWindow(document.getElementById("minimap"), 9, 9), // MAGIC CONSTANT
|
||||||
|
|
||||||
|
options: DefaultRendererOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @readonly @type {MiniMapRenderer} */
|
/** @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.player = new Player();
|
||||||
|
|
||||||
this.setupControls();
|
this.setupControls();
|
||||||
|
|
||||||
this.loadMap();
|
this.loadMap();
|
||||||
this.updateCompass();
|
|
||||||
this.rendering.view.commitToDOM();
|
|
||||||
this.render(this.player.x, this.player.y, this.player.orientation * PI_OVER_TWO);
|
this.render(this.player.x, this.player.y, this.player.orientation * PI_OVER_TWO);
|
||||||
|
this.renderCompass();
|
||||||
|
|
||||||
|
//
|
||||||
|
// Start the game loop
|
||||||
|
//
|
||||||
this.gameLoop();
|
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");
|
console.log("Renderer not ready yet");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.rendering.renderer.renderFrame(
|
this.rendering.firstPersonRenderer.renderFrame(
|
||||||
posX + 0.5, // add .5 to get camera into center of cell
|
camX + 0.5, // add .5 to get camera into center of cell
|
||||||
posY + 0.5, // add .5 to get camera into center of cell
|
camY + 0.5, // add .5 to get camera into center of cell
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderMinimap() {
|
||||||
|
this.rendering.miniMapRenderer.draw(this.player.x, this.player.y, this.player.orientation);
|
||||||
|
}
|
||||||
|
|
||||||
loadMap() {
|
loadMap() {
|
||||||
const mapString = document.getElementById("mapText").value;
|
const mapString = document.getElementById("mapText").value;
|
||||||
|
|
||||||
this.map = TileMap.fromText(mapString);
|
this.map = TileMap.fromText(mapString);
|
||||||
|
|
||||||
this.player._posV = this.map.findFirst({ startLocation: true });
|
this.player._posV = this.map.findFirst({ startLocation: true });
|
||||||
|
|
||||||
if (!this.player._posV) {
|
if (!this.player._posV) {
|
||||||
throw new Error("Could not find a start location for the player");
|
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");
|
this.rendering.miniMapRenderer = new MiniMapRenderer(this.rendering.minimapWindow, this.map);
|
||||||
const minimapWindow = new AsciiWindow(minimapElement, 9, 9); // MAGIC NUMBERS: width and height of the minimap
|
|
||||||
this.minimap = new MiniMapRenderer(minimapWindow, this.map);
|
|
||||||
|
|
||||||
const textureUrls = [eobWallUrl1, eobWallUrl2];
|
const textureUrls = [eobWallUrl1, gnollSpriteUrl];
|
||||||
const textureCount = textureUrls.length;
|
const textures = new Array(textureUrls.length).fill();
|
||||||
const textures = [];
|
let textureLoadCount = 0;
|
||||||
|
|
||||||
textureUrls.forEach((url) => {
|
textureUrls.forEach((url, textureId) => {
|
||||||
Texture.fromSource(url).then((texture) => {
|
Texture.fromSource(url).then((texture) => {
|
||||||
textures.push(texture);
|
textures[textureId] = texture;
|
||||||
|
|
||||||
if (textures.length < textureCount) {
|
if (textureLoadCount > textureUrls.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rendering.renderer = new FirstPersonRenderer(
|
textureLoadCount++;
|
||||||
this.rendering.view,
|
|
||||||
|
this.rendering.firstPersonRenderer = new FirstPersonRenderer(
|
||||||
|
this.rendering.firstPersonWindow,
|
||||||
this.map,
|
this.map,
|
||||||
this.rendering.fov,
|
|
||||||
this.rendering.maxDepth,
|
|
||||||
textures,
|
textures,
|
||||||
|
this.rendering.options,
|
||||||
);
|
);
|
||||||
this.render();
|
this.render();
|
||||||
this.minimap.draw(this.player.x, this.player.y, this.player.orientation);
|
this.renderMinimap();
|
||||||
|
|
||||||
console.debug("renderer ready", texture);
|
console.debug("renderer ready", texture);
|
||||||
});
|
});
|
||||||
@@ -213,7 +217,6 @@ class DungeonCrawler {
|
|||||||
|
|
||||||
//
|
//
|
||||||
this.player._directionV.rotateCCW(quarterTurns);
|
this.player._directionV.rotateCCW(quarterTurns);
|
||||||
this.updateCompass();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {RelativeMovement} Direction the player is going to move */
|
/** @type {RelativeMovement} Direction the player is going to move */
|
||||||
@@ -231,14 +234,14 @@ class DungeonCrawler {
|
|||||||
//
|
//
|
||||||
// We cant move into walls
|
// We cant move into walls
|
||||||
if (this.map.isWall(targetV.x, targetV.y)) {
|
if (this.map.isWall(targetV.x, targetV.y)) {
|
||||||
this.debounce = (this.pollsPerSec / 5) | 0;
|
|
||||||
console.info(
|
console.info(
|
||||||
"bumped into wall at %s (mypos: %s), direction=%d",
|
"bumped into wall at %s (mypos: %s), direction=%d",
|
||||||
targetV,
|
targetV,
|
||||||
this.player._posV,
|
this.player._posV,
|
||||||
this.player.angle,
|
this.player.angle,
|
||||||
);
|
);
|
||||||
return;
|
this.delay += 250; // MAGIC NUMBER: Pause for a tenth of a second after hitting a wall
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.animation = {
|
this.animation = {
|
||||||
@@ -254,7 +257,7 @@ class DungeonCrawler {
|
|||||||
};
|
};
|
||||||
this.player._posV = targetV;
|
this.player._posV = targetV;
|
||||||
|
|
||||||
this.updateCompass(); // technically not necessary, but Im anticipating the need + compensating for my bad memory.
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
setupControls() {
|
setupControls() {
|
||||||
@@ -291,50 +294,39 @@ class DungeonCrawler {
|
|||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const ticks = Math.round(1000 / this.pollsPerSec);
|
|
||||||
this.keys.interval = setInterval(() => {
|
|
||||||
this.handleKeyboardInput();
|
|
||||||
}, ticks);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKeyboardInput() {
|
handleKeyboardInput() {
|
||||||
if (this.debounce > 0) {
|
|
||||||
this.debounce--;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isAnimating) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// Check each key we can handle.
|
// Check each key we can handle.
|
||||||
for (let key of this.keys.names) {
|
for (let key of this.keys.names) {
|
||||||
if (this.keys.pressed[key]) {
|
if (this.keys.pressed[key]) {
|
||||||
this.debounce = Math.floor(this.animation.fps * this.animation.animationDuration) - 1;
|
|
||||||
const keyHandler = this.keys.handlers[key];
|
const keyHandler = this.keys.handlers[key];
|
||||||
keyHandler();
|
return keyHandler();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean} true if an animation is in progress
|
||||||
|
*/
|
||||||
handleAnimation() {
|
handleAnimation() {
|
||||||
//
|
//
|
||||||
// Guard: only animate if called for
|
// Guard: only animate if called for
|
||||||
if (!this.isAnimating) {
|
if (!this.isAnimating) {
|
||||||
this.animation = {};
|
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()) {
|
if (this.animation.targetTime <= performance.now()) {
|
||||||
this.render(this.player.x, this.player.y, this.player.angle);
|
this.render(this.player.x, this.player.y, this.player.angle);
|
||||||
|
this.renderMinimap();
|
||||||
|
this.renderCompass();
|
||||||
this.animation = {};
|
this.animation = {};
|
||||||
this.minimap.draw(this.player.x, this.player.y, this.player.orientation);
|
return false;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const a = this.animation;
|
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 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 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 animT = a.targetTime - a.startTime; // how long (in ms) this animation is supposed to take.
|
||||||
|
const progress = Math.min((nowT - a.startTime) / animT, 1);
|
||||||
const deltaT = (nowT - a.startTime) / animT;
|
|
||||||
if (deltaT > 1) {
|
|
||||||
throw new Error("Not supposed to happen!");
|
|
||||||
}
|
|
||||||
|
|
||||||
// render
|
// render
|
||||||
this.render(
|
this.render(
|
||||||
a.startX + animX * deltaT, //
|
a.startX + animX * progress, //
|
||||||
a.startY + animY * deltaT, //
|
a.startY + animY * progress, //
|
||||||
a.startAngle + animA * deltaT, //
|
a.startAngle + animA * progress, //
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
gameLoop() {
|
gameLoop() {
|
||||||
//
|
//
|
||||||
// We're not animating, so we chill out for 50 msec
|
// Has something in the game logic told us to chill out?
|
||||||
if (!this.isAnimating) {
|
//
|
||||||
setTimeout(() => this.gameLoop(), 50); // MAGIC NUMBER
|
if (this.delay) {
|
||||||
|
setTimeout(() => this.gameLoop(), this.delay);
|
||||||
|
this.delay = 0;
|
||||||
return;
|
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
|
// Update the compass
|
||||||
document.getElementById("compass").textContent = sprintf(
|
document.getElementById("compass").innerHTML = sprintf(
|
||||||
"%s %s (%d --> %.2f [%dº])",
|
"<div>%s</div><div>%s</div>",
|
||||||
this.player._posV,
|
this.player._posV,
|
||||||
Object.keys(Orientation)[this.player.orientation].toLowerCase(),
|
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";
|
import { AsciiWindow } from "./ascii_window.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which side of a tile did the ray strike
|
||||||
|
*/
|
||||||
export const Side = {
|
export const Side = {
|
||||||
X_AXIS: 0,
|
X_AXIS: 0,
|
||||||
Y_AXIS: 1,
|
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 {
|
export class FirstPersonRenderer {
|
||||||
/**
|
/**
|
||||||
* @param {AsciiWindow} aWindow the window we render onto.
|
* @param {AsciiWindow} aWindow the window we render onto.
|
||||||
* @param {TileMap} map
|
* @param {TileMap} map
|
||||||
* @param {number} fov field of view (in radians)
|
* @param {Texture[]} textures
|
||||||
* @param {number} maxDist maximum view distance.
|
* @param {FirstPersonRendererOptions} options
|
||||||
* @param {TexturePack} textures
|
|
||||||
*/
|
*/
|
||||||
constructor(aWindow, map, fov, maxDist, textures) {
|
constructor(aWindow, map, textures, options) {
|
||||||
/** @constant @readonly @type {TileMap} */
|
/** @constant @readonly @type {TileMap} */
|
||||||
this.map = map;
|
this.map = map;
|
||||||
|
|
||||||
@@ -22,91 +67,161 @@ export class FirstPersonRenderer {
|
|||||||
this.window = aWindow;
|
this.window = aWindow;
|
||||||
|
|
||||||
/** @constant @readonly @type {number} */
|
/** @constant @readonly @type {number} */
|
||||||
this.fov = fov;
|
this.fov = options.fov ?? DefaultRendererOptions.fov;
|
||||||
|
|
||||||
/** @constant @readonly @type {number} */
|
/** @constant @readonly @type {number} */
|
||||||
this.maxDist = maxDist;
|
this.viewDistance = options.viewDistance ?? DefaultRendererOptions.viewDistance;
|
||||||
|
|
||||||
/** @constant @readonly @type {Texture[]} */
|
/** @constant @readonly @type {Texture[]} */
|
||||||
this.textures = textures;
|
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) {
|
renderFrame(posX, posY, dirAngle, commit = true) {
|
||||||
|
const benchmarkStart = performance.now();
|
||||||
const screenWidth = this.window.width;
|
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++) {
|
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 angleOffset = (x / screenWidth - 0.5) * this.fov; // in radians
|
||||||
const rayAngle = dirAngle + angleOffset;
|
const rayAngle = dirAngle + angleOffset;
|
||||||
const rayDirX = Math.cos(rayAngle);
|
const rayDirX = Math.cos(rayAngle);
|
||||||
const rayDirY = Math.sin(rayAngle);
|
const rayDirY = Math.sin(rayAngle);
|
||||||
|
|
||||||
// Cast ray using our DDA function
|
// 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?
|
// Render a single screen column
|
||||||
//
|
this.renderColumn(x, ray, rayDirX, rayDirY, angleOffset);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
const renderTime = performance.now() - benchmarkStart;
|
||||||
// Our ray hit a wall, render it.
|
|
||||||
this.renderHitCol(x, hit, rayDirX, rayDirY, angleOffset);
|
// Did it take more than 5ms to render the scene?
|
||||||
|
if (renderTime > 5) {
|
||||||
|
console.log("Rendering took a long time", { renderTime });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commit) {
|
if (commit) {
|
||||||
this.window.commitToDOM();
|
requestAnimationFrame(() => {
|
||||||
}
|
const benchmarkStart = performance.now();
|
||||||
}
|
this.window.commitToDOM();
|
||||||
|
const commitTime = performance.now() - benchmarkStart;
|
||||||
/**
|
if (commitTime > 5) {
|
||||||
* Render a vertical column of pixels on the screen at the x coordinate.
|
console.log("Updating DOM took a long time:", { commitTime });
|
||||||
* 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.
|
* 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
|
* @protected
|
||||||
*/
|
*/
|
||||||
renderHitCol(x, hit, rayDirX, rayDirY, angleOffset) {
|
renderColumn(x, ray, rayDirX, rayDirY, angleOffset) {
|
||||||
const { rayLength, side, textureOffsetX, mapX, mapY } = hit;
|
//
|
||||||
|
// 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 { rayLength, side, sampleU, tile: wallTile } = ray.collisions[0];
|
||||||
const safeDistance = Math.max(rayLength * Math.cos(angleOffset), 1e-9); // Avoid divide by zero
|
|
||||||
|
const distance = Math.max(rayLength * Math.cos(angleOffset), 1e-12); // Avoid divide by zero
|
||||||
|
|
||||||
//
|
//
|
||||||
// Calculate perspective.
|
// Calculate perspective.
|
||||||
//
|
//
|
||||||
const screenHeight = this.window.height;
|
const screenHeight = this.window.height;
|
||||||
const halfScreenHieght = screenHeight / 2;
|
const lineHeight = Math.round(screenHeight / distance); // using round() because floor() gives aberrations when distance == (n + 0.500)
|
||||||
const lineHeight = Math.floor(screenHeight / safeDistance);
|
const halfScreenHeight = screenHeight / 2;
|
||||||
let minY = Math.floor(-lineHeight / 2 + halfScreenHieght);
|
const halfLineHeight = lineHeight / 2;
|
||||||
let maxY = Math.floor(lineHeight / 2 + halfScreenHieght);
|
|
||||||
let unsafeMinY = minY; // can be lower than zero
|
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) {
|
if (minY < 0) {
|
||||||
minY = 0;
|
minY = 0;
|
||||||
@@ -115,30 +230,26 @@ export class FirstPersonRenderer {
|
|||||||
maxY = screenHeight - 1;
|
maxY = screenHeight - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
// Pick texture (here grid value decides which texture)
|
// Pick texture (here grid value decides which texture)
|
||||||
const texture = this.textures[tile.textureId % this.textures.length];
|
//
|
||||||
|
const wallTexture = this.textures[wallTile.textureId];
|
||||||
// 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++) {
|
for (let y = 0; y < screenHeight; y++) {
|
||||||
//
|
//
|
||||||
// Are we hitting the ceiling?
|
// Are we hitting the ceiling?
|
||||||
//
|
//
|
||||||
if (y < minY) {
|
if (y < minY || y > maxY) {
|
||||||
this.window.put(x, y, "c", "#333");
|
const [char, color] = this.shades[y];
|
||||||
|
this.window.put(x, y, char, color);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (y === minY) {
|
||||||
if (y > maxY) {
|
this.window.put(x, y, "m", "#0F0");
|
||||||
this.window.put(x, y, "f", "#b52");
|
continue;
|
||||||
|
}
|
||||||
|
if (y === maxY) {
|
||||||
|
this.window.put(x, y, "M", "#F00");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,44 +257,39 @@ export class FirstPersonRenderer {
|
|||||||
// Map screen y to texture y
|
// Map screen y to texture y
|
||||||
let sampleV = (y - unsafeMinY) / lineHeight; // y- coordinate of the texture point to sample
|
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
|
// 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
|
// Dim walls that are far away
|
||||||
shade = shade / (1 + rayLength * 0.1);
|
const lightLevel = 1 - rayLength / this.viewDistance;
|
||||||
|
|
||||||
//
|
//
|
||||||
// Darken the image
|
// Darken the image
|
||||||
color.mulRGB(shade);
|
color.mulRGB(shade * lightLevel);
|
||||||
|
|
||||||
// const distancePalette = ["█", "▓", "▒", "░", " "];
|
this.window.put(x, y, this.wallChar, color.toCSS());
|
||||||
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} camX x-coordinate of the camera (is the same
|
||||||
* @param {number} camY y-coordinate of the camera
|
* @param {number} camY y-coordinate of the camera
|
||||||
* @parma {number} dirX x-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.
|
||||||
* @parma {number} dirX y-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
|
// Current map square
|
||||||
let mapX = Math.floor(camX);
|
let mapX = Math.floor(camX);
|
||||||
let mapY = Math.floor(camY);
|
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
|
// 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 deltaDistX = dirX === 0 ? 1e15 : Math.abs(1 / dirX);
|
||||||
const deltaDistY = dirY === 0 ? 1e15 : Math.abs(1 / dirY);
|
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 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 sideDistX; // How far has the ray moved horizontally (on the map) ?
|
||||||
let sideDistY; // How far has the ray moved vertically (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
|
// 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
|
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 ?
|
* Any sprites the ray has hit on its way.
|
||||||
//
|
* They are ordered in reverse order of closeness to the camera,
|
||||||
let hit = false;
|
* so that if they are drawn in their array ordering, they will
|
||||||
|
* appear in the correct order on the screen.
|
||||||
//
|
*
|
||||||
// Did the ray hit a wall on a horizontal edge or a vertical edge?
|
* @type {RayCastResult}
|
||||||
//
|
*/
|
||||||
let side = Side.X_AXIS;
|
const result = new RayCastResult();
|
||||||
|
|
||||||
// DDA loop
|
// DDA loop
|
||||||
while (!hit) {
|
while (!result.hitWall) {
|
||||||
//
|
//
|
||||||
// Check if ray is longer than maxDist
|
// Check if ray is longer than viewDistance
|
||||||
if (Math.min(sideDistX, sideDistY) > this.maxDist) {
|
if (Math.min(sideDistX, sideDistY) > this.viewDistance) {
|
||||||
return false; // ray got too long, no hit, exit early
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Check for out of bounds
|
// Check for out of bounds
|
||||||
if (mapX < 0 || mapX >= this.map.width || mapY < 0 || mapY >= this.map.height) {
|
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
|
// Should we step in the x- or y-direction
|
||||||
// DDA dictates we always move along the shortest vector
|
// DDA dictates we always move along the shortest vector
|
||||||
@@ -248,6 +357,13 @@ export class FirstPersonRenderer {
|
|||||||
sideDistX += deltaDistX;
|
sideDistX += deltaDistX;
|
||||||
mapX += stepX;
|
mapX += stepX;
|
||||||
side = Side.X_AXIS;
|
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 {
|
} else {
|
||||||
//
|
//
|
||||||
// Move vertically
|
// Move vertically
|
||||||
@@ -255,72 +371,73 @@ export class FirstPersonRenderer {
|
|||||||
sideDistY += deltaDistY;
|
sideDistY += deltaDistY;
|
||||||
mapY += stepY;
|
mapY += stepY;
|
||||||
side = Side.Y_AXIS;
|
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)) {
|
// Add a Wall to the result
|
||||||
//
|
// (and return)
|
||||||
// Ray hit a wall, proceed to the rest of the algorithm.
|
// --------------------------
|
||||||
//
|
if (tile.wall) {
|
||||||
hit = true;
|
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");
|
("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 {
|
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.r = r;
|
||||||
this.g = g;
|
this.g = g;
|
||||||
this.b = b;
|
this.b = b;
|
||||||
@@ -27,6 +13,10 @@ export class NRGBA {
|
|||||||
this.b *= factor;
|
this.b *= factor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mulledRGB(factor) {
|
||||||
|
return new NRGBA(this.r * factor, this.g * factor, this.b * factor, this.a);
|
||||||
|
}
|
||||||
|
|
||||||
get dR() {
|
get dR() {
|
||||||
return ((this.r * 255) | 0) % 256;
|
return ((this.r * 255) | 0) % 256;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,29 @@ import { Vector2i, Orientation } from "./ascii_types.js";
|
|||||||
import { AsciiWindow } from "./ascii_window.js";
|
import { AsciiWindow } from "./ascii_window.js";
|
||||||
import { Texture } from "./ascii_textureloader.js";
|
import { Texture } from "./ascii_textureloader.js";
|
||||||
|
|
||||||
class Tile {
|
export class Tile {
|
||||||
/** @type {string} How should this tile be rendered on the minimap.*/
|
/** @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? */
|
/** @type {boolean} Should this be rendered as a wall? */
|
||||||
wall = false;
|
wall = false;
|
||||||
|
|
||||||
|
/** @type {boolean} is this tile occupied by a sprite? */
|
||||||
|
sprite = false;
|
||||||
|
|
||||||
/** @type {boolean} Can the player walk here? */
|
/** @type {boolean} Can the player walk here? */
|
||||||
traversable = true;
|
traversable = true;
|
||||||
|
|
||||||
/** @type {boolean} Is this where they player starts? */
|
/** @type {boolean} Is this where they player starts? */
|
||||||
startLocation = false;
|
startLocation = false;
|
||||||
|
|
||||||
/** @type {boolean} Is this where they player starts? */
|
/** @type {boolean} Is this where they player starts? */
|
||||||
textureId = 0;
|
textureId = 0;
|
||||||
|
|
||||||
|
/** @type {Tile} options */
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
for (let [k, v] of Object.entries(options)) {
|
for (let [k, v] of Object.entries(options)) {
|
||||||
if (this[k] !== undefined) {
|
if (this[k] !== undefined) {
|
||||||
@@ -24,40 +35,50 @@ class Tile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const defaultLegend = Object.freeze({
|
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
|
// default floor
|
||||||
" ": new Tile({
|
" ": new Tile({
|
||||||
minimap: " ",
|
minimapChar: " ",
|
||||||
traversable: true,
|
traversable: true,
|
||||||
wall: false,
|
wall: false,
|
||||||
}),
|
}),
|
||||||
//
|
//
|
||||||
// Default wall
|
// Default wall
|
||||||
"#": new Tile({
|
"#": new Tile({
|
||||||
minimap: "#",
|
minimapChar: "#",
|
||||||
traversable: false,
|
traversable: false,
|
||||||
wall: true,
|
wall: true,
|
||||||
textureId: 0,
|
textureId: 0,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
"M": new Tile({
|
||||||
|
textureId: 1,
|
||||||
|
minimapChar: "M",
|
||||||
|
minimapColor: "#f00",
|
||||||
|
traversable: false,
|
||||||
|
wall: false,
|
||||||
|
}),
|
||||||
|
|
||||||
//
|
//
|
||||||
//secret door (looks like wall, but is traversable)
|
//secret door (looks like wall, but is traversable)
|
||||||
"Ω": new Tile({
|
"Ω": new Tile({
|
||||||
minimap: "#",
|
minimapChar: "#",
|
||||||
traversable: true,
|
traversable: true,
|
||||||
wall: 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
|
// where the player starts
|
||||||
"S": new Tile({
|
"S": new Tile({
|
||||||
minimap: "S", // "Š",
|
minimapChar: "S", // "Š",
|
||||||
traversable: true,
|
traversable: true,
|
||||||
wall: false,
|
wall: false,
|
||||||
startLocation: true,
|
startLocation: true,
|
||||||
@@ -101,6 +122,16 @@ export class TileMap {
|
|||||||
return new TileMap(longestLine, lines.length, tiles);
|
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} width
|
||||||
* @param {number} height
|
* @param {number} height
|
||||||
@@ -122,7 +153,7 @@ export class TileMap {
|
|||||||
for (let y = 0; y < this.height; y++) {
|
for (let y = 0; y < this.height; y++) {
|
||||||
for (let x = 0; x < this.width; x++) {
|
for (let x = 0; x < this.width; x++) {
|
||||||
const tile = this.tiles[y][x];
|
const tile = this.tiles[y][x];
|
||||||
result += tile.minimap;
|
result += tile.minimapChar;
|
||||||
}
|
}
|
||||||
result += "\n";
|
result += "\n";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
export class Pixel {
|
export class PseudoPixel {
|
||||||
/**
|
/**
|
||||||
* @param {HTMLElement} el
|
* @param {HTMLElement} htmlElement
|
||||||
* @param {string} char
|
* @param {string} char
|
||||||
* @param {number|string} color text/foreground color
|
* @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*/
|
/** @type {HTMLElement} el the html element that makes up this cell*/
|
||||||
this.el = el;
|
this.htmlElement = htmlElement;
|
||||||
|
|
||||||
/** @type {string} char */
|
/** @type {string} char */
|
||||||
this.char = char;
|
this.char = char;
|
||||||
@@ -15,25 +15,28 @@ export class Pixel {
|
|||||||
/** @type {number|string} fg color color */
|
/** @type {number|string} fg color color */
|
||||||
this.color = color;
|
this.color = color;
|
||||||
|
|
||||||
/** @type {boolean} Has this pixel been updated since it was flushed to DOM ? */
|
/** @type {boolean} Has this pixel's text content been updated since it was flushed to DOM ? */
|
||||||
this.dirty = true;
|
this.dirtyChar = true;
|
||||||
|
|
||||||
|
/** @type {boolean} Has this pixel's color been updated since it was flushed to DOM ? */
|
||||||
|
this.dirtyColor = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
clone() {
|
clone() {
|
||||||
return new Pixel(this.el, this.car, this.color);
|
return new PseudoPixel(this.htmlElement, this.car, this.color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AsciiWindow {
|
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} width Canvas width (in pseudo-pixels)
|
||||||
* @param {number} height Canvas height (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 */
|
/** @type {HTMLElement} the html element that contains all the pseudo-pixels */
|
||||||
this.container = container;
|
this.htmlElement = htmlElement;
|
||||||
|
|
||||||
/** @type {number} width Canvas width (in pseudo-pixels) */
|
/** @type {number} width Canvas width (in pseudo-pixels) */
|
||||||
this.width = width;
|
this.width = width;
|
||||||
@@ -41,8 +44,8 @@ export class AsciiWindow {
|
|||||||
/** @type {number} height Canvas height (in pseudo-pixels) */
|
/** @type {number} height Canvas height (in pseudo-pixels) */
|
||||||
this.height = height;
|
this.height = height;
|
||||||
|
|
||||||
/** @type {Pixel[]} */
|
/** @type {PseudoPixel[]} */
|
||||||
this.canvas = [];
|
this.pseudoPixels = [];
|
||||||
|
|
||||||
this.initializeCanvaas();
|
this.initializeCanvaas();
|
||||||
}
|
}
|
||||||
@@ -56,18 +59,18 @@ export class AsciiWindow {
|
|||||||
const w = this.width;
|
const w = this.width;
|
||||||
const h = this.height;
|
const h = this.height;
|
||||||
|
|
||||||
this.canvas = new Array(w * h).fill();
|
this.pseudoPixels = new Array(w * h).fill();
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
for (let y = 0; y < h; y++) {
|
for (let y = 0; y < h; y++) {
|
||||||
const rowEl = document.createElement("div");
|
const rowEl = document.createElement("div");
|
||||||
this.container.appendChild(rowEl);
|
this.htmlElement.appendChild(rowEl);
|
||||||
|
|
||||||
for (let x = 0; x < w; x++) {
|
for (let x = 0; x < w; x++) {
|
||||||
const pixelEl = document.createElement("code");
|
const pixelEl = document.createElement("code");
|
||||||
rowEl.appendChild(pixelEl);
|
rowEl.appendChild(pixelEl);
|
||||||
pixelEl.textContent = " ";
|
pixelEl.textContent = " ";
|
||||||
this.canvas[i] = new Pixel(pixelEl, " ", "#fff");
|
this.pseudoPixels[i] = new PseudoPixel(pixelEl, " ", "#fff");
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,17 +91,17 @@ export class AsciiWindow {
|
|||||||
this.mustBeWithinBounds(x, y);
|
this.mustBeWithinBounds(x, y);
|
||||||
const idx = this.width * y + x;
|
const idx = this.width * y + x;
|
||||||
|
|
||||||
const pixel = this.canvas[idx];
|
const pixel = this.pseudoPixels[idx];
|
||||||
|
|
||||||
// Check for changes in text contents
|
// Check for changes in text contents
|
||||||
if (char !== undefined && char !== null && char !== pixel.char) {
|
if (char !== undefined && char !== null && char !== pixel.char) {
|
||||||
pixel.char = char;
|
pixel.char = char;
|
||||||
pixel.dirty = true;
|
pixel.dirtyChar = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (color !== undefined && color !== null && color !== pixel.color) {
|
if (color !== undefined && color !== null && color !== pixel.color) {
|
||||||
pixel.color = color;
|
pixel.color = color;
|
||||||
pixel.dirty = true;
|
pixel.dirtyColor = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,14 +111,15 @@ export class AsciiWindow {
|
|||||||
* @return {number} number of DOM updates made
|
* @return {number} number of DOM updates made
|
||||||
*/
|
*/
|
||||||
commitToDOM() {
|
commitToDOM() {
|
||||||
this.canvas.forEach((pixel) => {
|
this.pseudoPixels.forEach((pixel) => {
|
||||||
if (!pixel.dirty) {
|
if (pixel.dirtyChar) {
|
||||||
return;
|
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 |
@@ -35,14 +35,6 @@ export class CharacterSeeder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an item, using an item blueprint with the given name
|
|
||||||
*
|
|
||||||
* @param {string} itemBlueprintId id of the item blueprint
|
|
||||||
* @returns {Item|undefined}
|
|
||||||
*/
|
|
||||||
item(itemBlueprintId) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Character} character
|
* @param {Character} character
|
||||||
* @param {...string} itemBlueprintIds
|
* @param {...string} itemBlueprintIds
|
||||||
@@ -178,7 +170,6 @@ export class CharacterSeeder {
|
|||||||
switch (foundation) {
|
switch (foundation) {
|
||||||
case ":random":
|
case ":random":
|
||||||
return this.applyFoundation(c, roll.d(3));
|
return this.applyFoundation(c, roll.d(3));
|
||||||
break;
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// Brawler
|
// Brawler
|
||||||
@@ -586,3 +577,7 @@ export class CharacterSeeder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Math.PI < 0 && Player) {
|
||||||
|
("STFU Linda");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { gGame } from "../models/globals.js";
|
import { gGame } from "../models/globals.js";
|
||||||
import { Player } from "../models/player.js";
|
|
||||||
|
|
||||||
export class PlayerSeeder {
|
export class PlayerSeeder {
|
||||||
seed() {
|
seed() {
|
||||||
|
|||||||
59
server.js
59
server.js
@@ -46,8 +46,8 @@ class MudServer {
|
|||||||
websocket.on("close", () => {
|
websocket.on("close", () => {
|
||||||
try {
|
try {
|
||||||
this.close(session);
|
this.close(session);
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error("Failed during closing of websocket");
|
console.error("Failed during closing of websocket", { error });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,8 +75,11 @@ class MudServer {
|
|||||||
//
|
//
|
||||||
//----------------------------------------------------------
|
//----------------------------------------------------------
|
||||||
start() {
|
start() {
|
||||||
//
|
/**
|
||||||
// The file types we allow to be served.
|
* The file types we allow to be served.
|
||||||
|
*
|
||||||
|
* @type {Record<string,string>}
|
||||||
|
*/
|
||||||
const contentTypes = {
|
const contentTypes = {
|
||||||
".css": "text/css",
|
".css": "text/css",
|
||||||
".html": "text/html",
|
".html": "text/html",
|
||||||
@@ -87,8 +90,11 @@ class MudServer {
|
|||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
};
|
};
|
||||||
|
|
||||||
//
|
/**
|
||||||
// Create HTTP server for serving the client - Consider moving to own file
|
* HTTP server for serving the MUDClient.
|
||||||
|
*
|
||||||
|
* NOTE: Consider moving to own file
|
||||||
|
*/
|
||||||
const httpServer = http.createServer((req, res) => {
|
const httpServer = http.createServer((req, res) => {
|
||||||
let filePath = path.join("public", req.url === "/" ? "index.html" : req.url);
|
let filePath = path.join("public", req.url === "/" ? "index.html" : req.url);
|
||||||
const ext = path.extname(filePath);
|
const ext = path.extname(filePath);
|
||||||
@@ -119,15 +125,17 @@ class MudServer {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
//
|
/**
|
||||||
// Create WebSocket server
|
* WebSocket Server for serving the MUDClient.
|
||||||
|
*
|
||||||
|
* NOTE: Consider moving to separate file
|
||||||
|
*/
|
||||||
const websocketServer = new WebSocketServer({ server: httpServer });
|
const websocketServer = new WebSocketServer({ server: httpServer });
|
||||||
|
|
||||||
websocketServer.on("connection", (ws) => {
|
websocketServer.on("connection", (ws) => {
|
||||||
this.onConnectionEstabished(ws);
|
this.onConnectionEstabished(ws);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.info(`Environment: ${Config.env}`);
|
|
||||||
httpServer.listen(Config.port, () => {
|
httpServer.listen(Config.port, () => {
|
||||||
console.info(`NUUHD server running on port ${Config.port}`);
|
console.info(`NUUHD server running on port ${Config.port}`);
|
||||||
});
|
});
|
||||||
@@ -210,14 +218,17 @@ class MudServer {
|
|||||||
console.warn("Unknown message type: >>%s<<", msgObj.type, msgObj);
|
console.warn("Unknown message type: >>%s<<", msgObj.type, msgObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ____ _ ___ ____ _____
|
/** ____ _ ___ ____ _____
|
||||||
// / ___| | / _ \/ ___|| ____|
|
* / ___| | / _ \/ ___|| ____|
|
||||||
// | | | | | | | \___ \| _|
|
* | | | | | | | \___ \| _|
|
||||||
// | |___| |__| |_| |___) | |___
|
* | |___| |__| |_| |___) | |___
|
||||||
// \____|_____\___/|____/|_____|
|
* \____|_____\___/|____/|_____|
|
||||||
//-------------------------------
|
* -------------------------------
|
||||||
// Handle Socket Closing
|
* Handle Socket Closing
|
||||||
//----------------------
|
* ----------------------
|
||||||
|
*
|
||||||
|
* @param {Session} session
|
||||||
|
*/
|
||||||
close(session) {
|
close(session) {
|
||||||
const playerName = session.player ? session.player.username : "[unauthenticated]";
|
const playerName = session.player ? session.player.username : "[unauthenticated]";
|
||||||
console.info(playerName + " disconnected");
|
console.info(playerName + " disconnected");
|
||||||
@@ -233,5 +244,19 @@ class MudServer {
|
|||||||
//---------------------------
|
//---------------------------
|
||||||
// Code entry point
|
// Code entry point
|
||||||
//-----------------
|
//-----------------
|
||||||
|
|
||||||
|
console.info(`Environment: ${Config.env}`);
|
||||||
|
|
||||||
const mudserver = new MudServer(/* location of crypto key for saving games */);
|
const mudserver = new MudServer(/* location of crypto key for saving games */);
|
||||||
mudserver.start();
|
mudserver.start();
|
||||||
|
|
||||||
|
//
|
||||||
|
// ____ _____ _____ _ _ _ _ _ _
|
||||||
|
// / ___|_ _| ___| | | | | | (_)_ __ __| | __ _| |
|
||||||
|
// \___ \ | | | |_ | | | | | | | | '_ \ / _` |/ _` | |
|
||||||
|
// ___) || | | _| | |_| | | |___| | | | | (_| | (_| |_|
|
||||||
|
// |____/ |_| |_| \___/ |_____|_|_| |_|\__,_|\__,_(_)
|
||||||
|
//
|
||||||
|
if (Math.PI < 0 && WebSocket) {
|
||||||
|
("STFU Linda");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user