This commit is contained in:
Kim Ravn Hansen
2025-09-28 15:03:11 +02:00
parent 95068939af
commit 2053dd3113
12 changed files with 557 additions and 669 deletions

View File

@@ -49,7 +49,7 @@ export class MiniMapRenderer {
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(wndPosV.x, wndPosV.y, this.map.get(x, y).minimapChar, this.fg, this.bg);
}
}
this.window.put(this.distance, this.distance, "@", "#44F");

View File

@@ -87,9 +87,9 @@
############################################################
## ################# ########################
## # # ################# # ## ########################
## # ################# # ## ################
## # # S ################# # ## #### ####
## # # ## # #### # # ####
## #S ################# # ## ################
## # # ################# # ## #### ####
## M # # ## # #### # # ####
###### #################### ## #### # ####
###### #################### # ## # # #### ####
###### #################### # ####

View File

@@ -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();
requestAnimationFrame(() => this.gameLoop());
//
// 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;
}
updateCompass() {
//
// 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
}
renderCompass() {
//
//
// Update the compass
document.getElementById("compass").textContent = sprintf(
"%s %s (%d --> %.2f [%dº])",
document.getElementById("compass").innerHTML = sprintf(
"<div>%s</div><div>%s</div>",
this.player._posV,
Object.keys(Orientation)[this.player.orientation].toLowerCase(),
this.player.orientation,
this.player.orientation * PI_OVER_TWO,
this.player.orientation * 90,
);
}
}

View File

