From 2053dd31134eb23818c07804f87ad45a6053dc0f Mon Sep 17 00:00:00 2001 From: Kim Ravn Hansen Date: Sun, 28 Sep 2025 15:03:11 +0200 Subject: [PATCH] wroomba --- ascii_minimap_renderer.js | 90 ++--- frontend/ascii_dungeon_crawler.html | 6 +- frontend/ascii_dungeon_crawler.js | 194 +++++----- frontend/ascii_first_person_renderer.js | 437 ++++++++++++++-------- frontend/ascii_first_person_renderer_2.js | 289 -------------- frontend/ascii_textureloader.js | 20 +- frontend/ascii_tile_map.js | 61 ++- frontend/ascii_window.js | 56 +-- frontend/gnoll.png | Bin 0 -> 6054 bytes seeders/characerSeeder.js | 13 +- seeders/playerSeeder.js | 1 - server.js | 59 ++- 12 files changed, 557 insertions(+), 669 deletions(-) delete mode 100644 frontend/ascii_first_person_renderer_2.js create mode 100755 frontend/gnoll.png diff --git a/ascii_minimap_renderer.js b/ascii_minimap_renderer.js index cbd8e59..21583c7 100755 --- a/ascii_minimap_renderer.js +++ b/ascii_minimap_renderer.js @@ -3,60 +3,60 @@ import { Vector2i } from "./frontend/ascii_types.js"; import { AsciiWindow } from "./frontend/ascii_window.js"; export class MiniMapRenderer { - /** - * @param {AsciiWindow} aWindow - * @param {TileMap} map - */ - constructor(aWindow, map) { - if (aWindow.width !== aWindow.height) { - console.log("Window now square", { width: aWindow.width, height: aWindow.height }); - throw new Error("Window must be square"); - } - if (aWindow.width % 2 === 0) { - console.log("Window dimension must be uneven", { width: aWindow.width, height: aWindow.height }); - throw new Error("Window dimension is even, it must be uneven"); - } + /** + * @param {AsciiWindow} aWindow + * @param {TileMap} map + */ + constructor(aWindow, map) { + if (aWindow.width !== aWindow.height) { + console.log("Window now square", { width: aWindow.width, height: aWindow.height }); + throw new Error("Window must be square"); + } + if (aWindow.width % 2 === 0) { + console.log("Window dimension must be uneven", { width: aWindow.width, height: aWindow.height }); + throw new Error("Window dimension is even, it must be uneven"); + } - /** @type {AsciiWindow} */ - this.window = aWindow; + /** @type {AsciiWindow} */ + this.window = aWindow; - /** @type {TileMap} */ - this.map = map; + /** @type {TileMap} */ + this.map = map; - /** @type {number} how far we can see on the minimap */ - this.distance = (aWindow.width - 1) / 2; + /** @type {number} how far we can see on the minimap */ + this.distance = (aWindow.width - 1) / 2; - 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.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 + } - /** - * @param {number} centerX - * @param {number} centerY - * @param {Orientation} orientation - */ - draw(centerX, centerY, orientation) { - // these variables are the coordinates of the - // area of the map (not minimap) we are looking at - const minX = centerX - this.distance; - const maxX = centerX + this.distance; - const minY = centerY - this.distance; - const maxY = centerY + this.distance; + /** + * @param {number} centerX + * @param {number} centerY + * @param {Orientation} orientation + */ + draw(centerX, centerY, orientation) { + // these variables are the coordinates of the + // area of the map (not minimap) we are looking at + const minX = centerX - this.distance; + const maxX = centerX + this.distance; + const minY = 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 x = minX; x <= maxX; x++) { - const wndPosV = new Vector2i(x - centerX, y - centerY).rotateCW(orientation + 1).add(distanceV); + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + 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(this.distance, this.distance, "@", "#44F"); - this.window.commitToDOM(); - } + 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.commitToDOM(); + } } if (Math.PI < 0 && AsciiWindow && TileMap && Vector2i) { - ("STFU Linda"); + ("STFU Linda"); } diff --git a/frontend/ascii_dungeon_crawler.html b/frontend/ascii_dungeon_crawler.html index 32a7abf..aebe732 100755 --- a/frontend/ascii_dungeon_crawler.html +++ b/frontend/ascii_dungeon_crawler.html @@ -87,9 +87,9 @@ ############################################################ ## ################# ######################## ## # # ################# # ## ######################## -## # ################# # ## ################ -## # # S ################# # ## #### #### -## # # ## # #### # # #### +## #S ################# # ## ################ +## # # ################# # ## #### #### +## M # # ## # #### # # #### ###### #################### ## #### # #### ###### #################### # ## # # #### #### ###### #################### # #### diff --git a/frontend/ascii_dungeon_crawler.js b/frontend/ascii_dungeon_crawler.js index aa95658..73f2978 100755 --- a/frontend/ascii_dungeon_crawler.js +++ b/frontend/ascii_dungeon_crawler.js @@ -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( + "
%s
%s
", this.player._posV, Object.keys(Orientation)[this.player.orientation].toLowerCase(), - this.player.orientation, - this.player.orientation * PI_OVER_TWO, - this.player.orientation * 90, ); } } diff --git a/frontend/ascii_first_person_renderer.js b/frontend/ascii_first_person_renderer.js index 87140f6..1a403c2 100755 --- a/frontend/ascii_first_person_renderer.js +++ b/frontend/ascii_first_person_renderer.js @@ -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>} + */ + 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 { + 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} 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; + + // + // 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; + // + 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"); } diff --git a/frontend/ascii_first_person_renderer_2.js b/frontend/ascii_first_person_renderer_2.js deleted file mode 100644 index cf3a2a1..0000000 --- a/frontend/ascii_first_person_renderer_2.js +++ /dev/null @@ -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 - } -} diff --git a/frontend/ascii_textureloader.js b/frontend/ascii_textureloader.js index a68bbca..69a420e 100755 --- a/frontend/ascii_textureloader.js +++ b/frontend/ascii_textureloader.js @@ -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; } diff --git a/frontend/ascii_tile_map.js b/frontend/ascii_tile_map.js index e422b8f..a7d9541 100755 --- a/frontend/ascii_tile_map.js +++ b/frontend/ascii_tile_map.js @@ -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"; } diff --git a/frontend/ascii_window.js b/frontend/ascii_window.js index f41d654..c1aae9c 100755 --- a/frontend/ascii_window.js +++ b/frontend/ascii_window.js @@ -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; }); } } diff --git a/frontend/gnoll.png b/frontend/gnoll.png new file mode 100755 index 0000000000000000000000000000000000000000..b6515be4e9dce5ae67d2953af282ec09535c083e GIT binary patch literal 6054 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 g21!IgR09A~RPH?_Mz~l20000 { try { this.close(session); - } catch (e) { - console.error("Failed during closing of websocket"); + } catch (error) { + console.error("Failed during closing of websocket", { error }); } }); @@ -75,8 +75,11 @@ class MudServer { // //---------------------------------------------------------- start() { - // - // The file types we allow to be served. + /** + * The file types we allow to be served. + * + * @type {Record} + */ const contentTypes = { ".css": "text/css", ".html": "text/html", @@ -87,8 +90,11 @@ class MudServer { ".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) => { let filePath = path.join("public", req.url === "/" ? "index.html" : req.url); 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 }); websocketServer.on("connection", (ws) => { this.onConnectionEstabished(ws); }); - console.info(`Environment: ${Config.env}`); httpServer.listen(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); } - // ____ _ ___ ____ _____ - // / ___| | / _ \/ ___|| ____| - // | | | | | | | \___ \| _| - // | |___| |__| |_| |___) | |___ - // \____|_____\___/|____/|_____| - //------------------------------- - // Handle Socket Closing - //---------------------- + /** ____ _ ___ ____ _____ + * / ___| | / _ \/ ___|| ____| + * | | | | | | | \___ \| _| + * | |___| |__| |_| |___) | |___ + * \____|_____\___/|____/|_____| + * ------------------------------- + * Handle Socket Closing + * ---------------------- + * + * @param {Session} session + */ close(session) { const playerName = session.player ? session.player.username : "[unauthenticated]"; console.info(playerName + " disconnected"); @@ -233,5 +244,19 @@ class MudServer { //--------------------------- // Code entry point //----------------- + +console.info(`Environment: ${Config.env}`); + const mudserver = new MudServer(/* location of crypto key for saving games */); mudserver.start(); + +// +// ____ _____ _____ _ _ _ _ _ _ +// / ___|_ _| ___| | | | | | (_)_ __ __| | __ _| | +// \___ \ | | | |_ | | | | | | | | '_ \ / _` |/ _` | | +// ___) || | | _| | |_| | | |___| | | | | (_| | (_| |_| +// |____/ |_| |_| \___/ |_____|_|_| |_|\__,_|\__,_(_) +// +if (Math.PI < 0 && WebSocket) { + ("STFU Linda"); +}