truthstorian
This commit is contained in:
62
ascii_minimap_renderer.js
Executable file
62
ascii_minimap_renderer.js
Executable file
@@ -0,0 +1,62 @@
|
|||||||
|
import { TileMap } from "./frontend/ascii_tile_map.js";
|
||||||
|
import { Vector2i } from "./frontend/ascii_types.js";
|
||||||
|
import { AsciiWindow } from "./frontend/ascii_window.js";
|
||||||
|
|
||||||
|
export class MiniMapRenderer {
|
||||||
|
/**
|
||||||
|
* @param {AsciiWindow} aWindow
|
||||||
|
* @param {TileMap} map
|
||||||
|
*/
|
||||||
|
constructor(aWindow, map) {
|
||||||
|
if (aWindow.width !== aWindow.height) {
|
||||||
|
console.log("Window now square", { width: aWindow.width, height: aWindow.height });
|
||||||
|
throw new Error("Window must be square");
|
||||||
|
}
|
||||||
|
if (aWindow.width % 2 === 0) {
|
||||||
|
console.log("Window dimension must be uneven", { width: aWindow.width, height: aWindow.height });
|
||||||
|
throw new Error("Window dimension is even, it must be uneven");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {AsciiWindow} */
|
||||||
|
this.window = aWindow;
|
||||||
|
|
||||||
|
/** @type {TileMap} */
|
||||||
|
this.map = map;
|
||||||
|
|
||||||
|
/** @type {number} how far we can see on the minimap */
|
||||||
|
this.distance = (aWindow.width - 1) / 2;
|
||||||
|
|
||||||
|
this.fg = undefined; // Let the CSS of the parent element control the colors of the tiles
|
||||||
|
this.bg = undefined; // let the CSS of the parent element control the background colors of the tiles
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} centerX
|
||||||
|
* @param {number} centerY
|
||||||
|
* @param {Orientation} orientation
|
||||||
|
*/
|
||||||
|
draw(centerX, centerY, orientation) {
|
||||||
|
// these variables are the coordinates of the
|
||||||
|
// area of the map (not minimap) we are looking at
|
||||||
|
const minX = centerX - this.distance;
|
||||||
|
const maxX = centerX + this.distance;
|
||||||
|
const minY = centerY - this.distance;
|
||||||
|
const maxY = centerY + this.distance;
|
||||||
|
|
||||||
|
const distanceV = new Vector2i(this.distance, this.distance);
|
||||||
|
|
||||||
|
for (let y = minY; y <= maxY; y++) {
|
||||||
|
for (let x = minX; x <= maxX; x++) {
|
||||||
|
const wndPosV = new Vector2i(x - centerX, y - centerY).rotateCW(orientation + 1).add(distanceV);
|
||||||
|
|
||||||
|
this.window.put(wndPosV.x, wndPosV.y, this.map.get(x, y).minimap, this.fg, this.bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.window.put(this.distance, this.distance, "@", "#44F");
|
||||||
|
this.window.commitToDOM();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.PI < 0 && AsciiWindow && TileMap && Vector2i) {
|
||||||
|
("STFU Linda");
|
||||||
|
}
|
||||||
41
bench.js
41
bench.js
@@ -1,41 +0,0 @@
|
|||||||
function dataset() {
|
|
||||||
return new Array(4000).fill().map(() => "fusse".repeat(5));
|
|
||||||
}
|
|
||||||
|
|
||||||
const simCount = 100_000;
|
|
||||||
|
|
||||||
const start = Date.now();
|
|
||||||
|
|
||||||
const target = "not found";
|
|
||||||
|
|
||||||
for (let sim = 0; sim < simCount; sim++) {
|
|
||||||
const ds = dataset();
|
|
||||||
|
|
||||||
// const len = ds.length;
|
|
||||||
// for (let i = 0; i < len; i++) {
|
|
||||||
// // pretend to do work on the data elements
|
|
||||||
// const el = ds[i];
|
|
||||||
// if (el === target) {
|
|
||||||
// console.log("foo");
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// ds.forEach((el) => {
|
|
||||||
// if (el === target) {
|
|
||||||
// console.log("foo");
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
|
|
||||||
while (ds.length > 0) {
|
|
||||||
let el = ds.pop();
|
|
||||||
if (el === target) {
|
|
||||||
console.log("foo");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("time: %f msec", Date.now() - start);
|
|
||||||
|
|
||||||
// for-loop : 8568 msec
|
|
||||||
// .forEach : 8551 msec
|
|
||||||
// pop() : 8765 msec
|
|
||||||
212
dda.txt
Normal file
212
dda.txt
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
You said:
|
||||||
|
Im making a dungeon crawler where all the walls are guaranteed to be tile-aligned. It's wolfensteil graphics, so no need for real ray tracing, only vertical ray casting in 2d. Can you help me make a fast javascript ray-caster that does not just increment the rays a tiny bit every time. I think the algorithm may be called DDA. I also need to know where on the wall the ray hits, so I can apply the correct texture
|
||||||
|
ChatGPT said:
|
||||||
|
|
||||||
|
Yes — you’re talking about the DDA (Digital Differential Analyzer) algorithm, which is the standard way to do fast 2D grid ray casting for Wolfenstein-style engines.
|
||||||
|
|
||||||
|
The idea: instead of stepping pixel by pixel, you compute how far you need to go to reach the next vertical or next horizontal grid line, then keep stepping grid-by-grid until you hit a wall.
|
||||||
|
|
||||||
|
Here’s a compact JavaScript implementation outline:
|
||||||
|
|
||||||
|
// grid: 2D array of 0 = empty, 1 = wall
|
||||||
|
// posX, posY: player position in map coords (floats, tile units)
|
||||||
|
// dirX, dirY: ray direction (normalized vector)
|
||||||
|
// maxDist: optional cutoff distance
|
||||||
|
function castRay(grid, posX, posY, dirX, dirY, maxDist = 100) {
|
||||||
|
const mapWidth = grid[0].length;
|
||||||
|
const mapHeight = grid.length;
|
||||||
|
|
||||||
|
// Current map square
|
||||||
|
let mapX = Math.floor(posX);
|
||||||
|
let mapY = Math.floor(posY);
|
||||||
|
|
||||||
|
// Length of ray from one x or y-side to next x or y-side
|
||||||
|
const deltaDistX = Math.abs(1 / dirX);
|
||||||
|
const deltaDistY = Math.abs(1 / dirY);
|
||||||
|
|
||||||
|
// Step direction (+1 or -1) and initial sideDist
|
||||||
|
let stepX, stepY;
|
||||||
|
let sideDistX, sideDistY;
|
||||||
|
|
||||||
|
if (dirX < 0) {
|
||||||
|
stepX = -1;
|
||||||
|
sideDistX = (posX - mapX) * deltaDistX;
|
||||||
|
} else {
|
||||||
|
stepX = 1;
|
||||||
|
sideDistX = (mapX + 1.0 - posX) * deltaDistX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirY < 0) {
|
||||||
|
stepY = -1;
|
||||||
|
sideDistY = (posY - mapY) * deltaDistY;
|
||||||
|
} else {
|
||||||
|
stepY = 1;
|
||||||
|
sideDistY = (mapY + 1.0 - posY) * deltaDistY;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hit = false;
|
||||||
|
let side = 0; // 0 = hit x-side, 1 = hit y-side
|
||||||
|
|
||||||
|
// DDA loop
|
||||||
|
while (!hit) {
|
||||||
|
if (sideDistX < sideDistY) {
|
||||||
|
sideDistX += deltaDistX;
|
||||||
|
mapX += stepX;
|
||||||
|
side = 0;
|
||||||
|
} else {
|
||||||
|
sideDistY += deltaDistY;
|
||||||
|
mapY += stepY;
|
||||||
|
side = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for out of bounds
|
||||||
|
if (mapX < 0 || mapX >= mapWidth || mapY < 0 || mapY >= mapHeight) {
|
||||||
|
return null; // no hit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ray hit a wall
|
||||||
|
if (grid[mapY][mapX] > 0) hit = true;
|
||||||
|
if (Math.min(sideDistX, sideDistY) > maxDist) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate distance to wall
|
||||||
|
let perpWallDist;
|
||||||
|
if (side === 0) {
|
||||||
|
perpWallDist = (mapX - posX + (1 - stepX) / 2) / dirX;
|
||||||
|
} else {
|
||||||
|
perpWallDist = (mapY - posY + (1 - stepY) / 2) / dirY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where on the wall did we hit? (texture coordinate)
|
||||||
|
let wallX;
|
||||||
|
if (side === 0) {
|
||||||
|
wallX = posY + perpWallDist * dirY;
|
||||||
|
} else {
|
||||||
|
wallX = posX + perpWallDist * dirX;
|
||||||
|
}
|
||||||
|
wallX -= Math.floor(wallX); // fractional part only [0..1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
mapX, mapY, // tile coords
|
||||||
|
side, // hit vertical or horizontal wall
|
||||||
|
distance: perpWallDist,
|
||||||
|
textureX: wallX // use this to index into texture
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
|
||||||
|
DDA stepping means you only move to the next grid line each time → very fast.
|
||||||
|
|
||||||
|
side tells you if you hit a vertical or horizontal wall (so you can darken one side for fake lighting).
|
||||||
|
|
||||||
|
textureX gives you the fractional offset into the wall tile (for correct texture mapping).
|
||||||
|
|
||||||
|
👉 Then in your renderer, for each vertical screen stripe:
|
||||||
|
|
||||||
|
Compute the ray direction from the camera plane.
|
||||||
|
|
||||||
|
Call castRay.
|
||||||
|
|
||||||
|
Use distance to determine column height.
|
||||||
|
|
||||||
|
Use textureX to select the right column from your texture.
|
||||||
|
|
||||||
|
Do you want me to also show you how to set up the main loop that casts all rays for the screen (like Wolfenstein’s projection plane math), or are you mainly looking for just the DDA core?
|
||||||
|
|
||||||
|
You said:
|
||||||
|
yes please
|
||||||
|
ChatGPT said:
|
||||||
|
|
||||||
|
Perfect — let’s wire the DDA ray-caster into a full “Wolfenstein” style projection loop.
|
||||||
|
|
||||||
|
The basic camera model:
|
||||||
|
|
||||||
|
Player has a position (posX, posY) and a direction vector (dirX, dirY).
|
||||||
|
|
||||||
|
You also need a perpendicular camera plane (planeX, planeY) that defines the field of view.
|
||||||
|
|
||||||
|
Typical setup: dirX=1, dirY=0 and planeX=0, planeY=0.66 → ~66° FOV.
|
||||||
|
|
||||||
|
Then, for each screen column x, you build a ray direction, cast it with DDA, and draw the vertical stripe.
|
||||||
|
|
||||||
|
Here’s a skeleton in JavaScript:
|
||||||
|
|
||||||
|
function renderFrame(ctx, screenWidth, screenHeight, grid, posX, posY, dirX, dirY, planeX, planeY, textures) {
|
||||||
|
for (let x = 0; x < screenWidth; x++) {
|
||||||
|
// cameraX: -1 on left side, +1 on right side of screen
|
||||||
|
const cameraX = 2 * x / screenWidth - 1;
|
||||||
|
const rayDirX = dirX + planeX * cameraX;
|
||||||
|
const rayDirY = dirY + planeY * cameraX;
|
||||||
|
|
||||||
|
// Cast ray using our DDA function
|
||||||
|
const hit = castRay(grid, posX, posY, rayDirX, rayDirY);
|
||||||
|
if (!hit) continue; // nothing hit (out of bounds)
|
||||||
|
|
||||||
|
const { distance, side, textureX, mapX, mapY } = hit;
|
||||||
|
|
||||||
|
// Projected wall height (avoid div by zero)
|
||||||
|
const lineHeight = Math.floor(screenHeight / (distance || 0.0001));
|
||||||
|
|
||||||
|
// Draw start and end of wall stripe
|
||||||
|
let drawStart = Math.floor(-lineHeight / 2 + screenHeight / 2);
|
||||||
|
let drawEnd = Math.floor(lineHeight / 2 + screenHeight / 2);
|
||||||
|
if (drawStart < 0) drawStart = 0;
|
||||||
|
if (drawEnd >= screenHeight) drawEnd = screenHeight - 1;
|
||||||
|
|
||||||
|
// Pick texture (here grid value decides which texture)
|
||||||
|
const tex = textures[grid[mapY][mapX] % textures.length];
|
||||||
|
const texWidth = tex.width, texHeight = tex.height;
|
||||||
|
|
||||||
|
// X coord on texture
|
||||||
|
let texX = Math.floor(textureX * texWidth);
|
||||||
|
if (side === 0 && rayDirX > 0) texX = texWidth - texX - 1;
|
||||||
|
if (side === 1 && rayDirY < 0) texX = texWidth - texX - 1;
|
||||||
|
|
||||||
|
// Draw vertical stripe from texture
|
||||||
|
const column = ctx.getImageData(texX, 0, 1, texHeight); // one column
|
||||||
|
for (let y = drawStart; y < drawEnd; y++) {
|
||||||
|
// Map screen y to texture y
|
||||||
|
const d = y * 256 - screenHeight * 128 + lineHeight * 128;
|
||||||
|
const texY = Math.floor(((d * texHeight) / lineHeight) / 256);
|
||||||
|
const texIndex = (texY * texWidth + texX) * 4;
|
||||||
|
|
||||||
|
const r = column.data[texIndex];
|
||||||
|
const g = column.data[texIndex + 1];
|
||||||
|
const b = column.data[texIndex + 2];
|
||||||
|
const a = column.data[texIndex + 3];
|
||||||
|
|
||||||
|
// Simple shading: darken if y-side
|
||||||
|
const shade = side === 1 ? 0.7 : 1.0;
|
||||||
|
|
||||||
|
ctx.fillStyle = `rgba(${r * shade}, ${g * shade}, ${b * shade}, ${a / 255})`;
|
||||||
|
ctx.fillRect(x, y, 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
What’s happening:
|
||||||
|
|
||||||
|
Camera math
|
||||||
|
Each screen column x corresponds to a ray direction via (dirX + planeX*cameraX, dirY + planeY*cameraX).
|
||||||
|
|
||||||
|
Ray casting
|
||||||
|
Calls the castRay function from earlier.
|
||||||
|
|
||||||
|
Projection
|
||||||
|
Wall height = screenHeight / distance.
|
||||||
|
|
||||||
|
Texturing
|
||||||
|
|
||||||
|
textureX = fractional hit location along the wall.
|
||||||
|
|
||||||
|
Flipped if hitting certain wall sides.
|
||||||
|
|
||||||
|
Then we stretch the vertical texture column to fit the projected wall height.
|
||||||
|
|
||||||
|
Shading
|
||||||
|
Optional: darken walls hit on y-sides to fake lighting.
|
||||||
|
|
||||||
|
This is enough to render Wolfenstein-style walls! 🚀
|
||||||
|
|
||||||
|
👉 Do you want me to also show you how to manage floor/ceiling rendering (raycasting them too, or using vertical interpolation), or do you only need walls for now?
|
||||||
@@ -18,16 +18,25 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#viewport,
|
#viewport {
|
||||||
#minimap {
|
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 10px;
|
line-height: 8px;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
border: 2px solid #0f0;
|
border: 2px solid #0f0;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background-color: #000;
|
padding: 2px;
|
||||||
padding: 10px;
|
border: 5px solid #666;
|
||||||
overflor: ignore;
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
#minimap {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
white-space: pre;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px;
|
||||||
|
border: 5px solid #666;
|
||||||
|
color: #666;
|
||||||
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
#controls {
|
#controls {
|
||||||
@@ -39,60 +48,6 @@
|
|||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#minimap .player {
|
|
||||||
position: relative; /* anchor */
|
|
||||||
color: #000; /* text blends into background */
|
|
||||||
/* background-color: red; */
|
|
||||||
}
|
|
||||||
#minimap .player.north::before {
|
|
||||||
content: "↑";
|
|
||||||
/* content: "★"; */
|
|
||||||
position: absolute;
|
|
||||||
inset: 0; /* shorthand for top:0; right:0; bottom:0; left:0; */
|
|
||||||
display: flex; /* center it */
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
pointer-events: none; /* 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 {
|
textarea {
|
||||||
background-color: #001100;
|
background-color: #001100;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
@@ -124,7 +79,7 @@
|
|||||||
<div id="compass">orientation</div>
|
<div id="compass">orientation</div>
|
||||||
|
|
||||||
<div id="mapInput">
|
<div id="mapInput">
|
||||||
<div>Load your map (# = walls, space = floor):</div>
|
←→↑↓
|
||||||
<br />
|
<br />
|
||||||
<textarea id="mapText" rows="10" cols="50">
|
<textarea id="mapText" rows="10" cols="50">
|
||||||
############################################################
|
############################################################
|
||||||
@@ -133,7 +88,7 @@
|
|||||||
## ################# ########################
|
## ################# ########################
|
||||||
## # # ################# # ## ########################
|
## # # ################# # ## ########################
|
||||||
## # ################# # ## ################
|
## # ################# # ## ################
|
||||||
## # # ################# # ## #### ####
|
## # # S ################# # ## #### ####
|
||||||
## # # ## # #### # # ####
|
## # # ## # #### # # ####
|
||||||
###### #################### ## #### # ####
|
###### #################### ## #### # ####
|
||||||
###### #################### # ## # # #### ####
|
###### #################### # ## # # #### ####
|
||||||
|
|||||||
@@ -1,35 +1,12 @@
|
|||||||
|
import { Vector2i, Orientation, RelativeMovement, PI_OVER_TWO } from "./ascii_types.js";
|
||||||
|
import { FirstPersonRenderer } from "./ascii_first_person_renderer.js";
|
||||||
|
import { MiniMapRenderer } from "../ascii_minimap_renderer.js";
|
||||||
|
import { Texture } from "./ascii_textureloader.js";
|
||||||
|
import { AsciiWindow } from "./ascii_window.js";
|
||||||
|
import { TileMap } from "./ascii_tile_map.js";
|
||||||
|
import eobWallUrl1 from "./eob1.png";
|
||||||
|
import eobWallUrl2 from "./eob2.png";
|
||||||
import { sprintf } from "sprintf-js";
|
import { sprintf } from "sprintf-js";
|
||||||
import { Vector2i } from "./vec2.js";
|
|
||||||
import { AsciiWindow } from "./ascrii_window.js";
|
|
||||||
|
|
||||||
const PI_OVER_TWO = Math.PI / 2;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enum Cardinal Direction (east north west south)
|
|
||||||
* @constant
|
|
||||||
* @readonly
|
|
||||||
*/
|
|
||||||
const CardinalDirection = {
|
|
||||||
/** @constant @readonly @type {number} Going east increases X */
|
|
||||||
EAST: 0,
|
|
||||||
/** @constant @readonly @type {number} Going south increases Y */
|
|
||||||
SOUTH: 1,
|
|
||||||
/** @constant @readonly @type {number} Going west decreases X */
|
|
||||||
WEST: 2,
|
|
||||||
/** @constant @readonly @type {number} Going south decreases Y */
|
|
||||||
NORTH: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enum Relative Direction (forward, left, right, backwards)
|
|
||||||
* @readonly
|
|
||||||
*/
|
|
||||||
const RelativeMovement = {
|
|
||||||
FORWARD: 0,
|
|
||||||
LEFT: 3,
|
|
||||||
BACKWARD: 2,
|
|
||||||
RIGHT: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
class Player {
|
class Player {
|
||||||
_posV = new Vector2i();
|
_posV = new Vector2i();
|
||||||
@@ -56,7 +33,7 @@ class Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get orientation() {
|
get orientation() {
|
||||||
return this._directionV.cardinalDirection();
|
return this._directionV.orientation();
|
||||||
}
|
}
|
||||||
|
|
||||||
set orientation(o) {
|
set orientation(o) {
|
||||||
@@ -64,19 +41,19 @@ class Player {
|
|||||||
// Sanitize o
|
// Sanitize o
|
||||||
o = ((o | 0) + 4) % 4;
|
o = ((o | 0) + 4) % 4;
|
||||||
|
|
||||||
if (o === CardinalDirection.EAST) {
|
if (o === Orientation.EAST) {
|
||||||
this._directionV = new Vector2i(1, 0);
|
this._directionV = new Vector2i(1, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (o === CardinalDirection.NORTH) {
|
if (o === Orientation.NORTH) {
|
||||||
this._directionV = new Vector2i(0, 1);
|
this._directionV = new Vector2i(0, 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (o === CardinalDirection.WEST) {
|
if (o === Orientation.WEST) {
|
||||||
this._directionV = new Vector2i(-1, 0);
|
this._directionV = new Vector2i(-1, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (o === CardinalDirection.SOUTH) {
|
if (o === Orientation.SOUTH) {
|
||||||
this._directionV = new Vector2i(0, -1);
|
this._directionV = new Vector2i(0, -1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -89,7 +66,12 @@ class Player {
|
|||||||
|
|
||||||
class DungeonCrawler {
|
class DungeonCrawler {
|
||||||
get isAnimating() {
|
get isAnimating() {
|
||||||
return this.animation.frames.length > 0;
|
return (
|
||||||
|
this.animation === undefined ||
|
||||||
|
this.animation.targetAngle !== undefined ||
|
||||||
|
this.animation.targetX !== undefined ||
|
||||||
|
this.animation.targetY !== undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -108,38 +90,18 @@ class DungeonCrawler {
|
|||||||
names: {},
|
names: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
this.map = {
|
/** @readonly @type {TileMap} */
|
||||||
/** @readonly height of map */
|
this.map;
|
||||||
width: 0,
|
|
||||||
|
|
||||||
/** @readonly width of map */
|
|
||||||
height: 0,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @readonly
|
|
||||||
* @type {Uint8Array[]}
|
|
||||||
* info about each cell/tile in the map (is it floor, is it a wall, etc.)
|
|
||||||
*
|
|
||||||
* The number 0 is navigable and has no decoration.
|
|
||||||
* The number 1 is not navigable, and is a nondescript wall.
|
|
||||||
*
|
|
||||||
* 1 bit for navigable
|
|
||||||
* 3 bits for cell type decoration / voxel type
|
|
||||||
* 2 bits for floor decoration.
|
|
||||||
* 2 bits for ceiling decoration.
|
|
||||||
*/
|
|
||||||
cells: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
this.animation = {
|
this.animation = {
|
||||||
/** @constant @readonly @type {number} Number of frames per second used in animations */
|
StartTime: undefined,
|
||||||
fps: 30,
|
StartAngle: undefined,
|
||||||
|
StartX: undefined,
|
||||||
/** @constant @readonly number of seconds a typical animation takes */
|
StartY: undefined,
|
||||||
duration: 0.7,
|
targetTime: undefined,
|
||||||
|
targetAngle: undefined,
|
||||||
/** Array storing information about each frame of an animation to show */
|
targetX: undefined,
|
||||||
frames: [],
|
targetY: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @readonly */
|
/** @readonly */
|
||||||
@@ -148,15 +110,14 @@ class DungeonCrawler {
|
|||||||
ticker: 0,
|
ticker: 0,
|
||||||
maxDepth: 5,
|
maxDepth: 5,
|
||||||
fov: Math.PI / 3, // 60 degrees, increase maybe?
|
fov: Math.PI / 3, // 60 degrees, increase maybe?
|
||||||
view: new AsciiWindow(document.getElementById("viewport"), 120, 40),
|
view: new AsciiWindow(document.getElementById("viewport"), 120, 50),
|
||||||
|
|
||||||
|
/** @type {FirstPersonRenderer} */
|
||||||
|
renderer: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @readonly */
|
/** @readonly @type {MiniMapRenderer} */
|
||||||
this.minimap = {
|
this.minimap;
|
||||||
parentElement: document.getElementById("minimap"),
|
|
||||||
/** @type {Element[][]} */
|
|
||||||
elements: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef Player
|
* @typedef Player
|
||||||
@@ -167,64 +128,69 @@ class DungeonCrawler {
|
|||||||
this.player = new Player();
|
this.player = new Player();
|
||||||
|
|
||||||
this.setupControls();
|
this.setupControls();
|
||||||
this.setupAnimationLoop();
|
|
||||||
|
|
||||||
this.loadMap();
|
this.loadMap();
|
||||||
this.render(this.player.x, this.player.y, this.player.orientation * PI_OVER_TWO, this.animation.frames.length);
|
|
||||||
this.updateCompass();
|
this.updateCompass();
|
||||||
|
this.rendering.view.commitToDOM();
|
||||||
|
this.render(this.player.x, this.player.y, this.player.orientation * PI_OVER_TWO);
|
||||||
|
this.gameLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
render(posX = this.player.x, posY = this.player.y, angle = this.player.angle) {
|
||||||
|
if (!this.rendering.renderer) {
|
||||||
|
console.log("Renderer not ready yet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.rendering.renderer.renderFrame(
|
||||||
|
posX + 0.5, // add .5 to get camera into center of cell
|
||||||
|
posY + 0.5, // add .5 to get camera into center of cell
|
||||||
|
angle,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadMap() {
|
loadMap() {
|
||||||
this.minimap.parentElement.innerHTML = "";
|
|
||||||
|
|
||||||
const mapString = document.getElementById("mapText").value;
|
const mapString = document.getElementById("mapText").value;
|
||||||
const lines = mapString.trim().split("\n");
|
|
||||||
|
|
||||||
const h = (this.map.height = lines.length);
|
this.map = TileMap.fromText(mapString);
|
||||||
const w = (this.map.width = Math.max(...lines.map((line) => line.length)));
|
|
||||||
|
|
||||||
this.map.cells = new Array(h).fill().map(() => new Uint8Array(w));
|
this.player._posV = this.map.findFirst({ startLocation: true });
|
||||||
this.minimap.elements = new Array(h).fill().map(() => new Array(w));
|
if (!this.player._posV) {
|
||||||
this.minimap.parentElement.innerHTML = "";
|
throw new Error("Could not find a start location for the player");
|
||||||
|
|
||||||
for (let y = 0; y < h; y++) {
|
|
||||||
//
|
|
||||||
const row = document.createElement("div");
|
|
||||||
|
|
||||||
for (let x = 0; x < w; x++) {
|
|
||||||
const isFree = lines[y][x] === " ";
|
|
||||||
|
|
||||||
//
|
|
||||||
// === Internal map ===
|
|
||||||
//
|
|
||||||
this.map.cells[y][x] = isFree ? 0 : 1;
|
|
||||||
|
|
||||||
//
|
|
||||||
// === Mini Map ===
|
|
||||||
//
|
|
||||||
const mmElement = document.createElement("span");
|
|
||||||
mmElement.textContent = isFree ? " " : "#";
|
|
||||||
row.appendChild(mmElement);
|
|
||||||
this.minimap.elements[y][x] = mmElement;
|
|
||||||
}
|
|
||||||
this.minimap.parentElement.appendChild(row);
|
|
||||||
}
|
}
|
||||||
|
console.log(this.map.getAreaAround(this.player.x, this.player.y, 5).toString());
|
||||||
|
|
||||||
// Find a starting position (first open space)
|
const minimapElement = document.getElementById("minimap");
|
||||||
for (let y = 1; y < this.map.height - 1; y++) {
|
const minimapWindow = new AsciiWindow(minimapElement, 9, 9); // MAGIC NUMBERS: width and height of the minimap
|
||||||
for (let x = 1; x < this.map.width - 1; x++) {
|
this.minimap = new MiniMapRenderer(minimapWindow, this.map);
|
||||||
if (this.isWall(x, y)) {
|
|
||||||
continue;
|
const textureUrls = [eobWallUrl1, eobWallUrl2];
|
||||||
}
|
const textureCount = textureUrls.length;
|
||||||
this.player.x = x;
|
const textures = [];
|
||||||
this.player.y = y;
|
|
||||||
|
textureUrls.forEach((url) => {
|
||||||
|
Texture.fromSource(url).then((texture) => {
|
||||||
|
textures.push(texture);
|
||||||
|
|
||||||
|
if (textures.length < textureCount) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
this.updateMinimap();
|
this.rendering.renderer = new FirstPersonRenderer(
|
||||||
|
this.rendering.view,
|
||||||
|
this.map,
|
||||||
|
this.rendering.fov,
|
||||||
|
this.rendering.maxDepth,
|
||||||
|
textures,
|
||||||
|
);
|
||||||
|
this.render();
|
||||||
|
this.minimap.draw(this.player.x, this.player.y, this.player.orientation);
|
||||||
|
|
||||||
|
console.debug("renderer ready", texture);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
startTurnAnimation(clockwise, quarterTurns = 1) {
|
startTurnAnimation(quarterTurns = 1) {
|
||||||
if (this.isAnimating) {
|
if (this.isAnimating) {
|
||||||
throw new Error("Cannot start an animation while one is already running");
|
throw new Error("Cannot start an animation while one is already running");
|
||||||
}
|
}
|
||||||
@@ -233,42 +199,38 @@ class DungeonCrawler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newDirection = clockwise
|
this.animation = {
|
||||||
? this.player._directionV.clone().rotateCW(quarterTurns)
|
startAngle: this.player.angle,
|
||||||
: this.player._directionV.clone().rotateCCW(quarterTurns);
|
startTime: performance.now(),
|
||||||
|
startX: this.player.x,
|
||||||
|
startY: this.player.y,
|
||||||
|
|
||||||
const ticks = Math.floor(this.animation.duration * this.animation.fps);
|
targetAngle: this.player.angle + PI_OVER_TWO * quarterTurns,
|
||||||
const startAngle = this.player.angle;
|
targetTime: performance.now() + 700, // MAGIC NUMBER: these animations take .7 seconds
|
||||||
const slice = this.player._directionV.angleTo(newDirection) / ticks;
|
targetX: this.player.x,
|
||||||
|
targetY: this.player.y,
|
||||||
this.animation.frames = [];
|
};
|
||||||
|
|
||||||
for (let i = 1; i < ticks; i++) {
|
|
||||||
this.animation.frames.push([
|
|
||||||
this.player.x, //
|
|
||||||
this.player.y, //
|
|
||||||
startAngle + slice * i, //
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.animation.frames.push([this.player.x, this.player.y, newDirection.angle()]);
|
|
||||||
|
|
||||||
//
|
//
|
||||||
//
|
this.player._directionV.rotateCCW(quarterTurns);
|
||||||
this.player._directionV = newDirection;
|
|
||||||
this.updateMinimap();
|
|
||||||
this.updateCompass();
|
this.updateCompass();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {RelativeMovement} Direction the player is going to move */
|
/** @type {RelativeMovement} Direction the player is going to move */
|
||||||
startMoveAnimation(direction) {
|
startMoveAnimation(direction) {
|
||||||
|
//
|
||||||
if (this.isAnimating) {
|
if (this.isAnimating) {
|
||||||
throw new Error("Cannot start an animation while one is already running");
|
throw new Error("Cannot start an animation while one is already running");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// When we move, we move relative to our current viewing direction,
|
||||||
|
// so moving LEFT means moving 1 square 90 degrees from our viewing direction vector
|
||||||
const targetV = this.player._directionV.rotatedCCW(direction | 0).added(this.player._posV);
|
const targetV = this.player._directionV.rotatedCCW(direction | 0).added(this.player._posV);
|
||||||
|
|
||||||
if (this.isWall(targetV.x, targetV.y)) {
|
//
|
||||||
|
// We cant move into walls
|
||||||
|
if (this.map.isWall(targetV.x, targetV.y)) {
|
||||||
this.debounce = (this.pollsPerSec / 5) | 0;
|
this.debounce = (this.pollsPerSec / 5) | 0;
|
||||||
console.info(
|
console.info(
|
||||||
"bumped into wall at %s (mypos: %s), direction=%d",
|
"bumped into wall at %s (mypos: %s), direction=%d",
|
||||||
@@ -279,21 +241,19 @@ class DungeonCrawler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticks = Math.floor(this.animation.duration * this.animation.fps);
|
this.animation = {
|
||||||
const stepX = (targetV.x - this.player.x) / ticks;
|
startAngle: this.player.angle,
|
||||||
const stepY = (targetV.y - this.player.y) / ticks;
|
startTime: performance.now(),
|
||||||
|
startX: this.player.x,
|
||||||
|
startY: this.player.y,
|
||||||
|
|
||||||
this.animation.frames = [];
|
targetAngle: this.player.angle,
|
||||||
for (let i = 1; i < ticks; i++) {
|
targetTime: performance.now() + 700, // MAGIC NUMBER: these animations take .7 seconds
|
||||||
this.animation.frames.push([
|
targetX: targetV.x,
|
||||||
this.player.x + stepX * i, //
|
targetY: targetV.y,
|
||||||
this.player.y + stepY * i, //
|
};
|
||||||
this.player.angle, //
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
this.animation.frames.push([targetV.x, targetV.y, this.player.angle]);
|
|
||||||
this.player._posV = targetV;
|
this.player._posV = targetV;
|
||||||
this.updateMinimap();
|
|
||||||
this.updateCompass(); // technically not necessary, but Im anticipating the need + compensating for my bad memory.
|
this.updateCompass(); // technically not necessary, but Im anticipating the need + compensating for my bad memory.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,10 +267,10 @@ class DungeonCrawler {
|
|||||||
KeyW: () => this.startMoveAnimation(RelativeMovement.FORWARD),
|
KeyW: () => this.startMoveAnimation(RelativeMovement.FORWARD),
|
||||||
ArrowUp: () => this.startMoveAnimation(RelativeMovement.FORWARD),
|
ArrowUp: () => this.startMoveAnimation(RelativeMovement.FORWARD),
|
||||||
ArrowDown: () => this.startMoveAnimation(RelativeMovement.BACKWARD),
|
ArrowDown: () => this.startMoveAnimation(RelativeMovement.BACKWARD),
|
||||||
ArrowLeft: () => this.startTurnAnimation(true),
|
ArrowLeft: () => this.startTurnAnimation(-1),
|
||||||
ArrowRight: () => this.startTurnAnimation(false),
|
ArrowRight: () => this.startTurnAnimation(1),
|
||||||
KeyQ: () => this.startTurnAnimation(true),
|
KeyQ: () => this.startTurnAnimation(-1),
|
||||||
KeyE: () => this.startTurnAnimation(false),
|
KeyE: () => this.startTurnAnimation(1),
|
||||||
};
|
};
|
||||||
this.keys.names = Object.keys(this.keys.handlers);
|
this.keys.names = Object.keys(this.keys.handlers);
|
||||||
|
|
||||||
@@ -361,134 +321,54 @@ class DungeonCrawler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleAnimation() {
|
handleAnimation() {
|
||||||
|
//
|
||||||
|
// Guard: only animate if called for
|
||||||
if (!this.isAnimating) {
|
if (!this.isAnimating) {
|
||||||
|
this.animation = {};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [x, y, a] = this.animation.frames.shift();
|
|
||||||
const framesLeft = this.animation.frames.length;
|
|
||||||
this.render(x, y, a, framesLeft);
|
|
||||||
}
|
|
||||||
|
|
||||||
// _____ ___ ____ ___
|
|
||||||
// |_ _/ _ \| _ \ / _ \ _
|
|
||||||
// | || | | | | | | | | (_)
|
|
||||||
// | || |_| | |_| | |_| |_
|
|
||||||
// |_| \___/|____/ \___/(_)
|
|
||||||
// -----------------------------
|
|
||||||
// Animation loop
|
|
||||||
// requestAnimationFrame(loop);
|
|
||||||
// requires using deltaT rather than ticks, etc.
|
|
||||||
setupAnimationLoop() {
|
|
||||||
const ticks = Math.round(1000 / this.animation.fps);
|
|
||||||
this.animation.interval = setInterval(() => this.handleAnimation(), ticks);
|
|
||||||
}
|
|
||||||
|
|
||||||
isWall(x, y) {
|
|
||||||
let mapX = x | 0;
|
|
||||||
let mapY = y | 0;
|
|
||||||
|
|
||||||
if (mapX < 0 || mapX >= this.map.width || mapY < 0 || mapY >= this.map.height) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.map.cells[mapY][mapX] !== 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
castRay(camX, camY, camAngle, angleOffset) {
|
|
||||||
const rayAngle = camAngle + angleOffset;
|
|
||||||
const rayX = Math.cos(rayAngle);
|
|
||||||
const rayY = Math.sin(rayAngle);
|
|
||||||
const fishEye = Math.cos(angleOffset); // corrects fish-eye effect https://stackoverflow.com/questions/66591163/how-do-i-fix-the-warped-perspective-in-my-raycaster
|
|
||||||
// const fishEye = 1;
|
|
||||||
|
|
||||||
let distance = Math.SQRT1_2 / 2;
|
|
||||||
let step = 0.0001;
|
|
||||||
|
|
||||||
while (distance < this.rendering.maxDepth) {
|
|
||||||
const testX = camX + rayX * distance;
|
|
||||||
const testY = camY + rayY * distance;
|
|
||||||
|
|
||||||
if (this.isWall(testX, testY)) {
|
|
||||||
return [
|
|
||||||
distance * fishEye,
|
|
||||||
{
|
|
||||||
// testX,
|
|
||||||
// testY,
|
|
||||||
// rayDistance: distance, // the distance the ray traveled, not the distance the object was away from us
|
|
||||||
color: (1 - distance / this.rendering.maxDepth) * 1.0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
distance += step;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [this.rendering.maxDepth, "#000"];
|
|
||||||
}
|
|
||||||
|
|
||||||
render(x, y, direction) {
|
|
||||||
if (!this.rendering.enabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.rendering.ticker++;
|
|
||||||
|
|
||||||
x += 0.5;
|
|
||||||
y += 0.5;
|
|
||||||
|
|
||||||
const h = this.rendering.view.height;
|
|
||||||
const w = this.rendering.view.width;
|
|
||||||
|
|
||||||
// const middle = this.rendering.height / 2;
|
|
||||||
// Hack to simulate bouncy walking by moving the middle of the screen up and down a bit
|
|
||||||
const bounce = Math.sin(this.rendering.ticker / 4) * 0.2;
|
|
||||||
const middle = h / 2 + bounce;
|
|
||||||
|
|
||||||
for (let screenX = 0; screenX < w; screenX++) {
|
|
||||||
//
|
//
|
||||||
//
|
// Guard, stop animation if it took too long
|
||||||
const rayOffset = (screenX / w) * this.rendering.fov - this.rendering.fov / 2;
|
if (this.animation.targetTime <= performance.now()) {
|
||||||
|
this.render(this.player.x, this.player.y, this.player.angle);
|
||||||
//
|
this.animation = {};
|
||||||
// Cast the ray, one ray per column, just like wolfenstein
|
this.minimap.draw(this.player.x, this.player.y, this.player.orientation);
|
||||||
const [distance, wall] = this.castRay(x, y, direction, rayOffset);
|
return;
|
||||||
|
|
||||||
//
|
|
||||||
// Start drawing
|
|
||||||
for (let screenY = 0; screenY < h; screenY++) {
|
|
||||||
//
|
|
||||||
// Calculate how high walls are at the distance of the ray's intersection
|
|
||||||
const wallH = middle / distance;
|
|
||||||
|
|
||||||
//
|
|
||||||
// Given the current y-coordinate and distance, are we hitting the ceiling?
|
|
||||||
if (screenY < middle - wallH) {
|
|
||||||
this.rendering.view.put(screenX, screenY, "´", "#999");
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const a = this.animation;
|
||||||
|
|
||||||
|
const nowT = performance.now();
|
||||||
|
const animY = a.targetY - a.startY; // how much this animation causes us to move in the y-direction
|
||||||
|
const animX = a.targetX - a.startX; // how much this animation causes us to move in the x-direction
|
||||||
|
const animA = a.targetAngle - a.startAngle; // how much this animation causes us to rotate in total
|
||||||
|
const animT = a.targetTime - a.startTime; // how long (in ms) this animation is supposed to take.
|
||||||
|
|
||||||
|
const deltaT = (nowT - a.startTime) / animT;
|
||||||
|
if (deltaT > 1) {
|
||||||
|
throw new Error("Not supposed to happen!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// render
|
||||||
|
this.render(
|
||||||
|
a.startX + animX * deltaT, //
|
||||||
|
a.startY + animY * deltaT, //
|
||||||
|
a.startAngle + animA * deltaT, //
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
gameLoop() {
|
||||||
//
|
//
|
||||||
// Given the current y-coordinate and distance, are we hitting the floor?
|
// We're not animating, so we chill out for 50 msec
|
||||||
if (screenY > middle + wallH) {
|
if (!this.isAnimating) {
|
||||||
this.rendering.view.put(screenX, screenY, "~", "#b52");
|
setTimeout(() => this.gameLoop(), 50); // MAGIC NUMBER
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
this.handleAnimation();
|
||||||
// We've either hit a wall or the limit of our visibility,
|
|
||||||
// So we Determine the color of the pixel to draw.
|
|
||||||
const color = wall && wall.color ? wall.color : 1 - distance / this.rendering.maxDepth;
|
|
||||||
|
|
||||||
// TODO: Lerp these characters.
|
requestAnimationFrame(() => this.gameLoop());
|
||||||
// const distancePalette = ["█", "▓", "▒", "░", " "];
|
|
||||||
const distancePalette = ["#", "%", "+", "÷", " "];
|
|
||||||
const char = distancePalette[distance | 0];
|
|
||||||
|
|
||||||
this.rendering.view.put(screenX, screenY, char, color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.rendering.view.commitToDOM();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCompass() {
|
updateCompass() {
|
||||||
@@ -498,38 +378,12 @@ class DungeonCrawler {
|
|||||||
document.getElementById("compass").textContent = sprintf(
|
document.getElementById("compass").textContent = sprintf(
|
||||||
"%s %s (%d --> %.2f [%dº])",
|
"%s %s (%d --> %.2f [%dº])",
|
||||||
this.player._posV,
|
this.player._posV,
|
||||||
Object.keys(CardinalDirection)[this.player.orientation].toLowerCase(),
|
Object.keys(Orientation)[this.player.orientation].toLowerCase(),
|
||||||
this.player.orientation,
|
this.player.orientation,
|
||||||
this.player.orientation * PI_OVER_TWO,
|
this.player.orientation * PI_OVER_TWO,
|
||||||
this.player.orientation * 90,
|
this.player.orientation * 90,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMinimap() {
|
|
||||||
if (!this.player.withinAABB(this.map.width - 1, this.map.height - 1)) {
|
|
||||||
console.error("Player out of bounds");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Remove the old player symbol
|
|
||||||
const playerEl = document.querySelector(".player");
|
|
||||||
if (playerEl) {
|
|
||||||
playerEl.className = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is just for debugging!
|
|
||||||
//
|
|
||||||
|
|
||||||
for (let y = 0; y < this.map.height; y++) {
|
|
||||||
for (let x = 0; x < this.map.width; x++) {
|
|
||||||
this.minimap.elements[y][x].textContent = this.map.cells[y][x] ? "#" : " ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add the player token to the minimap
|
|
||||||
const dirForCSS = Object.keys(CardinalDirection)[this.player.orientation].toLowerCase();
|
|
||||||
this.minimap.elements[this.player.y][this.player.x].classList.add("player", dirForCSS);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the game
|
// Start the game
|
||||||
|
|||||||
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 = {
|
import { Vector2i, Orientation } from "./ascii_types.js";
|
||||||
" ": {
|
import { AsciiWindow } from "./ascii_window.js";
|
||||||
type: "empty",
|
import { Texture } from "./ascii_textureloader.js";
|
||||||
},
|
|
||||||
// Default Wall
|
|
||||||
"#": {
|
|
||||||
minimap: "#",
|
|
||||||
occupied: true,
|
|
||||||
type: "wall",
|
|
||||||
},
|
|
||||||
|
|
||||||
// Column
|
class Tile {
|
||||||
"◯": {
|
/** @type {string} How should this tile be rendered on the minimap.*/
|
||||||
minimap: "◯",
|
minimap = " ";
|
||||||
occupied: true,
|
/** @type {boolean} Should this be rendered as a wall? */
|
||||||
type: "statue",
|
wall = false;
|
||||||
},
|
/** @type {boolean} Can the player walk here? */
|
||||||
|
traversable = true;
|
||||||
|
/** @type {boolean} Is this where they player starts? */
|
||||||
|
startLocation = false;
|
||||||
|
/** @type {boolean} Is this where they player starts? */
|
||||||
|
textureId = 0;
|
||||||
|
|
||||||
// Closed Door
|
|
||||||
"░": {
|
|
||||||
minimap: "░",
|
|
||||||
occupied: true,
|
|
||||||
type: "sprite?????",
|
|
||||||
},
|
|
||||||
|
|
||||||
// Where the player starts
|
|
||||||
"@": {
|
|
||||||
type: "playerStart",
|
|
||||||
},
|
|
||||||
|
|
||||||
// TRAP!
|
|
||||||
"☠": {
|
|
||||||
type: "trap",
|
|
||||||
},
|
|
||||||
|
|
||||||
// Monster
|
|
||||||
"!": {
|
|
||||||
type: "monster",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
export class Tile {
|
|
||||||
/** @param {object} options */
|
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
for (let [k, v] of Object.entries(options)) {
|
for (let [k, v] of Object.entries(options)) {
|
||||||
|
if (this[k] !== undefined) {
|
||||||
this[k] = v;
|
this[k] = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const defaultLegend = Object.freeze({
|
||||||
|
//
|
||||||
|
// default floor
|
||||||
|
" ": new Tile({
|
||||||
|
minimap: " ",
|
||||||
|
traversable: true,
|
||||||
|
wall: false,
|
||||||
|
}),
|
||||||
|
//
|
||||||
|
// Default wall
|
||||||
|
"#": new Tile({
|
||||||
|
minimap: "#",
|
||||||
|
traversable: false,
|
||||||
|
wall: true,
|
||||||
|
textureId: 0,
|
||||||
|
}),
|
||||||
|
//
|
||||||
|
//secret door (looks like wall, but is traversable)
|
||||||
|
"Ω": new Tile({
|
||||||
|
minimap: "#",
|
||||||
|
traversable: true,
|
||||||
|
wall: true,
|
||||||
|
}),
|
||||||
|
//
|
||||||
|
// "" is the Unknown Tile - if we encounter a tile that we don't know how to parse,
|
||||||
|
// the it will be noted here as the empty string
|
||||||
|
"": new Tile({
|
||||||
|
minimap: " ",
|
||||||
|
traversable: true,
|
||||||
|
wall: false,
|
||||||
|
}),
|
||||||
|
//
|
||||||
|
// where the player starts
|
||||||
|
"S": new Tile({
|
||||||
|
minimap: "S", // "Š",
|
||||||
|
traversable: true,
|
||||||
|
wall: false,
|
||||||
|
startLocation: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export class TileMap {
|
export class TileMap {
|
||||||
/** @param {string} str */
|
/**
|
||||||
static fromText(str) {
|
* @param {string} str
|
||||||
|
* @param {Record<string,Tile} legend
|
||||||
|
*/
|
||||||
|
static fromText(str, legend = defaultLegend) {
|
||||||
const lines = str.split("\n");
|
const lines = str.split("\n");
|
||||||
|
|
||||||
const longestLine = lines.reduce((acc, line) => Math.max(acc, line.length), 0);
|
const longestLine = lines.reduce((acc, line) => Math.max(acc, line.length), 0);
|
||||||
|
|
||||||
const tiles = new Array(lines.length).fill().map(() => new Array(longestLine));
|
const tiles = new Array(lines.length).fill().map(() => Array(longestLine));
|
||||||
|
|
||||||
lines.forEach((line, y) => {
|
lines.forEach((line, y) => {
|
||||||
line = line.padEnd(longestLine, "#");
|
line = line.padEnd(longestLine, "#");
|
||||||
|
|
||||||
line.split("").forEach((char, x) => {
|
line.split("").forEach((char, x) => {
|
||||||
const options = defaultLegend[char] ?? defaultLegend[" "];
|
let tile = legend[char];
|
||||||
tiles[y][x] = new Tile(options);
|
|
||||||
|
// unknown char?
|
||||||
|
// check fallback tile.
|
||||||
|
if (tile === undefined) {
|
||||||
|
tile = legend[""];
|
||||||
|
}
|
||||||
|
|
||||||
|
// still no tile - i.e. no back fallback tile?
|
||||||
|
if (tile === undefined) {
|
||||||
|
throw new Error("Dont know how to handle this character: " + char);
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert tile into map.
|
||||||
|
tiles[y][x] = tile;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return new TileMap(tiles);
|
return new TileMap(longestLine, lines.length, tiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Tile[]} tiles
|
* @param {number} width
|
||||||
|
* @param {number} height
|
||||||
|
* @param {Tile[][]} tiles
|
||||||
*/
|
*/
|
||||||
constructor(tiles) {
|
constructor(width, height, tiles) {
|
||||||
|
/** @constant @readonly @type {number} */
|
||||||
this.height = tiles.length;
|
this.height = tiles.length;
|
||||||
|
/** @constant @readonly @type {number} */
|
||||||
this.width = tiles[0].length;
|
this.width = tiles[0].length;
|
||||||
|
/** @constant @readonly @type {Tile[][]} */
|
||||||
this.tiles = tiles;
|
this.tiles = tiles;
|
||||||
|
/** @type {Tile} when probing a coordinate outside the map, this is the tile that is returned */
|
||||||
|
this.outOfBoundsWall = this.findFirst({ wall: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
let result = "";
|
||||||
|
for (let y = 0; y < this.height; y++) {
|
||||||
|
for (let x = 0; x < this.width; x++) {
|
||||||
|
const tile = this.tiles[y][x];
|
||||||
|
result += tile.minimap;
|
||||||
|
}
|
||||||
|
result += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(x, y) {
|
||||||
|
x |= 0;
|
||||||
|
y |= 0;
|
||||||
|
|
||||||
|
if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
|
||||||
|
return this.outOfBoundsWall;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tiles[y][x];
|
||||||
|
}
|
||||||
|
|
||||||
|
isWall(x, y) {
|
||||||
|
x |= 0;
|
||||||
|
y |= 0;
|
||||||
|
|
||||||
|
if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tiles[y][x].wall;
|
||||||
|
}
|
||||||
|
|
||||||
|
findFirst(criteria) {
|
||||||
|
return this.forEach((tile, x, y) => {
|
||||||
|
for (let k in criteria) {
|
||||||
|
if (tile[k] === criteria[k]) {
|
||||||
|
return new Vector2i(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
forEach(fn) {
|
||||||
|
for (let y = 0; y < this.height; y++) {
|
||||||
|
for (let x = 0; x < this.width; x++) {
|
||||||
|
let res = fn(this.tiles[y][x], x, y);
|
||||||
|
if (res !== undefined) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getArea(xMin, yMin, xMax, yMax) {
|
||||||
|
if (xMin > xMax) {
|
||||||
|
[xMin, xMax] = [xMax, xMin];
|
||||||
|
}
|
||||||
|
if (yMin > yMax) {
|
||||||
|
[yMin, yMax] = [yMax, yMin];
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = xMax - xMin + 1;
|
||||||
|
const h = yMax - yMin + 1;
|
||||||
|
let iX = 0;
|
||||||
|
let iY = 0;
|
||||||
|
|
||||||
|
const tiles = new Array(h).fill().map(() => new Array(w));
|
||||||
|
|
||||||
|
for (let y = yMin; y <= yMax; y++) {
|
||||||
|
for (let x = xMin; x <= xMax; x++) {
|
||||||
|
const tile = this.tiles[y][x];
|
||||||
|
if (!tile) {
|
||||||
|
throw new Error("Dafuqq is happing here?");
|
||||||
|
}
|
||||||
|
tiles[iY][iX] = tile;
|
||||||
|
iX++;
|
||||||
|
}
|
||||||
|
iX = 0;
|
||||||
|
iY++;
|
||||||
|
}
|
||||||
|
return new TileMap(w, h, tiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAreaAround(x, y, radius) {
|
||||||
|
return this.getArea(x - radius, y - radius, x + radius, y + radius);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const str = `
|
if (Math.PI < 0 && AsciiWindow && Texture && Orientation) {
|
||||||
kim
|
("STFU Linda");
|
||||||
har en
|
}
|
||||||
# meje #
|
|
||||||
stor pikkemand
|
|
||||||
`;
|
|
||||||
|
|
||||||
const tm = TileMap.fromText(str);
|
|
||||||
|
|
||||||
console.log(tm.tiles);
|
|
||||||
|
|||||||
@@ -1,3 +1,32 @@
|
|||||||
|
export const PI_OVER_TWO = Math.PI / 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum Cardinal Direction (east north west south)
|
||||||
|
* @constant
|
||||||
|
* @readonly
|
||||||
|
*/
|
||||||
|
export const Orientation = {
|
||||||
|
/** @constant @readonly @type {number} Going east increases X */
|
||||||
|
EAST: 0,
|
||||||
|
/** @constant @readonly @type {number} Going south increases Y */
|
||||||
|
SOUTH: 1,
|
||||||
|
/** @constant @readonly @type {number} Going west decreases X */
|
||||||
|
WEST: 2,
|
||||||
|
/** @constant @readonly @type {number} Going south decreases Y */
|
||||||
|
NORTH: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum Relative Direction (forward, left, right, backwards)
|
||||||
|
* @readonly
|
||||||
|
*/
|
||||||
|
export const RelativeMovement = {
|
||||||
|
FORWARD: 0,
|
||||||
|
LEFT: 3,
|
||||||
|
BACKWARD: 2,
|
||||||
|
RIGHT: 1,
|
||||||
|
};
|
||||||
|
|
||||||
export class Vector2i {
|
export class Vector2i {
|
||||||
constructor(x = 0, y = 0) {
|
constructor(x = 0, y = 0) {
|
||||||
this.x = x | 0; // force int
|
this.x = x | 0; // force int
|
||||||
@@ -46,7 +75,7 @@ export class Vector2i {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// which cardinal direction are we in?
|
// which cardinal direction are we in?
|
||||||
cardinalDirection() {
|
orientation() {
|
||||||
if (this.y === 0) {
|
if (this.y === 0) {
|
||||||
if (this.x > 0) {
|
if (this.x > 0) {
|
||||||
return 0;
|
return 0;
|
||||||
121
frontend/ascii_window.js
Executable file
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 |
@@ -41,7 +41,7 @@
|
|||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"tabWidth": 4,
|
"tabWidth": 4,
|
||||||
"printWidth": 120,
|
"printWidth": 160,
|
||||||
"quoteProps": "consistent",
|
"quoteProps": "consistent",
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
|
|||||||
@@ -116,6 +116,72 @@ Each group chat has a name.
|
|||||||
- Like `Rogue` but with tweaks such as detailed descriptions
|
- Like `Rogue` but with tweaks such as detailed descriptions
|
||||||
of the cool stuff the players have done with the room.
|
of the cool stuff the players have done with the room.
|
||||||
|
|
||||||
|
```
|
||||||
|
// __ __ _ ___
|
||||||
|
// | \/ | ___ _ __ ___ _ _ | | ( _ )
|
||||||
|
// | |\/| |/ _ \| '_ \ / _ \ | | / __) / _ \/\
|
||||||
|
// | | | | (_) | | | | __/ |_| \__ \ | (_> <
|
||||||
|
// |_| |_|\___/|_| |_|\___|\__, ( / \___/\/
|
||||||
|
// |___/ |_|
|
||||||
|
// _____
|
||||||
|
// | ____|_ __ ___ ___ _ __ ___ _ __ ___ _ _
|
||||||
|
// | _| | '_ \ / __/ _ \| '_ \ / _ \| '_ ` _ \| | | |
|
||||||
|
// | |___| | | | (_| (_) | | | | (_) | | | | | | |_| |
|
||||||
|
// |_____|_| |_|\___\___/|_| |_|\___/|_| |_| |_|\__, |
|
||||||
|
// |___/
|
||||||
|
```
|
||||||
|
|
||||||
|
- `VAT (Very Awful Tax)`
|
||||||
|
- Applies only to transfers of valuables between parties/players.
|
||||||
|
- Its 10%
|
||||||
|
- Also, players cannot give each other items, they must sell them for a fair price (50% of worth)
|
||||||
|
|
||||||
|
- `Financial Obligations`
|
||||||
|
- A party can have between 0 and 255 FOs.
|
||||||
|
- An obligation of X (where X is higher than 0) means that `X * 100 / 255` percent of all gold is subtracted
|
||||||
|
- When you find it
|
||||||
|
- When you sell an item
|
||||||
|
- When you acquire gold from quests, jobs, services, etc.
|
||||||
|
- When another player/party gives or lends you money
|
||||||
|
- Your obligation can reach 255, but never go above. This means that, if you have an obligation if `X`, and
|
||||||
|
means you cannot do something (hire a hireling, take out a loan, or buy a BOO) that has an FO
|
||||||
|
level higher than `255-X`;
|
||||||
|
- FO only affects gold, not items, gems, etc, but it will affect you once you try to sell those items.
|
||||||
|
|
||||||
|
- `Hirelings`
|
||||||
|
- Can be hired in taverns
|
||||||
|
- Can be exchanged with other players?
|
||||||
|
- Often cost money to buy
|
||||||
|
- Often come with an FO that only goes away when they leave the party.
|
||||||
|
|
||||||
|
- `Bases of Operation (BOOs)`
|
||||||
|
- Can be purchased in cities.
|
||||||
|
- Cost initial large amount of gold
|
||||||
|
- Always comes with some level of FO that
|
||||||
|
only goes away if you sell the BOO or if it is destroyed by
|
||||||
|
baddies.
|
||||||
|
|
||||||
|
- `Loans`
|
||||||
|
- Gives you cash in hand.
|
||||||
|
- Comes with an FO that only goes away when you pay back the loan in its
|
||||||
|
entirety. The obligation is not lowered, even if you pay back 99.9% of the
|
||||||
|
loan.
|
||||||
|
|
||||||
|
- `Artifacts`
|
||||||
|
- Certain magical items come with FOs.
|
||||||
|
Because you rent them rather than own them.
|
||||||
|
|
||||||
|
- `Pleading`
|
||||||
|
- Certain monsters (like Dragons, Gins, Dark Lord Wizards, Bandit Leaders,
|
||||||
|
etc) will let you walk away in exchange for a certain level of FO. This
|
||||||
|
only goes away when you kill the monster.
|
||||||
|
- Note that ordinary bandits will not let you live - they are not clever
|
||||||
|
enough to see your potential.
|
||||||
|
- Pleading only works if you have more free FOs than the level of the
|
||||||
|
monster.
|
||||||
|
- Pleading will usually cost you either 80% of your free FOs, or twice the
|
||||||
|
level of the monster, whichever is lower.
|
||||||
|
|
||||||
```
|
```
|
||||||
____ _ _ _ _ ____
|
____ _ _ _ _ ____
|
||||||
| _ \| | | | \ | / ___|
|
| _ \| | | | \ | / ___|
|
||||||
|
|||||||
Reference in New Issue
Block a user