@@ -1,20 +1,65 @@
import { TileMap } from "./ascii_tile_map.js";
import { NRGBA } from "./ascii_textureloader.js";
import { TileMap, Tile } from "./ascii_tile_map.js";
import { AsciiWindow } from "./ascii_window.js";
/**
* Which side of a tile did the ray strike
*/
export const Side = {
X_AXIS: 0,
Y_AXIS: 1,
};
class RayCollision {
mapX = 0;
mapY = 0;
rayLength = 0;
side = Side.X_AXIS;
/** @type {Tile} */
tile;
}
class RayCastResult {
hitWall = false;
hitSprite = false;
wallCollision = new RayCollision();
/** @type {RayCollision[]} */
collisions = [];
}
/**
* @typedef {object} FirstPersonRendererOptions
* @property {string} wallChar
* @property {NRGBA} floorColor
* @property {string} floorChar
* @property {NRGBA} ceilingColor
* @property {string} ceilingChar
* @property {number} viewDistance
* @property {number} fov
*/
/**
* @type {FirstPersonRendererOptions}
*/
export const DefaultRendererOptions = {
wallChar: "W",
floorColor: new NRGBA(0.365, 0.165, 0.065),
floorChar: "f",
ceilingColor: new NRGBA(0.3, 0.3, 0.3),
ceilingChar: "c",
fadeOutColor: new NRGBA(0.3, 0.3, 0.3),
viewDistance: 5,
fov: Math.PI / 3, // 60 degrees - good for spooky
};
export class FirstPersonRenderer {
/**
* @param {AsciiWindow} aWindow the window we render onto.
* @param {TileMap} map
* @param {number} fov field of view (in radians)
* @param {number} maxDist maximum view distance.
* @param {TexturePack} textures
* @param {Texture[]} textures
* @param {FirstPersonRendererOptions} options
*/
constructor(aWindow, map, fov, maxDist, textures) {
constructor(aWindow, map, textures, options) {
/** @constant @readonly @type {TileMap} */
this.map = map;
@@ -22,91 +67,161 @@ export class FirstPersonRenderer {
this.window = aWindow;
/** @constant @readonly @type {number} */
this.fov = fov;
this.fov = options.fov ?? DefaultRendererOptions.fov;
/** @constant @readonly @type {number} */
this.maxDist = maxDist;
this.viewDistance = options.viewDistance ?? DefaultRendererOptions.viewDistance;
/** @constant @readonly @type {Texture[]} */
this.textures = textures;
/** @constant @readonly @type {string} */
this.wallChar = options.wallChar ?? DefaultRendererOptions.wallChar;
/** @constant @readonly @type {NRGBA} */
this.floorColor = options.floorColor ?? DefaultRendererOptions.floorColor;
/** @constant @readonly @type {string} */
this.floorChar = options.floorChar ?? DefaultRendererOptions.floorChar;
/** @constant @readonly @type {NRGBA} */
this.ceilingColor = options.ceilingColor ?? DefaultRendererOptions.ceilingColor;
/** @constant @readonly @type {string} */
this.ceilingChar = options.ceilingChar ?? DefaultRendererOptions.ceilingChar;
/**
* Pre-computed colors to use when drawing floors, ceilings and "fadeout"
*
* There is one entry for every screen row.
* Each entry contains a color to use when drawing floors, ceilings, and "fadeout".
*
* @constant @readonly @type {Array<Array<string>>}
*/
this.shades = [];
/**
* Pre-compute the shades variable
*/
this.computeShades();
}
computeShades() {
const screenHeight = this.window.height;
const halfScreenHeight = screenHeight / 2;
const lineHeight = Math.floor(screenHeight / this.viewDistance);
const minY = Math.floor(-lineHeight / 2 + halfScreenHeight); // if y lower than minY, then we're painting ceiling
const maxY = Math.floor(lineHeight / 2 + halfScreenHeight); // if y higher than maxY then we're painting floor
for (let y = 0; y < screenHeight; y++) {
if (y < minY) {
//
// y is smaller than minY. This means we're painting above
// the walls, i.e. painting the ceiling.
// The closer y is to minY, the farther away this part of the
// ceiling is.
//
// High diff => near
// Low diff => far
//
const diff = minY - y;
this.shades.push([this.ceilingChar, this.ceilingColor.mulledRGB(diff / minY).toCSS()]);
} else if (y >= maxY) {
//
// Floor
//
const diff = y - maxY;
this.shades.push([this.floorChar, this.floorColor.mulledRGB(diff / minY).toCSS()]);
} else {
//
// The darkness at the end of the tunnel
//
this.shades.push([" ", "#000"]);
}
}
}
renderFrame(posX, posY, dirAngle, commit = true) {
const benchmarkStart = performance.now();
const screenWidth = this.window.width;
/** @type {Map<number,Tile} The coordinates of all the tiles checked while rendering this frame*/
const coordsCheckedFrame = new Map();
for (let x = 0; x < screenWidth; x++) {
/** @type {Map<number,Tile} The coordinates of all the tiles checked while casting this single ray*/
const coordsCheckedRay = new Map();
const angleOffset = (x / screenWidth - 0.5) * this.fov; // in radians
const rayAngle = dirAngle + angleOffset;
const rayDirX = Math.cos(rayAngle);
const rayDirY = Math.sin(rayAngle);
// Cast ray using our DDA function
const hit = this.castRay(posX, posY, rayDirX, rayDirY, rayAngle);
const ray = this.castRay(posX, posY, rayDirX, rayDirY, coordsCheckedRay);
coordsCheckedRay.forEach((tile, idx) => {
coordsCheckedFrame.set(idx, tile);
});
//
// Did we hit something?
//
if (!hit) {
// we did not hit anything. Either the ray went out of bounds,
// or it went too far, so move on to next pseudo-pixel
this.renderNoHitCol(x);
continue;
// Render a single screen column
this.renderColumn(x, ray, rayDirX, rayDirY, angleOffset);
}
//
// Our ray hit a wall, render it.
this.renderHitCol(x, hit, rayDirX, rayDirY, angleOffset);
const renderTime = performance.now() - benchmarkStart;
// Did it take more than 5ms to render the scene?
if (renderTime > 5) {
console.log("Rendering took a long time", { renderTime });
}
if (commit) {
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 vertical column of pixels on the screen at the x coordinate.
* This occurs when the ray did not hit anything.
*
* @protected
*/
renderNoHitCol(x) {
const screenHeight = this.window.height;
const halfScreenHieght = screenHeight / 2;
const lineHeight = Math.floor(screenHeight / this.maxDist);
let minY = Math.floor(-lineHeight / 2 + halfScreenHieght);
let maxY = Math.floor(lineHeight / 2 + halfScreenHieght);
for (let y = 0; y < screenHeight; y++) {
if (y < minY) {
this.window.put(x, y, "c", "#333"); // ceiling
} else if (y > maxY) {
this.window.put(x, y, "f", "#b52"); // floor
} else {
const char = ["·", "÷", "'", "~"][(y + x) % 4];
this.window.put(x, y, char, "#222"); // the far distance
}
});
}
}
/**
* Render a column on the screen where the ray hit a wall.
* @param {number} x
* @param {RayCastResult} ray
* @param {number} rayDirX
* @param {number} rayDirY
* @param {number} angleOffset for far (in radians) is this column from the middle of the screen
*
* @protected
*/
renderHitCol(x, hit, rayDirX, rayDirY, angleOffset) {
const { rayLength, side, textureOffsetX, mapX, mapY } = hit;
renderColumn(x, ray, rayDirX, rayDirY, angleOffset) {
//
// Check if we hit anything at all
if (ray.collisions.length === 0) {
//
// We didn't hit anything. Just paint floor, wall, and darkness
for (let y = 0; y < this.window.height; y++) {
const [char, color] = this.shades[y];
this.window.put(x, y, char, color);
}
return;
}
const tile = this.map.get(mapX, mapY);
const safeDistance = Math.max(rayLength * Math.cos(angleOffset), 1e-9); // Avoid divide by zero
const { rayLength, side, sampleU, tile: wallTile } = ray.collisions[0];
const distance = Math.max(rayLength * Math.cos(angleOffset), 1e-12); // Avoid divide by zero
//
// Calculate perspective.
//
const screenHeight = this.window.height;
const halfScreenHieght = screenHeight / 2;
const lineHeight = Math.floor(screenHeight / safeDistance);
let minY = Math.floor(-lineHeight / 2 + halfScreenHieght);
let maxY = Math.floor(lineHeight / 2 + halfScreenHieght);
let unsafeMinY = minY; // can be lower than zero
const lineHeight = Math.round(screenHeight / distance); // using round() because floor() gives aberrations when distance == (n + 0.500)
const halfScreenHeight = screenHeight / 2;
const halfLineHeight = lineHeight / 2;
let minY = Math.floor(halfScreenHeight - halfLineHeight);
let maxY = Math.floor(halfScreenHeight + halfLineHeight);
let unsafeMinY = minY; // can be lower than zero - it happens when we get so close to a wall we cannot see top or bottom
if (minY < 0) {
minY = 0;
@@ -115,30 +230,26 @@ export class FirstPersonRenderer {
maxY = screenHeight - 1;
}
//
// Pick texture (here grid value decides which texture)
const texture = this.textures[tile.textureId % this.textures.length];
// X coord on texture
let sampleU = textureOffsetX;
if (side === 0 && rayDirX > 0) {
sampleU = 1 - sampleU;
}
if (side === 1 && rayDirY < 0) {
sampleU = 1 - sampleU;
}
//
const wallTexture = this.textures[wallTile.textureId];
for (let y = 0; y < screenHeight; y++) {
//
// Are we hitting the ceiling?
//
if (y < minY) {
this.window.put(x, y, "c", "#333");
if (y < minY || y > maxY) {
const [char, color] = this.shades[y];
this.window.put(x, y, char, color);
continue;
}
if (y > maxY) {
this.window.put(x, y, "f", "#b52");
if (y === minY) {
this.window.put(x, y, "m", "#0F0");
continue;
}
if (y === maxY) {
this.window.put(x, y, "M", "#F00");
continue;
}
@@ -146,44 +257,39 @@ export class FirstPersonRenderer {
// Map screen y to texture y
let sampleV = (y - unsafeMinY) / lineHeight; // y- coordinate of the texture point to sample
const color = texture.sample(sampleU, sampleV);
const color = wallTexture.sample(sampleU, sampleV);
//
// North-south walls are shaded differently from east-west walls
let shade = side === Side.X_AXIS ? 0.7 : 1.0; // MAGIC NUMBERS
let shade = side === Side.X_AXIS ? 0.8 : 1.0; // MAGIC NUMBERS
//
// Dim walls that are far away
shade = shade / (1 + rayLength * 0.1);
const lightLevel = 1 - rayLength / this.viewDistance;
//
// Darken the image
color.mulRGB(shade);
color.mulRGB(shade * lightLevel);
// const distancePalette = ["█", "▓", "▒", "░", " "];
const distancePalette = ["#", "#", "#", "%", "+", "÷", " ", " "];
const char = distancePalette[rayLength | 0];
this.window.put(x, y, char, color.toCSS());
this.window.put(x, y, this.wallChar, color.toCSS());
}
}
/**
* @param {number} camX x-coordinate of the camera (is the same
* @param {number} camY y-coordinate of the camera
* @parma {number} dirX x-coordinate of the normalized vector of the viewing direction of the camera.
* @parma {number} dirX y-coordinate of the normalized vector of the viewing direction of the camera.
* @param {number} dirX x-coordinate of the normalized vector of the viewing direction of the camera.
* @param {number} dirX y-coordinate of the normalized vector of the viewing direction of the camera.
* @param {Set<number>} coodsChecked
*
* @returns {RayCastResult}
*
*/
castRay(camX, camY, dirX, dirY) {
castRay(camX, camY, dirX, dirY, coordsChecked) {
// Current map square
let mapX = Math.floor(camX);
let mapY = Math.floor(camY);
if (dirX === 0 || dirY === 0) {
console.log("Divide by zero is incoming", { dirX, dirY });
}
Number.MAX_SAFE_INTEGER;
// Length of ray from one x or y-side to next x or y-side
const deltaDistX = dirX === 0 ? 1e15 : Math.abs(1 / dirX);
const deltaDistY = dirY === 0 ? 1e15 : Math.abs(1 / dirY);
@@ -193,6 +299,7 @@ export class FirstPersonRenderer {
let stepY; // When DDA takes a vertical step (on the map), how far should it move?
let sideDistX; // How far has the ray moved horizontally (on the map) ?
let sideDistY; // How far has the ray moved vertically (on the map) ?
let side = Side.X_AXIS;
//
// Calculate how to move along the x-axis
@@ -214,30 +321,32 @@ export class FirstPersonRenderer {
sideDistY = (mapY + 1.0 - camY) * deltaDistY; // we've moved from the camera to the top edge of the tile
}
//
// Did the ray hit a wall ?
//
let hit = false;
//
// Did the ray hit a wall on a horizontal edge or a vertical edge?
//
let side = Side.X_AXIS;
/**
* Any sprites the ray has hit on its way.
* They are ordered in reverse order of closeness to the camera,
* so that if they are drawn in their array ordering, they will
* appear in the correct order on the screen.
*
* @type {RayCastResult}
*/
const result = new RayCastResult();
// DDA loop
while (!hit) {
while (!result.hitWall) {
//
// Check if ray is longer than maxDist
if (Math.min(sideDistX, sideDistY) > this.maxDist) {
return false; // ray got too long, no hit, exit early
// Check if ray is longer than viewDistance
if (Math.min(sideDistX, sideDistY) > this.viewDistance) {
return result;
}
//
// Check for out of bounds
if (mapX < 0 || mapX >= this.map.width || mapY < 0 || mapY >= this.map.height) {
return false; // ray got outside the map, no hit, exit early
return result;
}
let wallDist, sampleU;
//
// Should we step in the x- or y-direction
// DDA dictates we always move along the shortest vector
@@ -248,6 +357,13 @@ export class FirstPersonRenderer {
sideDistX += deltaDistX;
mapX += stepX;
side = Side.X_AXIS;
// Ray hit the east or west edge of the wall-tile
wallDist = (mapX - camX + (1 - stepX) / 2) / dirX;
sampleU = (camY + wallDist * dirY) % 1;
if (dirX > 0) {
sampleU = 1 - sampleU;
}
} else {
//
// Move vertically
@@ -255,72 +371,73 @@ export class FirstPersonRenderer {
sideDistY += deltaDistY;
mapY += stepY;
side = Side.Y_AXIS;
}
//
// 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;
// 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;
}
}
//
// 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 tile = this.map.get(mapX, mapY);
coordsChecked.set(this.map.tileIdx(mapX, mapY), tile);
const rayLength = Math.hypot(
perpWallDist * dirX, //
perpWallDist * dirY, //
wallDist * dirX, //
wallDist * dirY, //
);
return {
mapX,
mapY,
side,
rayLength,
textureOffsetX,
};
//
// --------------------------
// 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;
}
}
if (Math.PI < 0 && AsciiWindow && TileMap) {
//
// --------------------------
// Add a Wall to the result
// (and return)
// --------------------------
if (tile.wall) {
result.hitWall = true;
// <todo>
// DELETE BELOW
result.wallCollision.tile = tile;
result.wallCollision.side = side;
result.wallCollision.mapX = mapX;
result.wallCollision.mapY = mapY;
result.wallCollision.rayLength = rayLength;
result.wallCollision.sampleU = sampleU;
// </todo>
return result;
}
}
}
}
if (Math.PI < 0 && AsciiWindow && TileMap && Tile) {
("STFU Linda");
}

View File

@@ -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
}
}

View File

@@ -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;
}

View File

@@ -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";
}

View File

@@ -1,13 +1,13 @@
export class Pixel {
export class PseudoPixel {
/**
* @param {HTMLElement} el
* @param {HTMLElement} htmlElement
* @param {string} char
* @param {number|string} color text/foreground color
*/
constructor(el, char = " ", color = "#fff") {
constructor(htmlElement, char = " ", color = "#fff") {
//
/** @type {HTMLElement} el the html element that makes up this cell*/
this.el = el;
this.htmlElement = htmlElement;
/** @type {string} char */
this.char = char;
@@ -15,25 +15,28 @@ export class Pixel {
/** @type {number|string} fg color color */
this.color = color;
/** @type {boolean} Has this pixel been updated since it was flushed to DOM ? */
this.dirty = true;
/** @type {boolean} Has this pixel's text content been updated since it was flushed to DOM ? */
this.dirtyChar = true;
/** @type {boolean} Has this pixel's color been updated since it was flushed to DOM ? */
this.dirtyColor = true;
}
clone() {
return new Pixel(this.el, this.car, this.color);
return new PseudoPixel(this.htmlElement, this.car, this.color);
}
}
export class AsciiWindow {
/**
* @param {HTMLElement} container
* @param {HTMLElement} htmlElement the html element that contains all the pseudo-pixel elements
* @param {number} width Canvas width (in pseudo-pixels)
* @param {number} height Canvas height (in pseudo-pixels)
*/
constructor(container, width, height) {
constructor(htmlElement, width, height) {
//
/** @type {HTMLElement} Paren element that contains all the pseudo-pixels */
this.container = container;
/** @type {HTMLElement} the html element that contains all the pseudo-pixels */
this.htmlElement = htmlElement;
/** @type {number} width Canvas width (in pseudo-pixels) */
this.width = width;
@@ -41,8 +44,8 @@ export class AsciiWindow {
/** @type {number} height Canvas height (in pseudo-pixels) */
this.height = height;
/** @type {Pixel[]} */
this.canvas = [];
/** @type {PseudoPixel[]} */
this.pseudoPixels = [];
this.initializeCanvaas();
}
@@ -56,18 +59,18 @@ export class AsciiWindow {
const w = this.width;
const h = this.height;
this.canvas = new Array(w * h).fill();
this.pseudoPixels = new Array(w * h).fill();
let i = 0;
for (let y = 0; y < h; y++) {
const rowEl = document.createElement("div");
this.container.appendChild(rowEl);
this.htmlElement.appendChild(rowEl);
for (let x = 0; x < w; x++) {
const pixelEl = document.createElement("code");
rowEl.appendChild(pixelEl);
pixelEl.textContent = " ";
this.canvas[i] = new Pixel(pixelEl, " ", "#fff");
this.pseudoPixels[i] = new PseudoPixel(pixelEl, " ", "#fff");
i++;
}
}
@@ -88,17 +91,17 @@ export class AsciiWindow {
this.mustBeWithinBounds(x, y);
const idx = this.width * y + x;
const pixel = this.canvas[idx];
const pixel = this.pseudoPixels[idx];
// Check for changes in text contents
if (char !== undefined && char !== null && char !== pixel.char) {
pixel.char = char;
pixel.dirty = true;
pixel.dirtyChar = true;
}
if (color !== undefined && color !== null && color !== pixel.color) {
pixel.color = color;
pixel.dirty = true;
pixel.dirtyColor = true;
}
}
@@ -108,14 +111,15 @@ export class AsciiWindow {
* @return {number} number of DOM updates made
*/
commitToDOM() {
this.canvas.forEach((pixel) => {
if (!pixel.dirty) {
return;
this.pseudoPixels.forEach((pixel) => {
if (pixel.dirtyChar) {
pixel.htmlElement.textContent = pixel.char;
pixel.dirtyChar = false;
}
if (pixel.dirtyColor) {
pixel.htmlElement.style.color = pixel.color;
pixel.dirtyColor = false;
}
pixel.el.textContent = pixel.char;
pixel.el.style.color = pixel.color;
pixel.dirty = false;
});
}
}

BIN
frontend/gnoll.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -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 {...string} itemBlueprintIds
@@ -178,7 +170,6 @@ export class CharacterSeeder {
switch (foundation) {
case ":random":
return this.applyFoundation(c, roll.d(3));
break;
//
// Brawler
@@ -586,3 +577,7 @@ export class CharacterSeeder {
}
}
}
if (Math.PI < 0 && Player) {
("STFU Linda");
}

View File

@@ -1,5 +1,4 @@
import { gGame } from "../models/globals.js";
import { Player } from "../models/player.js";
export class PlayerSeeder {
seed() {

View File

@@ -46,8 +46,8 @@ class MudServer {
websocket.on("close", () => {
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<string,string>}
*/
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");
}