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

View File

@@ -18,16 +18,25 @@
text-align: center;
}
#viewport,
#minimap {
#viewport {
font-size: 10px;
line-height: 10px;
line-height: 8px;
white-space: pre;
border: 2px solid #0f0;
display: inline-block;
background-color: #000;
padding: 10px;
overflor: ignore;
padding: 2px;
border: 5px solid #666;
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 {
@@ -39,60 +48,6 @@
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 {
background-color: #001100;
color: #ccc;
@@ -124,7 +79,7 @@
<div id="compass">orientation</div>
<div id="mapInput">
<div>Load your map (# = walls, space = floor):</div>
←→↑↓
<br />
<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 { 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 {
_posV = new Vector2i();
@@ -56,7 +33,7 @@ class Player {
}
get orientation() {
return this._directionV.cardinalDirection();
return this._directionV.orientation();
}
set orientation(o) {
@@ -64,19 +41,19 @@ class Player {
// Sanitize o
o = ((o | 0) + 4) % 4;
if (o === CardinalDirection.EAST) {
if (o === Orientation.EAST) {
this._directionV = new Vector2i(1, 0);
return;
}
if (o === CardinalDirection.NORTH) {
if (o === Orientation.NORTH) {
this._directionV = new Vector2i(0, 1);
return;
}
if (o === CardinalDirection.WEST) {
if (o === Orientation.WEST) {
this._directionV = new Vector2i(-1, 0);
return;
}
if (o === CardinalDirection.SOUTH) {
if (o === Orientation.SOUTH) {
this._directionV = new Vector2i(0, -1);
return;
}
@@ -89,7 +66,12 @@ class Player {
class DungeonCrawler {
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() {
@@ -108,38 +90,18 @@ class DungeonCrawler {
names: {},
};
this.map = {
/** @readonly height of 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: [],
};
/** @readonly @type {TileMap} */
this.map;
this.animation = {
/** @constant @readonly @type {number} Number of frames per second used in animations */
fps: 30,
/** @constant @readonly number of seconds a typical animation takes */
duration: 0.7,
/** Array storing information about each frame of an animation to show */
frames: [],
StartTime: undefined,
StartAngle: undefined,
StartX: undefined,
StartY: undefined,
targetTime: undefined,
targetAngle: undefined,
targetX: undefined,
targetY: undefined,
};
/** @readonly */
@@ -148,15 +110,14 @@ class DungeonCrawler {
ticker: 0,
maxDepth: 5,
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 */
this.minimap = {
parentElement: document.getElementById("minimap"),
/** @type {Element[][]} */
elements: [],
};
/** @readonly @type {MiniMapRenderer} */
this.minimap;
/**
* @typedef Player
@@ -167,64 +128,69 @@ class DungeonCrawler {
this.player = new Player();
this.setupControls();
this.setupAnimationLoop();
this.loadMap();
this.render(this.player.x, this.player.y, this.player.orientation * PI_OVER_TWO, this.animation.frames.length);
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() {
this.minimap.parentElement.innerHTML = "";
const mapString = document.getElementById("mapText").value;
const lines = mapString.trim().split("\n");
const h = (this.map.height = lines.length);
const w = (this.map.width = Math.max(...lines.map((line) => line.length)));
this.map = TileMap.fromText(mapString);
this.map.cells = new Array(h).fill().map(() => new Uint8Array(w));
this.minimap.elements = new Array(h).fill().map(() => new Array(w));
this.minimap.parentElement.innerHTML = "";
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);
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());
// Find a starting position (first open space)
for (let y = 1; y < this.map.height - 1; y++) {
for (let x = 1; x < this.map.width - 1; x++) {
if (this.isWall(x, y)) {
continue;
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);
const textureUrls = [eobWallUrl1, eobWallUrl2];
const textureCount = textureUrls.length;
const textures = [];
textureUrls.forEach((url) => {
Texture.fromSource(url).then((texture) => {
textures.push(texture);
if (textures.length < textureCount) {
return;
}
this.player.x = x;
this.player.y = y;
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) {
throw new Error("Cannot start an animation while one is already running");
}
@@ -233,42 +199,38 @@ class DungeonCrawler {
return;
}
const newDirection = clockwise
? this.player._directionV.clone().rotateCW(quarterTurns)
: this.player._directionV.clone().rotateCCW(quarterTurns);
this.animation = {
startAngle: this.player.angle,
startTime: performance.now(),
startX: this.player.x,
startY: this.player.y,
const ticks = Math.floor(this.animation.duration * this.animation.fps);
const startAngle = this.player.angle;
const slice = this.player._directionV.angleTo(newDirection) / ticks;
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()]);
targetAngle: this.player.angle + PI_OVER_TWO * quarterTurns,
targetTime: performance.now() + 700, // MAGIC NUMBER: these animations take .7 seconds
targetX: this.player.x,
targetY: this.player.y,
};
//
//
this.player._directionV = newDirection;
this.updateMinimap();
this.player._directionV.rotateCCW(quarterTurns);
this.updateCompass();
}
/** @type {RelativeMovement} Direction the player is going to move */
startMoveAnimation(direction) {
//
if (this.isAnimating) {
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);
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;
console.info(
"bumped into wall at %s (mypos: %s), direction=%d",
@@ -279,21 +241,19 @@ class DungeonCrawler {
return;
}
const ticks = Math.floor(this.animation.duration * this.animation.fps);
const stepX = (targetV.x - this.player.x) / ticks;
const stepY = (targetV.y - this.player.y) / ticks;
this.animation = {
startAngle: this.player.angle,
startTime: performance.now(),
startX: this.player.x,
startY: this.player.y,
this.animation.frames = [];
for (let i = 1; i < ticks; i++) {
this.animation.frames.push([
this.player.x + stepX * i, //
this.player.y + stepY * i, //
this.player.angle, //
]);
}
this.animation.frames.push([targetV.x, targetV.y, this.player.angle]);
targetAngle: this.player.angle,
targetTime: performance.now() + 700, // MAGIC NUMBER: these animations take .7 seconds
targetX: targetV.x,
targetY: targetV.y,
};
this.player._posV = targetV;
this.updateMinimap();
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),
ArrowUp: () => this.startMoveAnimation(RelativeMovement.FORWARD),
ArrowDown: () => this.startMoveAnimation(RelativeMovement.BACKWARD),
ArrowLeft: () => this.startTurnAnimation(true),
ArrowRight: () => this.startTurnAnimation(false),
KeyQ: () => this.startTurnAnimation(true),
KeyE: () => this.startTurnAnimation(false),
ArrowLeft: () => this.startTurnAnimation(-1),
ArrowRight: () => this.startTurnAnimation(1),
KeyQ: () => this.startTurnAnimation(-1),
KeyE: () => this.startTurnAnimation(1),
};
this.keys.names = Object.keys(this.keys.handlers);
@@ -361,134 +321,54 @@ class DungeonCrawler {
}
handleAnimation() {
//
// Guard: only animate if called for
if (!this.isAnimating) {
this.animation = {};
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;
//
// 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.animation = {};
this.minimap.draw(this.player.x, this.player.y, this.player.orientation);
return;
}
return this.map.cells[mapY][mapX] !== 0;
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, //
);
}
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;
gameLoop() {
//
// We're not animating, so we chill out for 50 msec
if (!this.isAnimating) {
setTimeout(() => this.gameLoop(), 50); // MAGIC NUMBER
return;
}
return [this.rendering.maxDepth, "#000"];
}
this.handleAnimation();
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++) {
//
//
const rayOffset = (screenX / w) * this.rendering.fov - this.rendering.fov / 2;
//
// Cast the ray, one ray per column, just like wolfenstein
const [distance, wall] = this.castRay(x, y, direction, rayOffset);
//
// 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;
}
//
// Given the current y-coordinate and distance, are we hitting the floor?
if (screenY > middle + wallH) {
this.rendering.view.put(screenX, screenY, "~", "#b52");
continue;
}
//
// 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.
// const distancePalette = ["█", "▓", "▒", "░", " "];
const distancePalette = ["#", "%", "+", "÷", " "];
const char = distancePalette[distance | 0];
this.rendering.view.put(screenX, screenY, char, color);
}
}
this.rendering.view.commitToDOM();
requestAnimationFrame(() => this.gameLoop());
}
updateCompass() {
@@ -498,38 +378,12 @@ class DungeonCrawler {
document.getElementById("compass").textContent = sprintf(
"%s %s (%d --> %.2f [%dº])",
this.player._posV,
Object.keys(CardinalDirection)[this.player.orientation].toLowerCase(),
Object.keys(Orientation)[this.player.orientation].toLowerCase(),
this.player.orientation,
this.player.orientation * PI_OVER_TWO,
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

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 = {
" ": {
type: "empty",
},
// Default Wall
"#": {
minimap: "#",
occupied: true,
type: "wall",
},
import { Vector2i, Orientation } from "./ascii_types.js";
import { AsciiWindow } from "./ascii_window.js";
import { Texture } from "./ascii_textureloader.js";
// Column
"◯": {
minimap: "",
occupied: true,
type: "statue",
},
class Tile {
/** @type {string} How should this tile be rendered on the minimap.*/
minimap = " ";
/** @type {boolean} Should this be rendered as a wall? */
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) {
for (let [k, v] of Object.entries(options)) {
this[k] = v;
if (this[k] !== undefined) {
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 {
/** @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 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) => {
line = line.padEnd(longestLine, "#");
line.split("").forEach((char, x) => {
const options = defaultLegend[char] ?? defaultLegend[" "];
tiles[y][x] = new Tile(options);
let tile = legend[char];
// 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;
/** @constant @readonly @type {number} */
this.width = tiles[0].length;
/** @constant @readonly @type {Tile[][]} */
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 = `
kim
har en
# meje #
stor pikkemand
`;
const tm = TileMap.fromText(str);
console.log(tm.tiles);
if (Math.PI < 0 && AsciiWindow && Texture && Orientation) {
("STFU Linda");
}

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 {
constructor(x = 0, y = 0) {
this.x = x | 0; // force int
@@ -46,7 +75,7 @@ export class Vector2i {
}
// which cardinal direction are we in?
cardinalDirection() {
orientation() {
if (this.y === 0) {
if (this.x > 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