truthstorian

This commit is contained in:
Kim Ravn Hansen
2025-09-26 09:01:29 +02:00
parent 30a0842aa1
commit 95068939af
16 changed files with 1577 additions and 661 deletions

62
ascii_minimap_renderer.js Executable file
View File

@@ -0,0 +1,62 @@
import { TileMap } from "./frontend/ascii_tile_map.js";
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");
}
/** @type {AsciiWindow} */
this.window = aWindow;
/** @type {TileMap} */
this.map = map;
/** @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
}
/**
* @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);
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();
}
}
if (Math.PI < 0 && AsciiWindow && TileMap && Vector2i) {
("STFU Linda");
}

View File

@@ -1,41 +0,0 @@
function dataset() {
return new Array(4000).fill().map(() => "fusse".repeat(5));
}
const simCount = 100_000;
const start = Date.now();
const target = "not found";
for (let sim = 0; sim < simCount; sim++) {
const ds = dataset();
// const len = ds.length;
// for (let i = 0; i < len; i++) {
// // pretend to do work on the data elements
// const el = ds[i];
// if (el === target) {
// console.log("foo");
// }
// }
// ds.forEach((el) => {
// if (el === target) {
// console.log("foo");
// }
// });
while (ds.length > 0) {
let el = ds.pop();
if (el === target) {
console.log("foo");
}
}
}
console.log("time: %f msec", Date.now() - start);
// for-loop : 8568 msec
// .forEach : 8551 msec
// pop() : 8765 msec

212
dda.txt Normal file
View File

@@ -0,0 +1,212 @@
You said:
Im making a dungeon crawler where all the walls are guaranteed to be tile-aligned. It's wolfensteil graphics, so no need for real ray tracing, only vertical ray casting in 2d. Can you help me make a fast javascript ray-caster that does not just increment the rays a tiny bit every time. I think the algorithm may be called DDA. I also need to know where on the wall the ray hits, so I can apply the correct texture
ChatGPT said:
Yes — youre talking about the DDA (Digital Differential Analyzer) algorithm, which is the standard way to do fast 2D grid ray casting for Wolfenstein-style engines.
The idea: instead of stepping pixel by pixel, you compute how far you need to go to reach the next vertical or next horizontal grid line, then keep stepping grid-by-grid until you hit a wall.
Heres a compact JavaScript implementation outline:
// grid: 2D array of 0 = empty, 1 = wall
// posX, posY: player position in map coords (floats, tile units)
// dirX, dirY: ray direction (normalized vector)
// maxDist: optional cutoff distance
function castRay(grid, posX, posY, dirX, dirY, maxDist = 100) {
const mapWidth = grid[0].length;
const mapHeight = grid.length;
// Current map square
let mapX = Math.floor(posX);
let mapY = Math.floor(posY);
// Length of ray from one x or y-side to next x or y-side
const deltaDistX = Math.abs(1 / dirX);
const deltaDistY = Math.abs(1 / dirY);
// Step direction (+1 or -1) and initial sideDist
let stepX, stepY;
let sideDistX, sideDistY;
if (dirX < 0) {
stepX = -1;
sideDistX = (posX - mapX) * deltaDistX;
} else {
stepX = 1;
sideDistX = (mapX + 1.0 - posX) * deltaDistX;
}
if (dirY < 0) {
stepY = -1;
sideDistY = (posY - mapY) * deltaDistY;
} else {
stepY = 1;
sideDistY = (mapY + 1.0 - posY) * deltaDistY;
}
let hit = false;
let side = 0; // 0 = hit x-side, 1 = hit y-side
// DDA loop
while (!hit) {
if (sideDistX < sideDistY) {
sideDistX += deltaDistX;
mapX += stepX;
side = 0;
} else {
sideDistY += deltaDistY;
mapY += stepY;
side = 1;
}
// Check for out of bounds
if (mapX < 0 || mapX >= mapWidth || mapY < 0 || mapY >= mapHeight) {
return null; // no hit
}
// Check if ray hit a wall
if (grid[mapY][mapX] > 0) hit = true;
if (Math.min(sideDistX, sideDistY) > maxDist) return null;
}
// Calculate distance to wall
let perpWallDist;
if (side === 0) {
perpWallDist = (mapX - posX + (1 - stepX) / 2) / dirX;
} else {
perpWallDist = (mapY - posY + (1 - stepY) / 2) / dirY;
}
// Where on the wall did we hit? (texture coordinate)
let wallX;
if (side === 0) {
wallX = posY + perpWallDist * dirY;
} else {
wallX = posX + perpWallDist * dirX;
}
wallX -= Math.floor(wallX); // fractional part only [0..1)
return {
mapX, mapY, // tile coords
side, // hit vertical or horizontal wall
distance: perpWallDist,
textureX: wallX // use this to index into texture
};
}
Key points:
DDA stepping means you only move to the next grid line each time → very fast.
side tells you if you hit a vertical or horizontal wall (so you can darken one side for fake lighting).
textureX gives you the fractional offset into the wall tile (for correct texture mapping).
👉 Then in your renderer, for each vertical screen stripe:
Compute the ray direction from the camera plane.
Call castRay.
Use distance to determine column height.
Use textureX to select the right column from your texture.
Do you want me to also show you how to set up the main loop that casts all rays for the screen (like Wolfensteins projection plane math), or are you mainly looking for just the DDA core?
You said:
yes please
ChatGPT said:
Perfect — lets wire the DDA ray-caster into a full “Wolfenstein” style projection loop.
The basic camera model:
Player has a position (posX, posY) and a direction vector (dirX, dirY).
You also need a perpendicular camera plane (planeX, planeY) that defines the field of view.
Typical setup: dirX=1, dirY=0 and planeX=0, planeY=0.66 → ~66° FOV.
Then, for each screen column x, you build a ray direction, cast it with DDA, and draw the vertical stripe.
Heres a skeleton in JavaScript:
function renderFrame(ctx, screenWidth, screenHeight, grid, posX, posY, dirX, dirY, planeX, planeY, textures) {
for (let x = 0; x < screenWidth; x++) {
// cameraX: -1 on left side, +1 on right side of screen
const cameraX = 2 * x / screenWidth - 1;
const rayDirX = dirX + planeX * cameraX;
const rayDirY = dirY + planeY * cameraX;
// Cast ray using our DDA function
const hit = castRay(grid, posX, posY, rayDirX, rayDirY);
if (!hit) continue; // nothing hit (out of bounds)
const { distance, side, textureX, mapX, mapY } = hit;
// Projected wall height (avoid div by zero)
const lineHeight = Math.floor(screenHeight / (distance || 0.0001));
// Draw start and end of wall stripe
let drawStart = Math.floor(-lineHeight / 2 + screenHeight / 2);
let drawEnd = Math.floor(lineHeight / 2 + screenHeight / 2);
if (drawStart < 0) drawStart = 0;
if (drawEnd >= screenHeight) drawEnd = screenHeight - 1;
// Pick texture (here grid value decides which texture)
const tex = textures[grid[mapY][mapX] % textures.length];
const texWidth = tex.width, texHeight = tex.height;
// X coord on texture
let texX = Math.floor(textureX * texWidth);
if (side === 0 && rayDirX > 0) texX = texWidth - texX - 1;
if (side === 1 && rayDirY < 0) texX = texWidth - texX - 1;
// Draw vertical stripe from texture
const column = ctx.getImageData(texX, 0, 1, texHeight); // one column
for (let y = drawStart; y < drawEnd; y++) {
// Map screen y to texture y
const d = y * 256 - screenHeight * 128 + lineHeight * 128;
const texY = Math.floor(((d * texHeight) / lineHeight) / 256);
const texIndex = (texY * texWidth + texX) * 4;
const r = column.data[texIndex];
const g = column.data[texIndex + 1];
const b = column.data[texIndex + 2];
const a = column.data[texIndex + 3];
// Simple shading: darken if y-side
const shade = side === 1 ? 0.7 : 1.0;
ctx.fillStyle = `rgba(${r * shade}, ${g * shade}, ${b * shade}, ${a / 255})`;
ctx.fillRect(x, y, 1, 1);
}
}
}
Whats happening:
Camera math
Each screen column x corresponds to a ray direction via (dirX + planeX*cameraX, dirY + planeY*cameraX).
Ray casting
Calls the castRay function from earlier.
Projection
Wall height = screenHeight / distance.
Texturing
textureX = fractional hit location along the wall.
Flipped if hitting certain wall sides.
Then we stretch the vertical texture column to fit the projected wall height.
Shading
Optional: darken walls hit on y-sides to fake lighting.
This is enough to render Wolfenstein-style walls! 🚀
👉 Do you want me to also show you how to manage floor/ceiling rendering (raycasting them too, or using vertical interpolation), or do you only need walls for now?

View File

@@ -18,16 +18,25 @@
text-align: center; text-align: center;
} }
#viewport, #viewport {
#minimap {
font-size: 10px; font-size: 10px;
line-height: 10px; line-height: 8px;
white-space: pre; white-space: pre;
border: 2px solid #0f0; border: 2px solid #0f0;
display: inline-block; display: inline-block;
background-color: #000; padding: 2px;
padding: 10px; border: 5px solid #666;
overflor: ignore; font-weight: bold;
}
#minimap {
font-size: 12px;
line-height: 12px;
white-space: pre;
display: inline-block;
padding: 2px;
border: 5px solid #666;
color: #666;
background-color: black;
} }
#controls { #controls {
@@ -39,60 +48,6 @@
margin-top: 20px; margin-top: 20px;
} }
#minimap .player {
position: relative; /* anchor */
color: #000; /* text blends into background */
/* background-color: red; */
}
#minimap .player.north::before {
content: "↑";
/* content: "★"; */
position: absolute;
inset: 0; /* shorthand for top:0; right:0; bottom:0; left:0; */
display: flex; /* center it */
align-items: center;
justify-content: center;
pointer-events: none; /* dont block clicks */
font-size: 1.5em; /* bigger if you want */
color: red;
}
#minimap .player.south::before {
content: "↓";
/* content: "★"; */
position: absolute;
inset: 0; /* shorthand for top:0; right:0; bottom:0; left:0; */
display: flex; /* center it */
align-items: center;
justify-content: center;
pointer-events: none; /* dont block clicks */
font-size: 1.5em; /* bigger if you want */
color: red;
}
#minimap .player.east::before {
content: "→";
/* content: "★"; */
position: absolute;
inset: 0; /* shorthand for top:0; right:0; bottom:0; left:0; */
display: flex; /* center it */
align-items: center;
justify-content: center;
pointer-events: none; /* dont block clicks */
font-size: 1.5em; /* bigger if you want */
color: red;
}
#minimap .player.west::before {
content: "←";
/* content: "★"; */
position: absolute;
inset: 0; /* shorthand for top:0; right:0; bottom:0; left:0; */
display: flex; /* center it */
align-items: center;
justify-content: center;
pointer-events: none; /* dont block clicks */
font-size: 1.5em; /* bigger if you want */
color: red;
}
textarea { textarea {
background-color: #001100; background-color: #001100;
color: #ccc; color: #ccc;
@@ -124,7 +79,7 @@
<div id="compass">orientation</div> <div id="compass">orientation</div>
<div id="mapInput"> <div id="mapInput">
<div>Load your map (# = walls, space = floor):</div> ←→↑↓
<br /> <br />
<textarea id="mapText" rows="10" cols="50"> <textarea id="mapText" rows="10" cols="50">
############################################################ ############################################################
@@ -133,7 +88,7 @@
## ################# ######################## ## ################# ########################
## # # ################# # ## ######################## ## # # ################# # ## ########################
## # ################# # ## ################ ## # ################# # ## ################
## # # ################# # ## #### #### ## # # S ################# # ## #### ####
## # # ## # #### # # #### ## # # ## # #### # # ####
###### #################### ## #### # #### ###### #################### ## #### # ####
###### #################### # ## # # #### #### ###### #################### # ## # # #### ####

View File

@@ -1,35 +1,12 @@
import { Vector2i, Orientation, RelativeMovement, PI_OVER_TWO } from "./ascii_types.js";
import { 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 { sprintf } from "sprintf-js"; import { sprintf } from "sprintf-js";
import { Vector2i } from "./vec2.js";
import { AsciiWindow } from "./ascrii_window.js";
const PI_OVER_TWO = Math.PI / 2;
/**
* Enum Cardinal Direction (east north west south)
* @constant
* @readonly
*/
const CardinalDirection = {
/** @constant @readonly @type {number} Going east increases X */
EAST: 0,
/** @constant @readonly @type {number} Going south increases Y */
SOUTH: 1,
/** @constant @readonly @type {number} Going west decreases X */
WEST: 2,
/** @constant @readonly @type {number} Going south decreases Y */
NORTH: 3,
};
/**
* Enum Relative Direction (forward, left, right, backwards)
* @readonly
*/
const RelativeMovement = {
FORWARD: 0,
LEFT: 3,
BACKWARD: 2,
RIGHT: 1,
};
class Player { class Player {
_posV = new Vector2i(); _posV = new Vector2i();
@@ -56,7 +33,7 @@ class Player {
} }
get orientation() { get orientation() {
return this._directionV.cardinalDirection(); return this._directionV.orientation();
} }
set orientation(o) { set orientation(o) {
@@ -64,19 +41,19 @@ class Player {
// Sanitize o // Sanitize o
o = ((o | 0) + 4) % 4; o = ((o | 0) + 4) % 4;
if (o === CardinalDirection.EAST) { if (o === Orientation.EAST) {
this._directionV = new Vector2i(1, 0); this._directionV = new Vector2i(1, 0);
return; return;
} }
if (o === CardinalDirection.NORTH) { if (o === Orientation.NORTH) {
this._directionV = new Vector2i(0, 1); this._directionV = new Vector2i(0, 1);
return; return;
} }
if (o === CardinalDirection.WEST) { if (o === Orientation.WEST) {
this._directionV = new Vector2i(-1, 0); this._directionV = new Vector2i(-1, 0);
return; return;
} }
if (o === CardinalDirection.SOUTH) { if (o === Orientation.SOUTH) {
this._directionV = new Vector2i(0, -1); this._directionV = new Vector2i(0, -1);
return; return;
} }
@@ -89,7 +66,12 @@ class Player {
class DungeonCrawler { class DungeonCrawler {
get isAnimating() { get isAnimating() {
return this.animation.frames.length > 0; return (
this.animation === undefined ||
this.animation.targetAngle !== undefined ||
this.animation.targetX !== undefined ||
this.animation.targetY !== undefined
);
} }
constructor() { constructor() {
@@ -108,38 +90,18 @@ class DungeonCrawler {
names: {}, names: {},
}; };
this.map = { /** @readonly @type {TileMap} */
/** @readonly height of map */ this.map;
width: 0,
/** @readonly width of map */
height: 0,
/**
* @readonly
* @type {Uint8Array[]}
* info about each cell/tile in the map (is it floor, is it a wall, etc.)
*
* The number 0 is navigable and has no decoration.
* The number 1 is not navigable, and is a nondescript wall.
*
* 1 bit for navigable
* 3 bits for cell type decoration / voxel type
* 2 bits for floor decoration.
* 2 bits for ceiling decoration.
*/
cells: [],
};
this.animation = { this.animation = {
/** @constant @readonly @type {number} Number of frames per second used in animations */ StartTime: undefined,
fps: 30, StartAngle: undefined,
StartX: undefined,
/** @constant @readonly number of seconds a typical animation takes */ StartY: undefined,
duration: 0.7, targetTime: undefined,
targetAngle: undefined,
/** Array storing information about each frame of an animation to show */ targetX: undefined,
frames: [], targetY: undefined,
}; };
/** @readonly */ /** @readonly */
@@ -148,15 +110,14 @@ class DungeonCrawler {
ticker: 0, ticker: 0,
maxDepth: 5, maxDepth: 5,
fov: Math.PI / 3, // 60 degrees, increase maybe? fov: Math.PI / 3, // 60 degrees, increase maybe?
view: new AsciiWindow(document.getElementById("viewport"), 120, 40), view: new AsciiWindow(document.getElementById("viewport"), 120, 50),
/** @type {FirstPersonRenderer} */
renderer: null,
}; };
/** @readonly */ /** @readonly @type {MiniMapRenderer} */
this.minimap = { this.minimap;
parentElement: document.getElementById("minimap"),
/** @type {Element[][]} */
elements: [],
};
/** /**
* @typedef Player * @typedef Player
@@ -167,64 +128,69 @@ class DungeonCrawler {
this.player = new Player(); this.player = new Player();
this.setupControls(); this.setupControls();
this.setupAnimationLoop();
this.loadMap(); this.loadMap();
this.render(this.player.x, this.player.y, this.player.orientation * PI_OVER_TWO, this.animation.frames.length);
this.updateCompass(); this.updateCompass();
this.rendering.view.commitToDOM();
this.render(this.player.x, this.player.y, this.player.orientation * PI_OVER_TWO);
this.gameLoop();
}
render(posX = this.player.x, posY = this.player.y, angle = this.player.angle) {
if (!this.rendering.renderer) {
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
angle,
);
} }
loadMap() { loadMap() {
this.minimap.parentElement.innerHTML = "";
const mapString = document.getElementById("mapText").value; const mapString = document.getElementById("mapText").value;
const lines = mapString.trim().split("\n");
const h = (this.map.height = lines.length); this.map = TileMap.fromText(mapString);
const w = (this.map.width = Math.max(...lines.map((line) => line.length)));
this.map.cells = new Array(h).fill().map(() => new Uint8Array(w)); this.player._posV = this.map.findFirst({ startLocation: true });
this.minimap.elements = new Array(h).fill().map(() => new Array(w)); if (!this.player._posV) {
this.minimap.parentElement.innerHTML = ""; throw new Error("Could not find a start location for the player");
for (let y = 0; y < h; y++) {
//
const row = document.createElement("div");
for (let x = 0; x < w; x++) {
const isFree = lines[y][x] === " ";
//
// === Internal map ===
//
this.map.cells[y][x] = isFree ? 0 : 1;
//
// === Mini Map ===
//
const mmElement = document.createElement("span");
mmElement.textContent = isFree ? " " : "#";
row.appendChild(mmElement);
this.minimap.elements[y][x] = mmElement;
}
this.minimap.parentElement.appendChild(row);
} }
console.log(this.map.getAreaAround(this.player.x, this.player.y, 5).toString());
// Find a starting position (first open space) const minimapElement = document.getElementById("minimap");
for (let y = 1; y < this.map.height - 1; y++) { const minimapWindow = new AsciiWindow(minimapElement, 9, 9); // MAGIC NUMBERS: width and height of the minimap
for (let x = 1; x < this.map.width - 1; x++) { this.minimap = new MiniMapRenderer(minimapWindow, this.map);
if (this.isWall(x, y)) {
continue; const textureUrls = [eobWallUrl1, eobWallUrl2];
} const textureCount = textureUrls.length;
this.player.x = x; const textures = [];
this.player.y = y;
textureUrls.forEach((url) => {
Texture.fromSource(url).then((texture) => {
textures.push(texture);
if (textures.length < textureCount) {
return; return;
} }
}
this.updateMinimap(); this.rendering.renderer = new FirstPersonRenderer(
this.rendering.view,
this.map,
this.rendering.fov,
this.rendering.maxDepth,
textures,
);
this.render();
this.minimap.draw(this.player.x, this.player.y, this.player.orientation);
console.debug("renderer ready", texture);
});
});
} }
startTurnAnimation(clockwise, quarterTurns = 1) { startTurnAnimation(quarterTurns = 1) {
if (this.isAnimating) { if (this.isAnimating) {
throw new Error("Cannot start an animation while one is already running"); throw new Error("Cannot start an animation while one is already running");
} }
@@ -233,42 +199,38 @@ class DungeonCrawler {
return; return;
} }
const newDirection = clockwise this.animation = {
? this.player._directionV.clone().rotateCW(quarterTurns) startAngle: this.player.angle,
: this.player._directionV.clone().rotateCCW(quarterTurns); startTime: performance.now(),
startX: this.player.x,
startY: this.player.y,
const ticks = Math.floor(this.animation.duration * this.animation.fps); targetAngle: this.player.angle + PI_OVER_TWO * quarterTurns,
const startAngle = this.player.angle; targetTime: performance.now() + 700, // MAGIC NUMBER: these animations take .7 seconds
const slice = this.player._directionV.angleTo(newDirection) / ticks; targetX: this.player.x,
targetY: this.player.y,
this.animation.frames = []; };
for (let i = 1; i < ticks; i++) {
this.animation.frames.push([
this.player.x, //
this.player.y, //
startAngle + slice * i, //
]);
}
this.animation.frames.push([this.player.x, this.player.y, newDirection.angle()]);
// //
// this.player._directionV.rotateCCW(quarterTurns);
this.player._directionV = newDirection;
this.updateMinimap();
this.updateCompass(); this.updateCompass();
} }
/** @type {RelativeMovement} Direction the player is going to move */ /** @type {RelativeMovement} Direction the player is going to move */
startMoveAnimation(direction) { startMoveAnimation(direction) {
//
if (this.isAnimating) { if (this.isAnimating) {
throw new Error("Cannot start an animation while one is already running"); throw new Error("Cannot start an animation while one is already running");
} }
//
// When we move, we move relative to our current viewing direction,
// so moving LEFT means moving 1 square 90 degrees from our viewing direction vector
const targetV = this.player._directionV.rotatedCCW(direction | 0).added(this.player._posV); const targetV = this.player._directionV.rotatedCCW(direction | 0).added(this.player._posV);
if (this.isWall(targetV.x, targetV.y)) { //
// We cant move into walls
if (this.map.isWall(targetV.x, targetV.y)) {
this.debounce = (this.pollsPerSec / 5) | 0; this.debounce = (this.pollsPerSec / 5) | 0;
console.info( console.info(
"bumped into wall at %s (mypos: %s), direction=%d", "bumped into wall at %s (mypos: %s), direction=%d",
@@ -279,21 +241,19 @@ class DungeonCrawler {
return; return;
} }
const ticks = Math.floor(this.animation.duration * this.animation.fps); this.animation = {
const stepX = (targetV.x - this.player.x) / ticks; startAngle: this.player.angle,
const stepY = (targetV.y - this.player.y) / ticks; startTime: performance.now(),
startX: this.player.x,
startY: this.player.y,
this.animation.frames = []; targetAngle: this.player.angle,
for (let i = 1; i < ticks; i++) { targetTime: performance.now() + 700, // MAGIC NUMBER: these animations take .7 seconds
this.animation.frames.push([ targetX: targetV.x,
this.player.x + stepX * i, // targetY: targetV.y,
this.player.y + stepY * i, // };
this.player.angle, //
]);
}
this.animation.frames.push([targetV.x, targetV.y, this.player.angle]);
this.player._posV = targetV; this.player._posV = targetV;
this.updateMinimap();
this.updateCompass(); // technically not necessary, but Im anticipating the need + compensating for my bad memory. this.updateCompass(); // technically not necessary, but Im anticipating the need + compensating for my bad memory.
} }
@@ -307,10 +267,10 @@ class DungeonCrawler {
KeyW: () => this.startMoveAnimation(RelativeMovement.FORWARD), KeyW: () => this.startMoveAnimation(RelativeMovement.FORWARD),
ArrowUp: () => this.startMoveAnimation(RelativeMovement.FORWARD), ArrowUp: () => this.startMoveAnimation(RelativeMovement.FORWARD),
ArrowDown: () => this.startMoveAnimation(RelativeMovement.BACKWARD), ArrowDown: () => this.startMoveAnimation(RelativeMovement.BACKWARD),
ArrowLeft: () => this.startTurnAnimation(true), ArrowLeft: () => this.startTurnAnimation(-1),
ArrowRight: () => this.startTurnAnimation(false), ArrowRight: () => this.startTurnAnimation(1),
KeyQ: () => this.startTurnAnimation(true), KeyQ: () => this.startTurnAnimation(-1),
KeyE: () => this.startTurnAnimation(false), KeyE: () => this.startTurnAnimation(1),
}; };
this.keys.names = Object.keys(this.keys.handlers); this.keys.names = Object.keys(this.keys.handlers);
@@ -361,134 +321,54 @@ class DungeonCrawler {
} }
handleAnimation() { handleAnimation() {
//
// Guard: only animate if called for
if (!this.isAnimating) { if (!this.isAnimating) {
this.animation = {};
return; return;
} }
const [x, y, a] = this.animation.frames.shift();
const framesLeft = this.animation.frames.length;
this.render(x, y, a, framesLeft);
}
// _____ ___ ____ ___
// |_ _/ _ \| _ \ / _ \ _
// | || | | | | | | | | (_)
// | || |_| | |_| | |_| |_
// |_| \___/|____/ \___/(_)
// -----------------------------
// Animation loop
// requestAnimationFrame(loop);
// requires using deltaT rather than ticks, etc.
setupAnimationLoop() {
const ticks = Math.round(1000 / this.animation.fps);
this.animation.interval = setInterval(() => this.handleAnimation(), ticks);
}
isWall(x, y) {
let mapX = x | 0;
let mapY = y | 0;
if (mapX < 0 || mapX >= this.map.width || mapY < 0 || mapY >= this.map.height) {
return true;
}
return this.map.cells[mapY][mapX] !== 0;
}
castRay(camX, camY, camAngle, angleOffset) {
const rayAngle = camAngle + angleOffset;
const rayX = Math.cos(rayAngle);
const rayY = Math.sin(rayAngle);
const fishEye = Math.cos(angleOffset); // corrects fish-eye effect https://stackoverflow.com/questions/66591163/how-do-i-fix-the-warped-perspective-in-my-raycaster
// const fishEye = 1;
let distance = Math.SQRT1_2 / 2;
let step = 0.0001;
while (distance < this.rendering.maxDepth) {
const testX = camX + rayX * distance;
const testY = camY + rayY * distance;
if (this.isWall(testX, testY)) {
return [
distance * fishEye,
{
// testX,
// testY,
// rayDistance: distance, // the distance the ray traveled, not the distance the object was away from us
color: (1 - distance / this.rendering.maxDepth) * 1.0,
},
];
}
distance += step;
}
return [this.rendering.maxDepth, "#000"];
}
render(x, y, direction) {
if (!this.rendering.enabled) {
return false;
}
this.rendering.ticker++;
x += 0.5;
y += 0.5;
const h = this.rendering.view.height;
const w = this.rendering.view.width;
// const middle = this.rendering.height / 2;
// Hack to simulate bouncy walking by moving the middle of the screen up and down a bit
const bounce = Math.sin(this.rendering.ticker / 4) * 0.2;
const middle = h / 2 + bounce;
for (let screenX = 0; screenX < w; screenX++) {
// //
// // Guard, stop animation if it took too long
const rayOffset = (screenX / w) * this.rendering.fov - this.rendering.fov / 2; if (this.animation.targetTime <= performance.now()) {
this.render(this.player.x, this.player.y, this.player.angle);
// this.animation = {};
// Cast the ray, one ray per column, just like wolfenstein this.minimap.draw(this.player.x, this.player.y, this.player.orientation);
const [distance, wall] = this.castRay(x, y, direction, rayOffset); return;
//
// Start drawing
for (let screenY = 0; screenY < h; screenY++) {
//
// Calculate how high walls are at the distance of the ray's intersection
const wallH = middle / distance;
//
// Given the current y-coordinate and distance, are we hitting the ceiling?
if (screenY < middle - wallH) {
this.rendering.view.put(screenX, screenY, "´", "#999");
continue;
} }
const a = this.animation;
const nowT = performance.now();
const animY = a.targetY - a.startY; // how much this animation causes us to move in the y-direction
const animX = a.targetX - a.startX; // how much this animation causes us to move in the x-direction
const animA = a.targetAngle - a.startAngle; // how much this animation causes us to rotate in total
const 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!");
}
// render
this.render(
a.startX + animX * deltaT, //
a.startY + animY * deltaT, //
a.startAngle + animA * deltaT, //
);
}
gameLoop() {
// //
// Given the current y-coordinate and distance, are we hitting the floor? // We're not animating, so we chill out for 50 msec
if (screenY > middle + wallH) { if (!this.isAnimating) {
this.rendering.view.put(screenX, screenY, "~", "#b52"); setTimeout(() => this.gameLoop(), 50); // MAGIC NUMBER
continue; return;
} }
// this.handleAnimation();
// We've either hit a wall or the limit of our visibility,
// So we Determine the color of the pixel to draw.
const color = wall && wall.color ? wall.color : 1 - distance / this.rendering.maxDepth;
// TODO: Lerp these characters. requestAnimationFrame(() => this.gameLoop());
// const distancePalette = ["█", "▓", "▒", "░", " "];
const distancePalette = ["#", "%", "+", "÷", " "];
const char = distancePalette[distance | 0];
this.rendering.view.put(screenX, screenY, char, color);
}
}
this.rendering.view.commitToDOM();
} }
updateCompass() { updateCompass() {
@@ -498,38 +378,12 @@ class DungeonCrawler {
document.getElementById("compass").textContent = sprintf( document.getElementById("compass").textContent = sprintf(
"%s %s (%d --> %.2f [%dº])", "%s %s (%d --> %.2f [%dº])",
this.player._posV, this.player._posV,
Object.keys(CardinalDirection)[this.player.orientation].toLowerCase(), Object.keys(Orientation)[this.player.orientation].toLowerCase(),
this.player.orientation, this.player.orientation,
this.player.orientation * PI_OVER_TWO, this.player.orientation * PI_OVER_TWO,
this.player.orientation * 90, this.player.orientation * 90,
); );
} }
updateMinimap() {
if (!this.player.withinAABB(this.map.width - 1, this.map.height - 1)) {
console.error("Player out of bounds");
return;
}
//
// Remove the old player symbol
const playerEl = document.querySelector(".player");
if (playerEl) {
playerEl.className = "";
}
// This is just for debugging!
//
for (let y = 0; y < this.map.height; y++) {
for (let x = 0; x < this.map.width; x++) {
this.minimap.elements[y][x].textContent = this.map.cells[y][x] ? "#" : " ";
}
}
// Add the player token to the minimap
const dirForCSS = Object.keys(CardinalDirection)[this.player.orientation].toLowerCase();
this.minimap.elements[this.player.y][this.player.x].classList.add("player", dirForCSS);
}
} }
// Start the game // Start the game

View File

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

View File

@@ -0,0 +1,289 @@
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
}
}

115
frontend/ascii_textureloader.js Executable file
View File

@@ -0,0 +1,115 @@
/**
* @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) {
this.r = r;
this.g = g;
this.b = b;
this.a = a;
}
mulRGB(factor) {
this.r *= factor;
this.g *= factor;
this.b *= factor;
}
get dR() {
return ((this.r * 255) | 0) % 256;
}
get dG() {
return ((this.g * 255) | 0) % 256;
}
get dB() {
return ((this.b * 255) | 0) % 256;
}
get dA() {
return ((this.a * 255) | 0) % 256;
}
toCSS() {
return (
"#" + // prefix
this.dR.toString(16).padStart(2, "0") +
this.dG.toString(16).padStart(2, "0") +
this.dB.toString(16).padStart(2, "0") +
this.dA.toString(16).padStart(2, "0")
);
}
}
/**
* Texture class,
* represents a single texture
*/
export class Texture {
static async fromSource(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const result = new Texture(img.width, img.height, imageData.data);
resolve(result);
} catch (error) {
reject(error);
}
};
img.onerror = () => reject(new Error(`Failed to load texture: ${src}`));
img.src = src;
});
}
constructor(width, height, data) {
/** @type {number} */
this.width = width;
/** @type {number} */
this.height = height;
/** @type {Uint8ClampedArray} */
this.data = data;
}
/**
* Bilinear sampling for smooth texture filtering
*
* @param {number} u the "x" coordinate of the texture sample pixel. Normalized to [0...1]
* @param {number} w the "y" coordinate of the texture sample pixel. Normalized to [0...1]
*
* @returns {NRGBA}
*/
sample(u, v) {
const x = Math.round(u * this.width);
const y = Math.round(v * this.height);
const index = (y * this.width + x) * 4;
return new NRGBA(
this.data[index + 0] / 255,
this.data[index + 1] / 255,
this.data[index + 2] / 255,
1, // this.data[index + 3] / 255,
);
}
}

View File

@@ -1,89 +1,213 @@
export const defaultLegend = { import { Vector2i, Orientation } from "./ascii_types.js";
" ": { import { AsciiWindow } from "./ascii_window.js";
type: "empty", import { Texture } from "./ascii_textureloader.js";
},
// Default Wall
"#": {
minimap: "#",
occupied: true,
type: "wall",
},
// Column class Tile {
"◯": { /** @type {string} How should this tile be rendered on the minimap.*/
minimap: "", minimap = " ";
occupied: true, /** @type {boolean} Should this be rendered as a wall? */
type: "statue", wall = 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;
// Closed Door
"░": {
minimap: "░",
occupied: true,
type: "sprite?????",
},
// Where the player starts
"@": {
type: "playerStart",
},
// TRAP!
"☠": {
type: "trap",
},
// Monster
"!": {
type: "monster",
},
};
export class Tile {
/** @param {object} options */
constructor(options) { constructor(options) {
for (let [k, v] of Object.entries(options)) { for (let [k, v] of Object.entries(options)) {
if (this[k] !== undefined) {
this[k] = v; this[k] = v;
} }
} }
}
} }
export const defaultLegend = Object.freeze({
//
// default floor
" ": new Tile({
minimap: " ",
traversable: true,
wall: false,
}),
//
// Default wall
"#": new Tile({
minimap: "#",
traversable: false,
wall: true,
textureId: 0,
}),
//
//secret door (looks like wall, but is traversable)
"Ω": new Tile({
minimap: "#",
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", // "Š",
traversable: true,
wall: false,
startLocation: true,
}),
});
export class TileMap { export class TileMap {
/** @param {string} str */ /**
static fromText(str) { * @param {string} str
* @param {Record<string,Tile} legend
*/
static fromText(str, legend = defaultLegend) {
const lines = str.split("\n"); const lines = str.split("\n");
const longestLine = lines.reduce((acc, line) => Math.max(acc, line.length), 0); const longestLine = lines.reduce((acc, line) => Math.max(acc, line.length), 0);
const tiles = new Array(lines.length).fill().map(() => new Array(longestLine)); const tiles = new Array(lines.length).fill().map(() => Array(longestLine));
lines.forEach((line, y) => { lines.forEach((line, y) => {
line = line.padEnd(longestLine, "#"); line = line.padEnd(longestLine, "#");
line.split("").forEach((char, x) => { line.split("").forEach((char, x) => {
const options = defaultLegend[char] ?? defaultLegend[" "]; let tile = legend[char];
tiles[y][x] = new Tile(options);
// unknown char?
// check fallback tile.
if (tile === undefined) {
tile = legend[""];
}
// still no tile - i.e. no back fallback tile?
if (tile === undefined) {
throw new Error("Dont know how to handle this character: " + char);
}
// insert tile into map.
tiles[y][x] = tile;
}); });
}); });
return new TileMap(tiles); return new TileMap(longestLine, lines.length, tiles);
} }
/** /**
* @param {Tile[]} tiles * @param {number} width
* @param {number} height
* @param {Tile[][]} tiles
*/ */
constructor(tiles) { constructor(width, height, tiles) {
/** @constant @readonly @type {number} */
this.height = tiles.length; this.height = tiles.length;
/** @constant @readonly @type {number} */
this.width = tiles[0].length; this.width = tiles[0].length;
/** @constant @readonly @type {Tile[][]} */
this.tiles = tiles; this.tiles = tiles;
/** @type {Tile} when probing a coordinate outside the map, this is the tile that is returned */
this.outOfBoundsWall = this.findFirst({ wall: true });
}
toString() {
let result = "";
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 += "\n";
}
return result;
}
get(x, y) {
x |= 0;
y |= 0;
if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
return this.outOfBoundsWall;
}
return this.tiles[y][x];
}
isWall(x, y) {
x |= 0;
y |= 0;
if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
return true;
}
return this.tiles[y][x].wall;
}
findFirst(criteria) {
return this.forEach((tile, x, y) => {
for (let k in criteria) {
if (tile[k] === criteria[k]) {
return new Vector2i(x, y);
}
}
});
}
forEach(fn) {
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
let res = fn(this.tiles[y][x], x, y);
if (res !== undefined) {
return res;
}
}
}
}
getArea(xMin, yMin, xMax, yMax) {
if (xMin > xMax) {
[xMin, xMax] = [xMax, xMin];
}
if (yMin > yMax) {
[yMin, yMax] = [yMax, yMin];
}
const w = xMax - xMin + 1;
const h = yMax - yMin + 1;
let iX = 0;
let iY = 0;
const tiles = new Array(h).fill().map(() => new Array(w));
for (let y = yMin; y <= yMax; y++) {
for (let x = xMin; x <= xMax; x++) {
const tile = this.tiles[y][x];
if (!tile) {
throw new Error("Dafuqq is happing here?");
}
tiles[iY][iX] = tile;
iX++;
}
iX = 0;
iY++;
}
return new TileMap(w, h, tiles);
}
getAreaAround(x, y, radius) {
return this.getArea(x - radius, y - radius, x + radius, y + radius);
} }
} }
const str = ` if (Math.PI < 0 && AsciiWindow && Texture && Orientation) {
kim ("STFU Linda");
har en }
# meje #
stor pikkemand
`;
const tm = TileMap.fromText(str);
console.log(tm.tiles);

