This commit is contained in:
Kim Ravn Hansen
2025-10-01 20:50:06 +02:00
parent 251e2e4fef
commit 6787489186
13 changed files with 509 additions and 211 deletions

View File

@@ -72,12 +72,11 @@
</head> </head>
<body> <body>
<div id="gameContainer"> <div id="gameContainer">
<div id="threejs" style="border: 2px solid green; background-color: #222"></div>
<div id="minimap"></div>
<div id="compass">orientation</div>
<div id="viewport"></div> <div id="viewport"></div>
<div id="compass">orientation</div>
<div id="minimap"></div>
<div id="threejs" style="border: 2px solid green; background-color: #222"></div>
<div id="mapInput"> <div id="mapInput">
←→↑↓ ←→↑↓
<br /> <br />
@@ -87,12 +86,12 @@
############################################################ ############################################################
## ################# ######################## ## ################# ########################
## # ### ################# # ## ######################## ## # ### ################# # ## ########################
## #S# ################# # ## ################ ## #P# Z###############Z # ## ################ ::: P(north) Z(1) Z(1) ;; Comments
## # # # ################# # ## #### #### ## # # # ################# # ## #### ####
## M # # ## # #### # # #### ## E # # ## # #### # # #### ::: E(Gnolls)
###### #################### ## #### # #### ###### #################### ## #### # ####
###### #################### # ## # # #### #### ###### #################### # ## # # #### ####
######M#################### # #### ######E#################### # #### ::: E(Goblins) These are comments
###### #################### ########## #### #### ###### #################### ########## #### ####
###### #################### ########## # # #### # # #### ###### #################### ########## # # #### # # ####
###### #################### ########## #### # # #### ###### #################### ########## #### # # ####
@@ -100,23 +99,23 @@
###### #################### ############################ ###### #################### ############################
###### #################### # ############################ ###### #################### # ############################
###### #################### # ############################ ###### #################### # ############################
######M#################### # ############################ ######E#################### # ############################ ::: E(Minotaur)
###### ## ########### ############################ ###### ## ##### ## ############################
###### ## ########### # # ############################ ###### ## Z#### ## # # ############################ ::: Z(2) // Channel 2
###### ## ########### ######## ############ ###### ## ####Z ## ######## ############ ::: Z(2) // Channel 2
###### ## ## # ########### ## ######## ############ ###### ## ## # ########### ## ######## ############
###### ## # # ############ ######E## # #E ############ ::: E(Dwarf) ; E(Gelatinous_Cube)
###### # # # ############ ###### # # # ############
######### # ## ########### # ######### # ############ ######### # ## ########### # ######### # ############
######### # # ########### # ######### # # ############ ######### # # ########### # ######### # # ############
######### ########### # ######### ############ ######### ########### # ######### ############
########################### # ######### #### ############### ###########O############### # ######### #### ############### ::: O(1)
########################### # ######### #### ############### ########################### # ######### #### ###############
########################### # ######### #### ############### ########################### # ######### #### ###############
########################### # ######### #### ############### ########################### # ######### #### ###############
########################### # #### ############### ########################### # #### ###############
######################### # #### # # # ######## ### ######################### # #### # # # ######## ###
######################### # # ######## # ### ########################o # # ######## # ### ::: o:2
######################### # ##### # # # # ######## ### ######################### # ##### # # # # ######## ###
######################### # # ### ######################### # # ###
######################### ####################### # ### ######################### ####################### # ###

View File

