truthstorian
This commit is contained in:
@@ -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; /* don’t 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; /* don’t 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; /* don’t 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; /* don’t 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 ################# # ## #### ####
|
||||
## # # ## # #### # # ####
|
||||
###### #################### ## #### # ####
|
||||
###### #################### # ## # # #### ####
|
||||
|
||||
@@ -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
|
||||
|
||||
326
frontend/ascii_first_person_renderer.js
Executable file
326
frontend/ascii_first_person_renderer.js
Executable 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");
|
||||
}
|
||||
289
frontend/ascii_first_person_renderer_2.js
Normal file
289
frontend/ascii_first_person_renderer_2.js
Normal 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
115
frontend/ascii_textureloader.js
Executable 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
121
frontend/ascii_window.js
Executable 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
BIN
frontend/eob1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/eob2.png
Normal file
BIN
frontend/eob2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Reference in New Issue
Block a user