View File

@@ -1,3 +1,32 @@
export const PI_OVER_TWO = Math.PI / 2;
/**
* Enum Cardinal Direction (east north west south)
* @constant
* @readonly
*/
export const Orientation = {
/** @constant @readonly @type {number} Going east increases X */
EAST: 0,
/** @constant @readonly @type {number} Going south increases Y */
SOUTH: 1,
/** @constant @readonly @type {number} Going west decreases X */
WEST: 2,
/** @constant @readonly @type {number} Going south decreases Y */
NORTH: 3,
};
/**
* Enum Relative Direction (forward, left, right, backwards)
* @readonly
*/
export const RelativeMovement = {
FORWARD: 0,
LEFT: 3,
BACKWARD: 2,
RIGHT: 1,
};
export class Vector2i { export class Vector2i {
constructor(x = 0, y = 0) { constructor(x = 0, y = 0) {
this.x = x | 0; // force int this.x = x | 0; // force int
@@ -46,7 +75,7 @@ export class Vector2i {
} }
// which cardinal direction are we in? // which cardinal direction are we in?
cardinalDirection() { orientation() {
if (this.y === 0) { if (this.y === 0) {
if (this.x > 0) { if (this.x > 0) {
return 0; return 0;

121
frontend/ascii_window.js Executable file
View File

@@ -0,0 +1,121 @@
export class Pixel {
/**
* @param {HTMLElement} el
* @param {string} char
* @param {number|string} color text/foreground color
*/
constructor(el, char = " ", color = "#fff") {
//
/** @type {HTMLElement} el the html element that makes up this cell*/
this.el = el;
/** @type {string} char */
this.char = char;
/** @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;
}
clone() {
return new Pixel(this.el, this.car, this.color);
}
}
export class AsciiWindow {
/**
* @param {HTMLElement} container
* @param {number} width Canvas width (in pseudo-pixels)
* @param {number} height Canvas height (in pseudo-pixels)
*/
constructor(container, width, height) {
//
/** @type {HTMLElement} Paren element that contains all the pseudo-pixels */
this.container = container;
/** @type {number} width Canvas width (in pseudo-pixels) */
this.width = width;
/** @type {number} height Canvas height (in pseudo-pixels) */
this.height = height;
/** @type {Pixel[]} */
this.canvas = [];
this.initializeCanvaas();
}
/**
* Create the html elements that make up the canvas,
* as well as a buffer that holds a copy of the data
* in the cells so we can diff properly.
*/
initializeCanvaas() {
const w = this.width;
const h = this.height;
this.canvas = new Array(w * h).fill();
let i = 0;
for (let y = 0; y < h; y++) {
const rowEl = document.createElement("div");
this.container.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");
i++;
}
}
}
withinBounds(x, y) {
return x >= 0 && x < this.width && y >= 0 && y < this.height;
}
mustBeWithinBounds(x, y) {
if (!this.withinBounds(x, y)) {
throw new Error(`Coordinate [${x}, ${y}] is out of bounds`);
}
}
put(x, y, char, color) {
//
this.mustBeWithinBounds(x, y);
const idx = this.width * y + x;
const pixel = this.canvas[idx];
// Check for changes in text contents
if (char !== undefined && char !== null && char !== pixel.char) {
pixel.char = char;
pixel.dirty = true;
}
if (color !== undefined && color !== null && color !== pixel.color) {
pixel.color = color;
pixel.dirty = true;
}
}
/**
* Apply all patches to the DOM
*
* @return {number} number of DOM updates made
*/
commitToDOM() {
this.canvas.forEach((pixel) => {
if (!pixel.dirty) {
return;
}
pixel.el.textContent = pixel.char;
pixel.el.style.color = pixel.color;
pixel.dirty = false;
});
}
}

View File

@@ -1,196 +0,0 @@
const grayscale = [
"#000",
"#111",
"#222",
"#333",
"#444",
"#555",
"#666",
"#777",
"#888",
"#999",
"#aaa",
"#bbb",
"#ccc",
"#ddd",
"#eee",
"#fff",
];
function normalizeColor(color) {
if (typeof color === "number" && color >= 0 && color <= 1) {
return grayscale[Math.round(color * 16)];
}
if (typeof color === "number" && color >= 0 && color <= 16) {
return grayscale[color];
}
if (typeof color === "string" && color.length === 4 && color[0] === "#") {
return color;
}
throw new Error("Color could not be normalized");
}
class Patch {
/** @type {string} char */
char;
/** @type {number|string} fg foreground color */
bg;
/** @type {number|string} bg background color */
fg;
/** @param {HTMLElement} el */
constructor(el) {
/** @type {HTMLElement} el */
this.el = el;
}
}
export class Pixel {
/**
* @param {HTMLElement} el
* @param {string} char
* @param {number|string} fg foreground color
* @param {number|string} bg background color
*/
constructor(el, char = " ", fg = "#fff", bg = undefined) {
//
/** @type {HTMLElement} el the html element that makes up this cell*/
this.el = el;
/** @type {string} char */
this.char = char;
/** @type {number|string} bg background color */
this.fg = fg === undefined ? undefined : normalizeColor(fg);
/** @type {number|string} fg foreground color */
this.bg = bg === undefined ? undefined : normalizeColor(bg);
}
clone() {
return new Pixel(this.el, this.car, this.fg, this.bg);
}
}
export class AsciiWindow {
/**
* @param {HTMLElement} container
* @param {number} width Canvas width (in pseudo-pixels)
* @param {number} height Canvas height (in pseudo-pixels)
*/
constructor(container, width, height) {
//
/** @type {HTMLElement} Paren element that contains all the pseudo-pixels */
this.container = container;
/** @type {number} width Canvas width (in pseudo-pixels) */
this.width = width;
/** @type {number} height Canvas height (in pseudo-pixels) */
this.height = height;
/** @type {Pixel[][]} */
this.canvas = undefined;
/** @type {Patch[]} */
this.diff = [];
this.initializeCanvaas();
}
/**
* Create the html elements that make up the canvas,
* as well as a buffer that holds a copy of the data
* in the cells so we can diff properly.
*/
initializeCanvaas() {
const w = this.width;
const h = this.height;
/** @type {Pixel[][]} */
this.canvas = new Array(w).fill().map(() => new Array(h).fill().map(() => new Pixel()));
for (let y = 0; y < h; y++) {
const rowEl = document.createElement("div");
this.container.appendChild(rowEl);
for (let x = 0; x < w; x++) {
const pixelEl = document.createElement("code");
rowEl.appendChild(pixelEl);
pixelEl.textContent = " ";
this.canvas[y][x] = new Pixel(pixelEl, " ");
}
}
}
withinBounds(x, y) {
return x >= 0 && x < this.width && y >= 0 && y < this.height;
}
mustBeWithinBounds(x, y) {
if (!this.withinBounds(x, y)) {
throw new Error(`Coordinate [${x}, ${y}] is out of bounds`);
}
}
put(x, y, char, fg = undefined, bg = undefined) {
//
this.mustBeWithinBounds(x, y);
const pixel = this.canvas[y][x];
const patch = new Patch(pixel.el);
fg = fg === undefined ? undefined : normalizeColor(fg);
bg = bg === undefined ? undefined : normalizeColor(bg);
let changeCount = 0;
// Check for changes in text contents
if (char !== undefined && char !== pixel.char) {
changeCount++;
patch.char = char;
pixel.char = char;
}
// Check for changes in foreground color
if (fg !== undefined && fg !== pixel.fg) {
changeCount++;
patch.fg = fg;
pixel.fg = fg;
}
// Check for changes in background color
if (bg !== undefined && bg !== pixel.bg) {
changeCount++;
patch.bg = bg;
pixel.bg = bg;
}
if (changeCount > 0) {
this.diff.push(patch);
}
}
/**
* Apply all patches to the DOM
*/
commitToDOM() {
this.diff.forEach((/** @type {Patch} */ patch) => {
if (patch.char !== undefined) {
patch.el.textContent = patch.char;
}
if (patch.fg !== undefined) {
patch.el.style.color = patch.fg;
}
if (patch.bg !== undefined) {
patch.el.style.backgroundColor = patch.bg;
}
});
this.diff = [];
}
}

BIN
frontend/eob1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
frontend/eob2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -41,7 +41,7 @@
}, },
"prettier": { "prettier": {
"tabWidth": 4, "tabWidth": 4,
"printWidth": 120, "printWidth": 160,
"quoteProps": "consistent", "quoteProps": "consistent",
"singleQuote": false, "singleQuote": false,
"trailingComma": "all", "trailingComma": "all",

View File

@@ -116,6 +116,72 @@ Each group chat has a name.
- Like `Rogue` but with tweaks such as detailed descriptions - Like `Rogue` but with tweaks such as detailed descriptions
of the cool stuff the players have done with the room. of the cool stuff the players have done with the room.
```
// __ __ _ ___
// | \/ | ___ _ __ ___ _ _ | | ( _ )
// | |\/| |/ _ \| '_ \ / _ \ | | / __) / _ \/\
// | | | | (_) | | | | __/ |_| \__ \ | (_> <
// |_| |_|\___/|_| |_|\___|\__, ( / \___/\/
// |___/ |_|
// _____
// | ____|_ __ ___ ___ _ __ ___ _ __ ___ _ _
// | _| | '_ \ / __/ _ \| '_ \ / _ \| '_ ` _ \| | | |
// | |___| | | | (_| (_) | | | | (_) | | | | | | |_| |
// |_____|_| |_|\___\___/|_| |_|\___/|_| |_| |_|\__, |
// |___/
```
- `VAT (Very Awful Tax)`
- Applies only to transfers of valuables between parties/players.
- Its 10%
- Also, players cannot give each other items, they must sell them for a fair price (50% of worth)
- `Financial Obligations`
- A party can have between 0 and 255 FOs.
- An obligation of X (where X is higher than 0) means that `X * 100 / 255` percent of all gold is subtracted
- When you find it
- When you sell an item
- When you acquire gold from quests, jobs, services, etc.
- When another player/party gives or lends you money
- Your obligation can reach 255, but never go above. This means that, if you have an obligation if `X`, and
means you cannot do something (hire a hireling, take out a loan, or buy a BOO) that has an FO
level higher than `255-X`;
- FO only affects gold, not items, gems, etc, but it will affect you once you try to sell those items.
- `Hirelings`
- Can be hired in taverns
- Can be exchanged with other players?
- Often cost money to buy
- Often come with an FO that only goes away when they leave the party.
- `Bases of Operation (BOOs)`
- Can be purchased in cities.
- Cost initial large amount of gold
- Always comes with some level of FO that
only goes away if you sell the BOO or if it is destroyed by
baddies.
- `Loans`
- Gives you cash in hand.
- Comes with an FO that only goes away when you pay back the loan in its
entirety. The obligation is not lowered, even if you pay back 99.9% of the
loan.
- `Artifacts`
- Certain magical items come with FOs.
Because you rent them rather than own them.
- `Pleading`
- Certain monsters (like Dragons, Gins, Dark Lord Wizards, Bandit Leaders,
etc) will let you walk away in exchange for a certain level of FO. This
only goes away when you kill the monster.
- Note that ordinary bandits will not let you live - they are not clever
enough to see your potential.
- Pleading only works if you have more free FOs than the level of the
monster.
- Pleading will usually cost you either 80% of your free FOs, or twice the
level of the monster, whichever is lower.
``` ```
____ _ _ _ _ ____ ____ _ _ _ _ ____
| _ \| | | | \ | / ___| | _ \| | | | \ | / ___|