@@ -3,8 +3,6 @@ import { DefaultRendererOptions, FirstPersonRenderer } from "./ascii_first_perso
import { MiniMapRenderer } from "../ascii_minimap_renderer.js"; import { MiniMapRenderer } from "../ascii_minimap_renderer.js";
import { AsciiWindow } from "./ascii_window.js"; import { AsciiWindow } from "./ascii_window.js";
import { TileMap } from "./ascii_tile_map.js"; import { TileMap } from "./ascii_tile_map.js";
import eobWallUrl1 from "./eob1.png";
import gnollSpriteUrl from "./gnoll.png";
import { sprintf } from "sprintf-js"; import { sprintf } from "sprintf-js";
class Player { class Player {
@@ -109,7 +107,7 @@ class DungeonCrawler {
/** @type {FirstPersonRenderer} */ firstPersonRenderer: null, /** @type {FirstPersonRenderer} */ firstPersonRenderer: null,
/** @type {MiniMapRenderer} */ miniMapRenderer: null, /** @type {MiniMapRenderer} */ miniMapRenderer: null,
firstPersonWindow: new AsciiWindow(document.getElementById("viewport"), 100, 45), // MAGIC CONSTANTS firstPersonWindow: new AsciiWindow(document.getElementById("viewport"), 80, 45), // MAGIC CONSTANTS
minimapWindow: new AsciiWindow(document.getElementById("minimap"), 9, 9), // MAGIC CONSTANT minimapWindow: new AsciiWindow(document.getElementById("minimap"), 9, 9), // MAGIC CONSTANT
options: DefaultRendererOptions, options: DefaultRendererOptions,
@@ -163,11 +161,10 @@ class DungeonCrawler {
this.rendering.miniMapRenderer = new MiniMapRenderer(this.rendering.minimapWindow, this.map); this.rendering.miniMapRenderer = new MiniMapRenderer(this.rendering.minimapWindow, this.map);
const textureFilenames = [eobWallUrl1, gnollSpriteUrl];
this.rendering.firstPersonRenderer = new FirstPersonRenderer( this.rendering.firstPersonRenderer = new FirstPersonRenderer(
this.rendering.firstPersonWindow, this.rendering.firstPersonWindow,
this.map, this.map,
textureFilenames, ["./eobBlueWall.png", "gnoll.png"], // textures
this.rendering.options, this.rendering.options,
); );
this.rendering.firstPersonRenderer.onReady = () => { this.rendering.firstPersonRenderer.onReady = () => {
@@ -216,15 +213,15 @@ class DungeonCrawler {
// //
// We cant move into walls // We cant move into walls
if (this.map.isWall(targetV.x, targetV.y)) { if (!this.map.isTraversable(targetV.x, targetV.y)) {
console.info( console.info(
"bumped into wall at %s (mypos: %s), direction=%d", "bumped into an obstacle at %s (mypos: %s), direction=%d",
targetV, targetV,
this.player._posV, this.player._posV,
this.player.angle, this.player.angle,
); );
// this.delay += 250; // MAGIC NUMBER: Pause for a tenth of a second after hitting a wall this.delay += 250; // MAGIC NUMBER: Pause for a bit after hitting an obstacle
// return false; return false;
} }
this.animation = { this.animation = {

View File

@@ -1,20 +1,15 @@
import { TileMap, Tile } from "./ascii_tile_map.js"; import { TileMap } from "./ascii_tile_map.js";
import { Tile } from "./ascii_tile_types.js";
import { AsciiWindow } from "./ascii_window.js"; import { AsciiWindow } from "./ascii_window.js";
import * as THREE from "three"; import * as THREE from "three";
import eobWallUrl1 from "./eob1.png"; import { Vector3 } from "three";
import gnollSpriteUrl from "./gnoll.png";
export const DefaultRendererOptions = { export const DefaultRendererOptions = {
viewDistance: 5, viewDistance: 5,
fov: Math.PI / 3, // 60 degrees - good for spooky fov: 60, // degrees
wallChar: "#",
floorColor: 0x654321, floorColor: 0x654321,
floorChar: "f",
ceilingColor: 0x555555, ceilingColor: 0x555555,
ceilingChar: "c",
fadeOutColor: 0x555555,
}; };
export class FirstPersonRenderer { export class FirstPersonRenderer {
@@ -24,37 +19,64 @@ export class FirstPersonRenderer {
* @param {string[]} textureFilenames * @param {string[]} textureFilenames
*/ */
constructor(aWindow, map, textureFilenames, options) { constructor(aWindow, map, textureFilenames, options) {
const w = 600; this.map = map;
const h = 400; this.window = aWindow;
this.widthPx = aWindow.htmlElement.clientWidth;
this.heightPx = aWindow.htmlElement.clientHeight;
this.asciiWidth = aWindow.width;
this.asciiHeight = aWindow.height;
this.aaspect = this.widthPx / this.heightPx;
this.fov = options.fov ?? DefaultRendererOptions.fov; this.fov = options.fov ?? DefaultRendererOptions.fov;
this.viewDistance = options.viewDistance ?? DefaultRendererOptions.viewDistance; this.viewDistance = options.viewDistance ?? DefaultRendererOptions.viewDistance;
this.floorColor = options.floorColor ?? DefaultRendererOptions.floorColor;
this.window = aWindow; this.ceilingColor = options.ceilingColor ?? DefaultRendererOptions.ceilingColor;
this.map = map;
this.scene = new THREE.Scene(); this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera((this.fov * 180) / Math.PI, w / h); this.mainCamera = new THREE.PerspectiveCamera(this.fov, this.aspect, 0.1, this.viewDistance);
this.renderer = new THREE.WebGLRenderer({ antialias: false }); // Do not anti-alias, it could interfere with the conversion to ascii this.renderer = new THREE.WebGLRenderer({
antialias: false,
preserveDrawingBuffer: true,
}); // Do not anti-alias, it could interfere with the conversion to ascii
//
// Render buffer
//
this.bufferCanvas = document.createElement("canvas");
this.bufferCanvas.width = this.asciiWidth;
this.bufferCanvas.height = this.asciiHeight;
this.bufferContext = this.bufferCanvas.getContext("2d");
// //
// Fog, Fadeout & Background // Fog, Fadeout & Background
// //
this.scene.background = new THREE.Color(0); this.scene.background = new THREE.Color(0);
this.scene.fog = new THREE.Fog(0, 0, this.viewDistance - 1); this.scene.fog = new THREE.Fog(0, 0, this.viewDistance);
// //
// Camera // Camera
// //
this.camera.up.set(0, 0, 1); // Z-up instead of Y-up this.mainCamera.up.set(0, 0, 1); // Z-up instead of Y-up
// //
// Torch // Torch
// //
this.torch = new THREE.PointLight(0xffffff, 0.9, this.viewDistance, 2); // https://threejs.org/docs/#api/en/lights/PointLight this.torch = new THREE.PointLight(0xffffff, 2, this.viewDistance * 2, 1); // https://threejs.org/docs/#api/en/lights/PointLight
this.torch.position.copy(this.camera.position); this.torch.position.copy(this.mainCamera.position);
this.scene.add(this.torch); this.scene.add(this.torch);
this.textures = [];
for (const textureFile of textureFilenames) {
const tex = new THREE.TextureLoader().load(textureFile, (t) => {
t.magFilter = THREE.NearestFilter; // no smoothing when scaling up
t.minFilter = THREE.NearestFilter; // no mipmaps / no smoothing when scaling down
t.generateMipmaps = false; // dont build mipmaps
});
this.textures.push(tex);
}
// //
// Sprites // Sprites
// //
@@ -65,8 +87,7 @@ export class FirstPersonRenderer {
this.initMap(); this.initMap();
// //
this.renderer.setSize(w, h); this.renderer.setSize(this.asciiWidth * 1, this.asciiHeight * 1);
document.getElementById("threejs").appendChild(this.renderer.domElement);
this.renderFrame(); this.renderFrame();
} }
@@ -74,15 +95,19 @@ export class FirstPersonRenderer {
const wallPlanes = []; const wallPlanes = [];
const sprites = []; const sprites = [];
//
// -------------
// PARSE THE MAP
// -------------
/** @type {Map<number,Array} */ /** @type {Map<number,Array} */
this.map.forEach((/** @type {Tile} */ tile, /** @type {number} */ x, /** @type {number} y */ y) => { this.map.forEach((/** @type {Tile} */ tile, /** @type {number} */ x, /** @type {number} y */ y) => {
// //
if (tile.isStartLocation) { if (tile.isStartLocation) {
this.camera.position.set(x, y, 0); this.mainCamera.position.set(x, y, 0);
this.camera.lookAt(x, y - 1, 0); this.mainCamera.lookAt(x, y - 1, 0);
this.torch.position.copy(this.camera.position); this.torch.position.copy(this.mainCamera.position);
console.log("Initial Camera Position:", this.camera.position); console.log("Initial Camera Position:", this.mainCamera.position);
return; return;
} }
@@ -102,7 +127,7 @@ export class FirstPersonRenderer {
return; return;
} }
if (tile.isSprite) { if (tile.isEncounter) {
console.log("Sprite", tile); console.log("Sprite", tile);
sprites.push([x, y, tile.textureId]); sprites.push([x, y, tile.textureId]);
return; return;
@@ -112,41 +137,48 @@ export class FirstPersonRenderer {
}); });
// //
// Floor (XY plane at Z = -.5) // ---------------------------
// // FLOOR (XY PLANE AT Z = -.5)
// ---------------------------
const floorGeo = new THREE.PlaneGeometry(this.map.width, this.map.height); const floorGeo = new THREE.PlaneGeometry(this.map.width, this.map.height);
const floorMat = new THREE.MeshStandardMaterial({ color: 0x964b00 /* side: THREE.DoubleSide */ }); const floorMat = new THREE.MeshStandardMaterial({
color: this.floorColor /* side: THREE.DoubleSide */,
});
const floor = new THREE.Mesh(floorGeo, floorMat); const floor = new THREE.Mesh(floorGeo, floorMat);
floor.position.set(this.map.width / 2, this.map.height / 2, -0.5); floor.position.set(this.map.width / 2, this.map.height / 2, -0.5);
this.scene.add(floor); this.scene.add(floor);
// //
// Ceiling (XY plane at Z = .5) // -----------------------------
// // CEILING (XY PLANE AT Z = .5)
// -----------------------------
const ceilingGeo = new THREE.PlaneGeometry(this.map.width, this.map.height); const ceilingGeo = new THREE.PlaneGeometry(this.map.width, this.map.height);
const ceilingMat = new THREE.MeshStandardMaterial({ color: 0x333333, side: THREE.BackSide }); const ceilingMat = new THREE.MeshStandardMaterial({
color: this.ceilingColor,
side: THREE.BackSide,
});
const ceiling = new THREE.Mesh(ceilingGeo, ceilingMat); const ceiling = new THREE.Mesh(ceilingGeo, ceilingMat);
ceiling.position.set(this.map.width / 2, this.map.height / 2, 0.5); ceiling.position.set(this.map.width / 2, this.map.height / 2, 0.5);
this.scene.add(ceiling); this.scene.add(ceiling);
// //
// Walls // ------
// // WALLS
const wallTex = new THREE.TextureLoader().load(eobWallUrl1, (texture) => { // ------
texture.magFilter = THREE.NearestFilter; // no smoothing when scaling up
texture.minFilter = THREE.NearestFilter; // no mipmaps / no smoothing when scaling down
texture.generateMipmaps = false; // dont build mipmaps
});
const wallGeo = new THREE.PlaneGeometry(); const wallGeo = new THREE.PlaneGeometry();
wallGeo.rotateX(Math.PI / 2); // Get the geometry-plane the right way up (z-up) wallGeo.rotateX(Math.PI / 2); // Get the geometry-plane the right way up (z-up)
// wallGeo.rotateY(Math.PI); // rotate textures to be the right way up wallGeo.rotateY(Math.PI); // rotate textures to be the right way up
const instancedMesh = new THREE.InstancedMesh( const instancedMesh = new THREE.InstancedMesh(
wallGeo, wallGeo,
new THREE.MeshStandardMaterial({ map: wallTex }), new THREE.MeshStandardMaterial({ map: this.textures[0] }),
wallPlanes.length, wallPlanes.length,
); );
instancedMesh.userData.pastelMaterial = new THREE.MeshBasicMaterial({
color: 0xffffff,
});
instancedMesh.userData.parimaryMaterial = instancedMesh.material;
this.scene.add(instancedMesh); this.scene.add(instancedMesh);
// Temp objects for generating matrices // Temp objects for generating matrices
@@ -162,29 +194,19 @@ export class FirstPersonRenderer {
instancedMesh.instanceMatrix.needsUpdate = true; instancedMesh.instanceMatrix.needsUpdate = true;
// //
// Sprites // -------
// SPRITES
// -------
// //
// Load a sprite texture
const tex = new THREE.TextureLoader().load(gnollSpriteUrl, (t) => {
t.magFilter = THREE.NearestFilter; // pixel-art crisp
t.minFilter = THREE.NearestFilter;
t.generateMipmaps = false;
t.wrapS = THREE.RepeatWrapping;
t.wrapT = THREE.RepeatWrapping;
t.repeat.set(1, 1);
});
const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true });
for (const [x, y, textureId] of sprites) { for (const [x, y, textureId] of sprites) {
// TODO: only one material per sprite type
const spriteMat = new THREE.SpriteMaterial({
map: this.textures[textureId],
transparent: true,
});
const sprite = new THREE.Sprite(spriteMat); const sprite = new THREE.Sprite(spriteMat);
sprite.position.set(
x,
y,
0, // z (stand on floor)
);
sprite.position.set(x, y, 0); sprite.position.set(x, y, 0);
sprite.userData.mapLocation = new Vector3(x, y, 0); // The location (in tilemap coordinates) of this sprite
this.sprites.push(sprite); this.sprites.push(sprite);
this.scene.add(sprite); this.scene.add(sprite);
console.log({ x, y, textureId }); console.log({ x, y, textureId });
@@ -192,23 +214,115 @@ export class FirstPersonRenderer {
} }
renderFrame(posX, posY, dirAngle, commit = true) { renderFrame(posX, posY, dirAngle, commit = true) {
this.renderer.render(this.scene, this.camera); //
const lookAtV = new THREE.Vector3(1, 0, 0); const posV = new Vector3(posX, posY, 0);
lookAtV
.applyAxisAngle(new THREE.Vector3(0, 0, 1), dirAngle)
.normalize()
.add(this.camera.position);
this.camera.position.x = posX; //
this.camera.position.y = posY; // -------------------------------
// Camera Position and Orientation
// -------------------------------
//
// Direction we're looking
const lookDirV = new Vector3(1, 0, 0)
.applyAxisAngle(new Vector3(0, 0, 1), dirAngle)
.setZ(0)
.normalize();
this.torch.position.copy(this.camera.position); //
this.torch.position.z += 0.25; // The Point we're looking at.
this.camera.lookAt(lookAtV); //
const lookAtV = lookDirV.clone().add(posV);
lookAtV.z = 0;
this.mainCamera.position.copy(posV); // Move the camera
this.mainCamera.lookAt(lookAtV); // Rotate the camera
// -----
// TORCH
// -----
//
// The torch should hover right above the camera
this.torch.position.set(posV.x, posV.y, posV.z + 0.25);
// -------
// SPRITES
// -------
//
this.sprites.forEach((sprite) => {
//
// The tilemap position (vector) of the sprite
/** @type {Vector3} */
const spriteCenterV = sprite.userData.mapLocation;
//
// Direction from sprite to camera
const dir = new Vector3().subVectors(spriteCenterV, posV);
const len = dir.length();
//
if (len > this.viewDistance) {
// Sprite is out of range, do nothing
return;
}
if (Math.abs(dir.x) > 1e-6 && Math.abs(dir.y) > 1e-6) {
// Sprite is not in a direct cardinal line to us, do nothing
return;
}
sprite.position.copy(spriteCenterV).addScaledVector(lookDirV, -0.5);
});
performance.mark("scene_render_start");
this.renderer.render(this.scene, this.mainCamera);
performance.mark("scene_render_end");
performance.measure("3D Scene Rendering", "scene_render_start", "scene_render_end");
//
//
// ----------------
// ASCII Conversion
// ----------------
//
performance.mark("asciification_start");
const gl = this.renderer.getContext();
const width = this.renderer.domElement.width;
const height = this.renderer.domElement.height;
const pixels = new Uint8Array(width * height * 4);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let idx = 0;
for (let y = height - 1; y >= 0; y--) {
for (let x = 0; x < width; x++) {
const r = pixels[idx];
const g = pixels[idx + 1];
const b = pixels[idx + 2];
const cssColor =
"#" + //
r.toString(16).padStart(2, "0") +
g.toString(16).padStart(2, "0") +
b.toString(16).padStart(2, "0");
this.window.put(x, y, "#", cssColor);
idx += 4;
}
}
performance.mark("asciification_end");
performance.measure(
"Asciification", // The name for our measurement
"asciification_start", // The starting mark
"asciification_end", // The ending mark
);
// //
if (commit) { if (commit) {
performance.mark("dom_commit_start");
this.window.commitToDOM(); this.window.commitToDOM();
performance.mark("dom_commit_end");
performance.measure("DOM Commit", "dom_commit_start", "dom_commit_end");
} }
} }
} }

View File

@@ -1,129 +1,40 @@
import { FunctionCallParser } from "../utils/callParser.js";
import { Vector2i, Orientation } from "./ascii_types.js"; import { Vector2i, Orientation } from "./ascii_types.js";
import { AsciiWindow } from "./ascii_window.js"; import { AsciiWindow } from "./ascii_window.js";
export class Tile {
/** @type {string} How should this tile be rendered on the minimap.*/
minimapChar = " ";
/** @type {string} How should this tile be rendered on the minimap.*/
minimapColor = "#fff";
/** @type {boolean} Should this be rendered as a wall? */
isWall = false;
/** @type {boolean} is this tile occupied by a sprite? */
isSprite = false;
/** @type {boolean} Can the player walk here? */
traversable = true;
/** @type {boolean} Is this where they player starts? */
isStartLocation = false;
/** @type {boolean} Is this where they player starts? */
textureId = 0;
/** @type {Tile} options */
constructor(options) {
for (let [k, v] of Object.entries(options)) {
if (this[k] !== undefined) {
this[k] = v;
}
}
}
get collision() {
return this.isWall || this.isSprite;
}
}
export const defaultLegend = Object.freeze({
//
// "" is the Unknown Tile - if we encounter a tile that we don't know how to parse,
// the it will be noted here as the empty string
"": new Tile({
minimapChar: " ",
traversable: true,
isWall: false,
}),
//
// default floor
" ": new Tile({
minimapChar: " ",
traversable: true,
isWall: false,
}),
//
// Default wall
"#": new Tile({
minimapChar: "#",
traversable: false,
isWall: true,
textureId: 0,
}),
"M": new Tile({
textureId: 1,
minimapChar: "M",
minimapColor: "#f00",
traversable: false,
isWall: false,
isSprite: true,
}),
//
//secret door (looks like wall, but is traversable)
"Ω": new Tile({
minimapChar: "#",
traversable: true,
isWall: true,
}),
//
// where the player starts
"S": new Tile({
minimapChar: "S", // "Š",
traversable: true,
isWall: false,
isStartLocation: true,
}),
});
export class TileMap { export class TileMap {
/** /**
* @param {string} str * @param {string} str
* @param {Record<string,Tile} legend * @param {Record<string,Tile} legend
*/ */
static fromText(str, legend = defaultLegend) { static fromText(str) {
const lines = str.split("\n"); const lines = str.split("\n");
const tiles = [];
const options = [];
const optionsParser = new FunctionCallParser();
const longestLine = lines.reduce((acc, line) => Math.max(acc, line.length), 0); let mapWidth;
const tiles = new Array(lines.length).fill().map(() => Array(longestLine));
lines.forEach((line, y) => { lines.forEach((line, y) => {
line = line.padEnd(longestLine, "#"); tiles[y] = [];
options[y] = [];
line.split("").forEach((char, x) => { // Everything before ":::" is map tiles, and everything after is options for the tiles on that line
let tile = legend[char]; let [tileStr, optionStr] = line.split(/\s*:::\s*/);
// unknown char? // Infer the width of the map from the first line
// check fallback tile. if (!mapWidth) {
if (tile === undefined) { mapWidth = tileStr.length;
tile = legend[""]; }
}
// still no tile - i.e. no back fallback tile? optionStr = optionStr.split(/\s*\/\//)[0];
if (tile === undefined) { options[y] = optionStr ? optionsParser.parse(optionStr) : [];
throw new Error("Dont know how to handle this character: " + char);
}
// insert tile into map. // STFU Linda
tiles[y][x] = tile; console.log(tileStr, optionStr, y);
});
}); });
return new TileMap(longestLine, lines.length, tiles); // return new TileMap(longestLine, lines.length, tiles, options);
} }
tileIdx(x, y) { tileIdx(x, y) {
@@ -184,9 +95,25 @@ export class TileMap {
return true; return true;
} }
if (!this.tiles[y][x]) {
x++;
return true;
}
return this.tiles[y][x].isWall; return this.tiles[y][x].isWall;
} }
isTraversable(x, y) {
x |= 0;
y |= 0;
if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
return true;
}
return this.tiles[y][x].isTraversable;
}
findFirst(criteria) { findFirst(criteria) {
return this.forEach((tile, x, y) => { return this.forEach((tile, x, y) => {
for (let k in criteria) { for (let k in criteria) {

147
frontend/ascii_tile_types.js Executable file
View File

@@ -0,0 +1,147 @@
import { Orientation } from "./ascii_types";
export class Tile {
/** @type {string} How should this tile be rendered on the minimap.*/
minimapChar;
/** @type {string} How should this tile be rendered on the minimap.*/
minimapColor;
/** @type {boolean} Should this be rendered as a wall? */
isWall;
/** @type {boolean} Can the player walk here? */
isTraversable;
/** @type {boolean} is this tile occupied by an encounter? */
isEncounter;
/** @type {boolean} Is this where they player starts? */
isStartLocation;
/** @type {boolean} Is this a two-way portal entry/exit */
isTwoWayPortal;
/** @type {boolean} Is this a one-way portal entry */
isOneWayPortalEntry;
/** @type {boolean} Is this a one-way portal exit */
isOneWayPortalExit;
/** @type {boolean} Has the secret properties of this tile been uncovered? */
isUncovered;
/** @type {string|number} The portals "channel" - each tile in a portal pair must have the same channel */
channel;
/** @type {number|string} id of texture to use */
textureId;
/** @type {number|string} id the encounter located on this tile */
encounterId;
/** @type {boolean} Can/does this tile wander around on empty tiles? */
isWandering;
/** @type {Orientation} */
orientation;
/** @param {Tile} options */
constructor(options) {
for (let [k, v] of Object.entries(options)) {
if (this[k] !== undefined) {
this[k] = v;
}
}
}
/** @param {Tile} options */
static fromChar(char, options = {}) {
switch (char) {
case " ":
return new FloorTile();
case "#":
return new WallTile();
case "P":
return new PlayerStartTile(options.orientation);
case "E":
return new EncounterTile(options.textureId, options.encounterId);
case "O":
return new SecretOneWayPortalEntryTile(options.channel);
case "o":
return new SecretOneWayPortalExitTile(options.channel);
case "Z":
return new SecretTwoWayPortalTile(options.channel);
default:
throw new Error("Unknown character: " + char);
}
}
clone() {
return new this.constructor(this);
}
}
export class FloorTile extends Tile {
isTraversable = true;
minimapChar = " ";
internalMapChar = " ";
}
export class PlayerStartTile extends Tile {
isTraversable = true;
isStartLocation = true;
MinimapChar = "▤"; // stairs
orientation = Orientation.NORTH;
/** @param {Orientation} orientation */
constructor(orientation) {
super({ orientation });
}
}
export class WallTile extends Tile {
textureId = 0;
isTraversable = false;
isWall = true;
minimapChar = "#";
internalMapChar = "#";
}
export class EncounterTile extends Tile {
isEncounter = true;
constructor(textureId, encounterId) {
super({ textureId, encounterId });
}
}
/**
* One-way portal entries look exactly like walls. You need to
* probe for them, or otherwise unlock their location.
* You can walk into them, and then the magic happens
*/
export class SecretOneWayPortalEntryTile extends Tile {
textureId = 0;
isWall = true;
isTraversable = true; // we can walk in to it?
isOneWayPortalEntry = true;
internalMapChar = "O";
minimapChar = "#"; // Change char when the portal has been uncovered
isUncovered = false;
constructor(channel) {
super({ channel });
}
}
export class SecretOneWayPortalExitTile extends Tile {
isTraversable = true;
isOneWayPortalExit = true;
internalMapChar = "o";
minimapChar = " "; // Change char when the portal has been uncovered
isUncovered = false;
constructor(channel) {
super({ channel });
}
}
export class SecretTwoWayPortalTile extends Tile {
textureId = 0;
isWall = true;
isTraversable = true;
isTwoWayPortalEntry = true;
internalMapChar = "0";
minimapChar = "#"; // Change char when the portal has been uncovered
isUncovered = false;
constructor(channel) {
super({ channel });
}
}

View File

@@ -2,8 +2,7 @@ export const PI_OVER_TWO = Math.PI / 2;
/** /**
* Enum Cardinal Direction (east north west south) * Enum Cardinal Direction (east north west south)
* @constant * @constant @readonly @enum {number}
* @readonly
*/ */
export const Orientation = { export const Orientation = {
/** @constant @readonly @type {number} Going east increases X */ /** @constant @readonly @type {number} Going east increases X */
@@ -18,7 +17,7 @@ export const Orientation = {
/** /**
* Enum Relative Direction (forward, left, right, backwards) * Enum Relative Direction (forward, left, right, backwards)
* @readonly * @constant @readonly @enum {number}
*/ */
export const RelativeMovement = { export const RelativeMovement = {
FORWARD: 0, FORWARD: 0,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

0
frontend/eob1.png → frontend/eobBlueWall.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

BIN
frontend/eobRedWall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -2,28 +2,46 @@
"hash": "5eac6a41", "hash": "5eac6a41",
"configHash": "86a557ed", "configHash": "86a557ed",
"lockfileHash": "3ceab950", "lockfileHash": "3ceab950",
"browserHash": "20105502", "browserHash": "1d3df51c",
"optimized": { "optimized": {
"sprintf-js": { "sprintf-js": {
"src": "../../sprintf-js/src/sprintf.js", "src": "../../sprintf-js/src/sprintf.js",
"file": "sprintf-js.js", "file": "sprintf-js.js",
"fileHash": "089e5f0c", "fileHash": "cfe4c24f",
"needsInterop": true "needsInterop": true
}, },
"three": { "three": {
"src": "../../three/build/three.module.js", "src": "../../three/build/three.module.js",
"file": "three.js", "file": "three.js",
"fileHash": "22510eaa", "fileHash": "7e792144",
"needsInterop": false "needsInterop": false
}, },
"three/src/math/MathUtils.js": { "three/src/math/MathUtils.js": {
"src": "../../three/src/math/MathUtils.js", "src": "../../three/src/math/MathUtils.js",
"file": "three_src_math_MathUtils__js.js", "file": "three_src_math_MathUtils__js.js",
"fileHash": "f611651c", "fileHash": "6afcef1b",
"needsInterop": false
},
"three/tsl": {
"src": "../../three/build/three.tsl.js",
"file": "three_tsl.js",
"fileHash": "40fd901e",
"needsInterop": false
},
"three/webgpu": {
"src": "../../three/build/three.webgpu.js",
"file": "three_webgpu.js",
"fileHash": "0d6a1d7c",
"needsInterop": false "needsInterop": false
} }
}, },
"chunks": { "chunks": {
"chunk-5FFPRNLG": {
"file": "chunk-5FFPRNLG.js"
},
"chunk-GHUIN7QU": {
"file": "chunk-GHUIN7QU.js"
},
"chunk-BUSYA2B4": { "chunk-BUSYA2B4": {
"file": "chunk-BUSYA2B4.js" "file": "chunk-BUSYA2B4.js"
} }

0
test.js Executable file
View File

97
utils/callParser.js Executable file
View File

@@ -0,0 +1,97 @@
export class FunctionCallParser {
/**
* @typedef {{name: string, args: Array<number|string|boolean|null>}} CallType
*/
/**
*
* @param {string} input
*
* @returns {CallType[]}
*/
parse(input) {
const calls = [];
const pattern = /(\w+)\s*\(([^)]*)\)/g;
let match;
while ((match = pattern.exec(input)) !== null) {
const name = match[1];
const argsStr = match[2].trim();
const args = this.parseArguments(argsStr);
calls.push({ name, args });
}
return calls;
}
/** @protected */
parseArguments(argsStr) {
if (!argsStr) return [];
const args = [];
const tokens = this.tokenize(argsStr);
for (const token of tokens) {
args.push(this.parseValue(token));
}
return args;
}
/** @protected */
tokenize(argsStr) {
const tokens = [];
let current = "";
let depth = 0;
for (let i = 0; i < argsStr.length; i++) {
const char = argsStr[i];
if (char === "(" || char === "[" || char === "{") {
depth++;
current += char;
} else if (char === ")" || char === "]" || char === "}") {
depth--;
current += char;
} else if (char === "," && depth === 0) {
if (current.trim()) {
tokens.push(current.trim());
}
current = "";
} else {
current += char;
}
}
if (current.trim()) {
tokens.push(current.trim());
}
return tokens;
}
/** @protected */
parseValue(str) {
str = str.trim();
// Try to parse as number
if (/^-?\d+(\.\d+)?$/.test(str)) {
return parseFloat(str);
}
// Boolean
if (str === "true") return true;
if (str === "false") return false;
// Null/undefined
if (str === "null") return null;
// Otherwise treat as string (remove quotes if present)
if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) {
return str.slice(1, -1);
}
return str;
}
}