diff --git a/ascii_minimap_renderer.js b/ascii_minimap_renderer.js
deleted file mode 100755
index 21583c7..0000000
--- a/ascii_minimap_renderer.js
+++ /dev/null
@@ -1,62 +0,0 @@
-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).minimapChar, this.fg, this.bg);
- }
- }
- this.window.put(this.distance, this.distance, "@", "#44F");
- this.window.commitToDOM();
- }
-}
-
-if (Math.PI < 0 && AsciiWindow && TileMap && Vector2i) {
- ("STFU Linda");
-}
diff --git a/frontend/WfcImage.1.js b/frontend/WfcImage.1.js
deleted file mode 100644
index e69de29..0000000
diff --git a/frontend/ascii_dungeon_crawler.html b/frontend/ascii_dungeon_crawler.html
index d85b4ae..27f04f9 100755
--- a/frontend/ascii_dungeon_crawler.html
+++ b/frontend/ascii_dungeon_crawler.html
@@ -35,7 +35,7 @@
display: inline-block;
padding: 2px;
border: 5px solid #666;
- color: #666;
+ /* color: #666; */
background-color: black;
}
@@ -73,7 +73,7 @@
-
orientation
+
orientation
@@ -85,13 +85,13 @@
############################################################
############################################################
## ################# ########################
-## # ### ################# # ## ########################
-## #P# Z###############Z # ## ################ ::: P(north) Z(1) Z(1) ;; Comments
-## # # # ################# # ## #### ####
-## E # # ## # #### # # #### ::: E(Gnolls)
-###### #################### ## #### # ####
-###### #################### # ## # # #### ####
-######E#################### # #### ::: E(Goblins) These are comments
+## # ################# # ## ########################
+## #P Z###############Z # ## ################ ::: P(north) Z(channel_1) Z(channel_1) // Comments
+## # # ################# # ## #### ####
+### #E # # ## # #### # # #### ::: E(gnoll)
+#### ################## ## #### # ####
+##### ################### # ## # # #### ####
+######E#################### # #### ::: E(Goblins, gnoll) // These are comments
###### #################### ########## #### ####
###### #################### ########## # # #### # # ####
###### #################### ########## #### # # ####
@@ -99,12 +99,12 @@
###### #################### ############################
###### #################### # ############################
###### #################### # ############################
-######E#################### # ############################ ::: E(Minotaur)
-###### ## ##### ## ############################
+######E#################### # ############################ ::: E(gnoll)
+###### ## ##### ## ############################ :::
###### ## Z#### ## # # ############################ ::: Z(2) // Channel 2
###### ## ####Z ## ######## ############ ::: Z(2) // Channel 2
###### ## ## # ########### ## ######## ############
-######E## # #E ############ ::: E(Dwarf) ; E(Gelatinous_Cube)
+######E## # #E ############ ::: E(Dwarves, gnoll) ; E(Gelatinous_Cube, gnoll)
###### # # # ############
######### # ## ########### # ######### # ############
######### # # ########### # ######### # # ############
@@ -115,7 +115,7 @@
########################### # ######### #### ###############
########################### # #### ###############
######################### # #### # # # ######## ###
-########################o # # ######## # ### ::: o:2
+########################o # # ######## # ### ::: o(2)
######################### # ##### # # # # ######## ###
######################### # # ###
######################### ####################### # ###
diff --git a/frontend/ascii_dungeon_crawler.js b/frontend/ascii_dungeon_crawler.js
index e4827c6..45a1c5e 100755
--- a/frontend/ascii_dungeon_crawler.js
+++ b/frontend/ascii_dungeon_crawler.js
@@ -1,6 +1,6 @@
import { Vector2i, Orientation, RelativeMovement, PI_OVER_TWO } from "./ascii_types.js";
import { DefaultRendererOptions, FirstPersonRenderer } from "./ascii_first_person_renderer.js";
-import { MiniMapRenderer } from "../ascii_minimap_renderer.js";
+import { MiniMap } from "./ascii_minimap.js";
import { AsciiWindow } from "./ascii_window.js";
import { TileMap } from "./ascii_tile_map.js";
import { sprintf } from "sprintf-js";
@@ -105,15 +105,14 @@ class DungeonCrawler {
/** @readonly */
this.rendering = {
/** @type {FirstPersonRenderer} */ firstPersonRenderer: null,
- /** @type {MiniMapRenderer} */ miniMapRenderer: null,
+ /** @type {MiniMap} */ miniMapRenderer: null,
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"), 15, 15), // MAGIC CONSTANT
options: DefaultRendererOptions,
};
- /** @readonly @type {MiniMapRenderer} */
this.player = new Player();
this.setupControls();
@@ -151,30 +150,33 @@ class DungeonCrawler {
loadMap() {
const mapString = document.getElementById("mapText").value;
- this.map = TileMap.fromText(mapString);
+ this.map = TileMap.fromHumanText(mapString);
- this.player._posV = this.map.findFirst({ isStartLocation: true });
+ this.player._posV = this.map.findFirstV({ isStartLocation: true });
if (!this.player._posV) {
throw new Error("Could not find a start location for the player");
}
- this.rendering.miniMapRenderer = new MiniMapRenderer(this.rendering.minimapWindow, this.map);
+ this.rendering.miniMapRenderer = new MiniMap(
+ this.rendering.minimapWindow,
+ this.map,
+ this.rendering.options.viewDistance,
+ );
this.rendering.firstPersonRenderer = new FirstPersonRenderer(
this.rendering.firstPersonWindow,
this.map,
- ["./eobBlueWall.png", "gnoll.png"], // textures
this.rendering.options,
);
this.rendering.firstPersonRenderer.onReady = () => {
this.render();
this.renderMinimap();
- this.renderCompass();
+ this.renderStatus();
};
}
- startTurnAnimation(quarterTurns = 1) {
+ startRotationAnimation(quarterTurns = 1) {
if (this.isAnimating) {
throw new Error("Cannot start an animation while one is already running");
}
@@ -189,14 +191,14 @@ class DungeonCrawler {
startX: this.player.x,
startY: this.player.y,
- targetAngle: this.player.angle + PI_OVER_TWO * quarterTurns,
+ targetAngle: this.player.angle - PI_OVER_TWO * quarterTurns,
targetTime: performance.now() + 700, // MAGIC NUMBER: these animations take .7 seconds
targetX: this.player.x,
targetY: this.player.y,
};
//
- this.player._directionV.rotateCCW(quarterTurns);
+ this.player._directionV.rotateCW(quarterTurns);
}
/** @type {RelativeMovement} Direction the player is going to move */
@@ -214,8 +216,27 @@ class DungeonCrawler {
//
// We cant move into walls
if (!this.map.isTraversable(targetV.x, targetV.y)) {
+ const tile = this.map.get(targetV.x, targetV.y);
+
+ // _____ ___ ____ ___
+ // |_ _/ _ \| _ \ / _ \ _
+ // | || | | | | | | | | (_)
+ // | || |_| | |_| | |_| |_
+ // |_| \___/|____/ \___/(_)
+ // --------------------------
+ //
+ // Handle "Bumps"
+ // Bumping into an encounter engages the enemy (requires confirmation, unless disabled)
+ // Bumping into a wall you're looking at will inspect the wall, revealing hidden passages, etc.
+ // Bumping into a door will open/remove it.
+ // Bumping into stairs will go down/up (requires confirmation, unless disabled)
+ // Bumping into a wall sconce will pick up the torch (losing the light on the wall, but gaining a torch that lasts for X turns)
+ // Bumping into a trap activates it.
+ // Bumping into a treasure opens it.
+
console.info(
- "bumped into an obstacle at %s (mypos: %s), direction=%d",
+ "bumped into %s at %s (mypos: %s), direction=%d",
+ tile.constructor.name,
targetV,
this.player._posV,
this.player.angle,
@@ -250,10 +271,10 @@ class DungeonCrawler {
KeyW: () => this.startMoveAnimation(RelativeMovement.FORWARD),
ArrowUp: () => this.startMoveAnimation(RelativeMovement.FORWARD),
ArrowDown: () => this.startMoveAnimation(RelativeMovement.BACKWARD),
- ArrowLeft: () => this.startTurnAnimation(1),
- ArrowRight: () => this.startTurnAnimation(-1),
- KeyQ: () => this.startTurnAnimation(1),
- KeyE: () => this.startTurnAnimation(-1),
+ ArrowLeft: () => this.startRotationAnimation(-1),
+ ArrowRight: () => this.startRotationAnimation(1),
+ KeyQ: () => this.startRotationAnimation(-1),
+ KeyE: () => this.startRotationAnimation(1),
};
this.keys.names = Object.keys(this.keys.handlers);
@@ -304,7 +325,7 @@ class DungeonCrawler {
if (this.animation.targetTime <= performance.now()) {
this.render(this.player.x, this.player.y, this.player.angle);
this.renderMinimap();
- this.renderCompass();
+ this.renderStatus();
this.animation = {};
return false;
}
@@ -370,14 +391,25 @@ class DungeonCrawler {
setTimeout(() => this.gameLoop(), 50); // MAGIC NUMBER
}
- renderCompass() {
+ renderStatus() {
//
//
// Update the compass
- document.getElementById("compass").innerHTML = sprintf(
- "
%s
%s
",
- this.player._posV,
- Object.keys(Orientation)[this.player.orientation].toLowerCase(),
+ document.getElementById("status").innerHTML = sprintf(
+ [
+ "
",
+ sprintf("You are in %s,", "[A HALLWAY?]"), // a hallway, an intersection, a cul-de-sac
+ sprintf("facing %s", Object.keys(Orientation)[this.player.orientation]),
+ sprintf("on map location %s", this.player._posV),
+ "
",
+ "
",
+ // ONLY RELEVANT IF Tile in front of player is non-empty
+ sprintf("Directly in front of you is", "TODO: a wall|a set of stairs going down|an enemy"),
+ "
",
+ "
",
+ sprintf("Ahead of you is %s", "TODO: more hallway | an enemy | etc"),
+ "
",
+ ].join(" "),
);
}
}
diff --git a/frontend/ascii_first_person_renderer.js b/frontend/ascii_first_person_renderer.js
index 97c3530..50d3580 100755
--- a/frontend/ascii_first_person_renderer.js
+++ b/frontend/ascii_first_person_renderer.js
@@ -1,52 +1,55 @@
import { TileMap } from "./ascii_tile_map.js";
-import { Tile } from "./ascii_tile_types.js";
+import { PlayerStartTile, Tile } from "./ascii_tile_types.js";
import { AsciiWindow } from "./ascii_window.js";
import * as THREE from "three";
import { Vector3 } from "three";
export const DefaultRendererOptions = {
- viewDistance: 5,
- fov: 60, // degrees
+ viewDistance: 5, // number of tiles we can see ahead
+ fov: 70, // degrees
floorColor: 0x654321,
ceilingColor: 0x555555,
+
+ commitToDOM: true,
};
export class FirstPersonRenderer {
/**
* @param {AsciiWindow} aWindow the window we render onto.
* @param {TileMap} map
- * @param {string[]} textureFilenames
*/
- constructor(aWindow, map, textureFilenames, options) {
+ constructor(aWindow, map, options) {
this.map = map;
this.window = aWindow;
+ //
+ // Window geometry
+ //
this.widthPx = aWindow.htmlElement.clientWidth;
this.heightPx = aWindow.htmlElement.clientHeight;
this.asciiWidth = aWindow.width;
this.asciiHeight = aWindow.height;
this.aaspect = this.widthPx / this.heightPx;
+ //
+ // Rendering options
+ //
this.fov = options.fov ?? DefaultRendererOptions.fov;
this.viewDistance = options.viewDistance ?? DefaultRendererOptions.viewDistance;
this.floorColor = options.floorColor ?? DefaultRendererOptions.floorColor;
this.ceilingColor = options.ceilingColor ?? DefaultRendererOptions.ceilingColor;
+ this.commitToDOM = options.commitToDOM ?? DefaultRendererOptions.commitToDOM;
+ //
+ // THREE variables
+ //
this.scene = new THREE.Scene();
this.mainCamera = new THREE.PerspectiveCamera(this.fov, this.aspect, 0.1, this.viewDistance);
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");
+ antialias: false, // Do not AA - it ruins asciification
+ preserveDrawingBuffer: true, // Preserve the rendering buffer so we can access it during asciification
+ });
//
// Fog, Fadeout & Background
@@ -66,22 +69,15 @@ export class FirstPersonRenderer {
this.torch.position.copy(this.mainCamera.position);
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; // don’t build mipmaps
- });
- this.textures.push(tex);
- }
-
//
- // Sprites
+ // Caches
//
- /** @type {THREE.Sprite[]} */
- this.sprites = [];
+ /** @type {Map
{
+ t.magFilter = THREE.NearestFilter; // no smoothing when scaling up
+ t.minFilter = THREE.NearestFilter; // no mipmaps / no smoothing when scaling down
+ t.generateMipmaps = false; // don’t build mipmaps
+ });
+ this.textures.set(textureId, texture);
+ }
+
+ if (!texture) {
+ console.warn(" texture could not be loaded", { textureId, texture });
+ }
+
+ return texture;
+ }
+
+ getSpriteMaterial(textureId) {
+ console.debug("fetching material", { textureId });
+
+ let material = this.spriteMaterials.get(textureId);
+
+ if (!material) {
+ console.log("Creating material", { textureId });
+ material = new THREE.SpriteMaterial({
+ map: this.getTexture(textureId),
+ transparent: true,
+ });
+
+ this.spriteMaterials.set(textureId, material);
+ }
+
+ return material;
+ }
+
initMap() {
const wallPlanes = [];
- const sprites = [];
+ const roamers = [];
//
// -------------
@@ -101,8 +135,14 @@ export class FirstPersonRenderer {
// -------------
/** @type {Map {
+ tile.textureId !== null && tile.textureId !== undefined && this.getTexture(tile.textureId);
+
//
- if (tile.isStartLocation) {
+ if (tile instanceof PlayerStartTile) {
+ //
+ // This is temporary - the one that calls render() will determine the camera's
+ // position and orientation
+ //
this.mainCamera.position.set(x, y, 0);
this.mainCamera.lookAt(x, y - 1, 0);
this.torch.position.copy(this.mainCamera.position);
@@ -111,25 +151,24 @@ export class FirstPersonRenderer {
return;
}
- if (tile.isWall) {
- if (!this.map.isWall(x, y + 1)) {
+ if (tile.looksLikeWall) {
+ if (!this.map.looksLikeWall(x, y + 1)) {
wallPlanes.push([x, y + 0.5, Math.PI * 0.0]);
}
- if (!this.map.isWall(x + 1, y)) {
+ if (!this.map.looksLikeWall(x + 1, y)) {
wallPlanes.push([x + 0.5, y, Math.PI * 0.5]);
}
- if (!this.map.isWall(x, y - 1)) {
+ if (!this.map.looksLikeWall(x, y - 1)) {
wallPlanes.push([x, y - 0.5, Math.PI * 1.0]);
}
- if (!this.map.isWall(x - 1, y)) {
+ if (!this.map.looksLikeWall(x - 1, y)) {
wallPlanes.push([x - 0.5, y, Math.PI * 1.5]);
}
return;
}
- if (tile.isEncounter) {
- console.log("Sprite", tile);
- sprites.push([x, y, tile.textureId]);
+ if (tile.isRoaming) {
+ roamers.push([x, y, tile]);
return;
}
@@ -168,10 +207,13 @@ export class FirstPersonRenderer {
const wallGeo = new THREE.PlaneGeometry();
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
+ const wallTextureId = this.map.getReferenceWallTile().textureId;
const instancedMesh = new THREE.InstancedMesh(
wallGeo,
- new THREE.MeshStandardMaterial({ map: this.textures[0] }),
+ new THREE.MeshStandardMaterial({
+ map: this.getTexture(wallTextureId),
+ }),
wallPlanes.length,
);
instancedMesh.userData.pastelMaterial = new THREE.MeshBasicMaterial({
@@ -195,34 +237,58 @@ export class FirstPersonRenderer {
//
// -------
- // SPRITES
+ // Roamers
// -------
//
- 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);
- 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.scene.add(sprite);
- console.log({ x, y, textureId });
+ // Roaming tiles (e.g. encounters)
+ //
+ for (const [x, y, tile] of roamers) {
+ const textureId = tile.textureId;
+
+ if (!textureId) {
+ console.warn("invalid textureId", { x, y, textureId });
+ }
+
+ const roamerSprite = new THREE.Sprite(this.getSpriteMaterial(textureId));
+ roamerSprite.position.set(x, y, 0);
+ roamerSprite.userData.tile = tile;
+ this.roamers.push(roamerSprite);
+ this.scene.add(roamerSprite);
}
}
- renderFrame(posX, posY, dirAngle, commit = true) {
+ renderFrame(camX, camY, camOrientation) {
//
- const posV = new Vector3(posX, posY, 0);
+ // Camera and lighting
+ //
+ const camV = new Vector3(camX, camY, 0);
+ this.updateCameraPosition(camOrientation, camV);
+ this.torch.position.set(camV.x, camV.y, camV.z + 0.25);
//
- // -------------------------------
- // Camera Position and Orientation
- // -------------------------------
+ // Update position of roaming entities
//
- // Direction we're looking
+ this.updateRoamsers(camV);
+
+ //
+ // Render the scene into an image
+ //
+ this.renderSceneImage();
+
+ //
+ // Convert the rendered image to ASCII
+ //
+ this.renderSceneASCII();
+ }
+
+ renderSceneImage() {
+ 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");
+ }
+
+ updateCameraPosition(dirAngle, camV) {
const lookDirV = new Vector3(1, 0, 0)
.applyAxisAngle(new Vector3(0, 0, 1), dirAngle)
.setZ(0)
@@ -231,59 +297,54 @@ export class FirstPersonRenderer {
//
// The Point we're looking at.
//
- const lookAtV = lookDirV.clone().add(posV);
+ const lookAtV = lookDirV.clone().add(camV);
lookAtV.z = 0;
- this.mainCamera.position.copy(posV); // Move the camera
- this.mainCamera.lookAt(lookAtV); // Rotate the camera
+ this.mainCamera.position.copy(camV); // Move the camera
+ this.mainCamera.lookAt(lookAtV);
+ }
- // -----
- // TORCH
- // -----
- //
- // The torch should hover right above the camera
- this.torch.position.set(posV.x, posV.y, posV.z + 0.25);
+ updateRoamsers(camV) {
+ this.roamers.forEach((roamerSprite) => {
+ /** @type {Tile} */
+ const tile = roamerSprite.userData.tile;
- // -------
- // SPRITES
- // -------
- //
- this.sprites.forEach((sprite) => {
//
- // The tilemap position (vector) of the sprite
+ // The map position (vector) of the encounter
/** @type {Vector3} */
- const spriteCenterV = sprite.userData.mapLocation;
+ const roamerTilePosV = new THREE.Vector3(tile.currentPosX, tile.currentPosY, 0);
+
+ // -------------------------------------
+ // Move sprite visually closer to camera
+ // -------------------------------------
+ //
+ // Sprites look better if they are right on the
+ // edge of their tile, closest to the player.
+ //
+ //
+ // Direction from encounter to camera
+ const dirV = new Vector3().subVectors(roamerTilePosV, camV);
//
- // 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
+ // Is the encounter too far away to see? (manhattan distance for
+ if (dirV.manhattanLength() > this.viewDistance) {
+ // Encounter is out of range 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);
+ //
+ // Set sprite position to the edge of the tile that is closest to the camera
+ roamerSprite.position.copy(roamerTilePosV);
+ // Magic constant. 0.6 is visually appealing and makes the encounter/sprite
+ // look fairly close while still being able to see the entire sprite.
+ roamerSprite.position.addScaledVector(dirV.normalize(), -0.6);
});
+ }
- 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
- // ----------------
- //
+ /**
+ * Convert rendered image to ASCII (asciification)
+ */
+ renderSceneASCII() {
performance.mark("asciification_start");
const gl = this.renderer.getContext();
const width = this.renderer.domElement.width;
@@ -314,11 +375,11 @@ export class FirstPersonRenderer {
performance.measure(
"Asciification", // The name for our measurement
"asciification_start", // The starting mark
- "asciification_end", // The ending mark
+ "asciification_end",
);
//
- if (commit) {
+ if (this.commitToDOM) {
performance.mark("dom_commit_start");
this.window.commitToDOM();
performance.mark("dom_commit_end");
diff --git a/frontend/ascii_minimap.js b/frontend/ascii_minimap.js
new file mode 100755
index 0000000..217a594
--- /dev/null
+++ b/frontend/ascii_minimap.js
@@ -0,0 +1,203 @@
+import { TileMap } from "./ascii_tile_map.js";
+import { Orientation, Vector2i } from "./ascii_types.js";
+import { AsciiWindow } from "./ascii_window.js";
+
+export class MiniMap {
+ /**
+ * @param {AsciiWindow} aWindow
+ * @param {TileMap} map
+ */
+ constructor(aWindow, map) {
+ if (aWindow.width !== aWindow.height) {
+ console.warn("Window now square", { width: aWindow.width, height: aWindow.height });
+ throw new Error("Window must be square");
+ }
+ if (aWindow.width % 2 === 0) {
+ console.warn("Window width must not be an even number", {
+ width: aWindow.width,
+ });
+ 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;
+ }
+
+ /**
+ * @param {number} pX
+ * @param {number} pY
+ * @param {Orientation} orientation
+ */
+ draw(pX, pY, orientation) {
+ console.log("Updating minimap", { px: pX, py: pY, orientation });
+
+ //
+ // 2D array of tiles that are visible
+ const visibleTiles = new Array(this.map.height).fill().map(() => new Array(this.map.width).fill(false));
+
+ const radius = this.distance;
+ const radiusSq = radius * radius;
+
+ //
+ // Mark a tile visible
+ const setVisible = (x, y) => {
+ if (x < 0) return;
+ if (y < 0) return;
+ if (x >= visibleTiles[0].length) return;
+ if (y >= visibleTiles.length) return;
+
+ visibleTiles[y][x] = true;
+ };
+
+ //
+ // Test if a tile is visible
+ const isVisible = (x, y) => {
+ if (x < 0) return false;
+ if (y < 0) return false;
+ if (x >= visibleTiles[0].length) return false;
+ if (y >= visibleTiles.length) return false;
+
+ return visibleTiles[y][x];
+ };
+
+ //
+ // Recursive shadowcasting
+ const castLight = (row, startSlope, endSlope, xx, xy, yx, yy) => {
+ //
+ if (startSlope < endSlope) {
+ return;
+ }
+
+ for (let i = row; i <= radius; i++) {
+ let dx = -i;
+ const dy = -i;
+ let blocked = false;
+ let newStart = startSlope;
+
+ while (dx <= 0) {
+ const X = pX + dx * xx + dy * xy;
+ const Y = pY + dx * yx + dy * yy;
+
+ const lSlope = (dx - 0.5) / (dy + 0.5);
+ const rSlope = (dx + 0.5) / (dy - 0.5);
+
+ if (startSlope < rSlope) {
+ dx++;
+ continue;
+ }
+ if (endSlope > lSlope) {
+ break;
+ }
+
+ if (dx * dx + dy * dy <= radiusSq) {
+ setVisible(X, Y);
+ }
+
+ if (blocked) {
+ if (this.map.looksLikeWall(X, Y)) {
+ newStart = rSlope;
+ } else {
+ blocked = false;
+ startSlope = newStart;
+ }
+ } else if (i < radius && this.map.looksLikeWall(X, Y)) {
+ blocked = true;
+ castLight(i + 1, startSlope, lSlope, xx, xy, yx, yy);
+ newStart = rSlope;
+ }
+ dx++;
+ }
+
+ if (blocked) {
+ break;
+ }
+ }
+ };
+
+ const computeVisibleTiles = () => {
+ setVisible(pX, pY);
+
+ const multipliers = [
+ [1, 0, 0, 1], // Octant 1 (N-NE)
+ [0, 1, 1, 0], // Octant 2 (E-NE)
+ [-1, 0, 0, 1], // Octant 3 (N-NW)
+ [0, 1, -1, 0], // Octant 4 (W-NW)
+ [-1, 0, 0, -1], // Octant 5 (S-SW)
+ [0, -1, -1, 0], // Octant 6 (W-SW)
+ [1, 0, 0, -1], // Octant 7 (S-SE)
+ [0, -1, 1, 0], // Octant 8 (E-SE)
+ ];
+
+ for (const m of multipliers) {
+ castLight(1, 1.0, 0.0, ...m);
+ }
+ };
+
+ computeVisibleTiles();
+
+ let [invertX, invertY, switchXY] = [false, false, false];
+
+ switch (orientation) {
+ case Orientation.NORTH:
+ invertX = true;
+ break;
+ case Orientation.SOUTH:
+ invertY = true;
+ break;
+ case Orientation.EAST:
+ switchXY = true;
+ break;
+ case Orientation.WEST:
+ switchXY = true;
+ invertY = true;
+ invertX = true;
+ break;
+ }
+
+ let [x, y] = [0, 0];
+ const max = this.window.width - 1;
+ const dX = invertX ? -1 : 1;
+ const dY = invertY ? -1 : 1;
+ const startX = invertX ? max : 0;
+ const startY = invertY ? max : 0;
+
+ const minX = pX - radius;
+ const minY = pY - radius;
+ const maxX = pX + radius;
+ const maxY = pY + radius;
+
+ //
+ y = startY;
+ for (let mapY = minY; mapY < maxY; mapY++) {
+ //
+ x = startX;
+ for (let mapX = minX; mapX < maxX; mapX++) {
+ //
+ const [putX, putY] = switchXY ? [y, x] : [x, y];
+
+ if (isVisible(mapX, mapY)) {
+ const tile = this.map.get(mapX, mapY);
+ this.window.put(putX, putY, tile.minimapChar, tile.minimapColor);
+ } else {
+ // this.window.put(putX, putY, "░", "#666");
+ this.window.put(putX, putY, " ", "#666");
+ }
+ x += dX;
+ }
+ y += dY;
+ }
+
+ this.window.put(this.distance, this.distance, "@", "#4f4fff");
+ this.window.commitToDOM();
+ }
+}
+
+if (Math.PI < 0 && AsciiWindow && TileMap && Vector2i) {
+ ("STFU Linda");
+}
diff --git a/frontend/ascii_tile_map.js b/frontend/ascii_tile_map.js
index f78db9c..21801f6 100755
--- a/frontend/ascii_tile_map.js
+++ b/frontend/ascii_tile_map.js
@@ -1,66 +1,76 @@
-import { FunctionCallParser } from "../utils/callParser.js";
-import { Vector2i, Orientation } from "./ascii_types.js";
-import { AsciiWindow } from "./ascii_window.js";
+import parseOptions, { ParsedCall } from "../utils/callParser.js";
+import { Tile } from "./ascii_tile_types.js";
+import { Vector2i } from "./ascii_types.js";
export class TileMap {
/**
* @param {string} str
* @param {Record>} */
const tiles = [];
- const options = [];
- const optionsParser = new FunctionCallParser();
let mapWidth;
lines.forEach((line, y) => {
- tiles[y] = [];
- options[y] = [];
-
// Everything before ":::" is map tiles, and everything after is options for the tiles on that line
let [tileStr, optionStr] = line.split(/\s*:::\s*/);
- // Infer the width of the map from the first line
- if (!mapWidth) {
+ if (y === 0) {
+ // Infer the width of the map from the first line
mapWidth = tileStr.length;
+ console.log({ mapWidth });
}
- optionStr = optionStr.split(/\s*\/\//)[0];
- options[y] = optionStr ? optionsParser.parse(optionStr) : [];
+ // Create a new row in the 2d tiles array
+ tiles[y] = Array(mapWidth);
- // STFU Linda
- console.log(tileStr, optionStr, y);
+ optionStr = optionStr ? optionStr.split(/\s*\/\//)[0] : false;
+ const options = optionStr ? parseOptions(optionStr) : [];
+ let lineWidth = 0;
+
+ options.length && console.log({ options, y });
+
+ tileStr.split("").forEach((char, x) => {
+ //
+ // Check if there are options in the queue that matches the current character
+ const tileArgs = options[0] && options[0].name === char ? options.shift() : null;
+
+ tiles[y][x] = Tile.fromChar(char, tileArgs, x, y);
+
+ lineWidth++;
+ });
+
+ if (lineWidth !== mapWidth) {
+ console.error("Invalid line in map", {
+ line: y,
+ expectedWidth: mapWidth,
+ lineWidth,
+ });
+ throw new Error("Line in map had invalid length");
+ }
});
- // return new TileMap(longestLine, lines.length, tiles, options);
- }
-
- tileIdx(x, y) {
- return y * this.width + x;
- }
-
- getByIdx(idx) {
- const y = Math.floor(idx / this.width);
- const x = idx % this.width;
- return this.tiles[y][x];
+ return new TileMap(tiles);
}
/**
- * @param {number} width
- * @param {number} height
* @param {Tile[][]} tiles
+ * @param {Map} options
*/
- constructor(width, height, tiles) {
+ constructor(tiles) {
/** @constant @readonly @type {number} */
this.height = tiles.length;
/** @constant @readonly @type {number} */
this.width = tiles[0].length;
/** @constant @readonly @type {Tile[][]} */
this.tiles = tiles;
+
/** @type {Tile} when probing a coordinate outside the map, this is the tile that is returned */
- this.outOfBoundsWall = this.findFirst({ isWall: true });
+ this.outOfBoundsWall = this.findFirstV({ looksLikeWall: true });
}
toString() {
@@ -76,6 +86,16 @@ export class TileMap {
return result;
}
+ tileIdx(x, y) {
+ return y * this.width + x;
+ }
+
+ getByIdx(idx) {
+ const y = Math.floor(idx / this.width);
+ const x = idx % this.width;
+ return this.tiles[y][x];
+ }
+
get(x, y) {
x |= 0;
y |= 0;
@@ -87,7 +107,7 @@ export class TileMap {
return this.tiles[y][x];
}
- isWall(x, y) {
+ looksLikeWall(x, y) {
x |= 0;
y |= 0;
@@ -100,7 +120,7 @@ export class TileMap {
return true;
}
- return this.tiles[y][x].isWall;
+ return this.tiles[y][x].looksLikeWall;
}
isTraversable(x, y) {
@@ -114,7 +134,11 @@ export class TileMap {
return this.tiles[y][x].isTraversable;
}
- findFirst(criteria) {
+ /**
+ * @param {object} criteria Search criteria - AND gate
+ * @returns {Vector2i|undefined}
+ */
+ findFirstV(criteria) {
return this.forEach((tile, x, y) => {
for (let k in criteria) {
if (tile[k] === criteria[k]) {
@@ -124,6 +148,39 @@ export class TileMap {
});
}
+ /**
+ * @param {object} criteria Search criteria - AND gate
+ * @returns {Tile|undefined}
+ */
+ findFirstTile(criteria) {
+ const v = this.findFirstV(criteria);
+ if (!v) {
+ return;
+ }
+
+ return this.get(v.x, v.y);
+ }
+
+ /**
+ * Return the main wall tile.
+ *
+ * Outer edge of map MUST be wall tiles, so we
+ * use tile at [0,0] as the reference wall tile
+ *
+ * @returns {WallTile}
+ */
+ getReferenceWallTile() {
+ return this.get(0, 0).clone();
+ }
+
+ /**
+ * Calls `fn(tile, x, y) ` on each element,
+ * but _stops_ if fn() returns anything but `undefined`,
+ * and then that return value is returned from `forEach`
+ *
+ * @param { (tile, x,y) => any|undefined ) } fn
+ * @returns any|undefined
+ */
forEach(fn) {
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
@@ -168,29 +225,8 @@ export class TileMap {
getAreaAround(x, y, radius) {
return this.getArea(x - radius, y - radius, x + radius, y + radius);
}
-
- isVisible(x, y) {
- //
- // At least one of the four cardinal neighbours
- // must be non-wall in order for a tile to be
- // visible
- if (!this.isWall(x - 1, y)) {
- return true;
- }
- if (!this.isWall(x + 1, y)) {
- return true;
- }
- if (!this.isWall(x, y - 1)) {
- return true;
- }
- if (!this.isWall(x, y + 1)) {
- return true;
- }
-
- return false;
- }
}
-if (Math.PI < 0 && AsciiWindow && Orientation) {
+if (Math.PI < 0 && ParsedCall) {
("STFU Linda");
}
diff --git a/frontend/ascii_tile_types.js b/frontend/ascii_tile_types.js
index 056932c..2a93081 100755
--- a/frontend/ascii_tile_types.js
+++ b/frontend/ascii_tile_types.js
@@ -1,4 +1,5 @@
-import { Orientation } from "./ascii_types";
+import { ParsedCall } from "../utils/callParser";
+import { Orientation, Vector2i } from "./ascii_types";
export class Tile {
/** @type {string} How should this tile be rendered on the minimap.*/
@@ -6,7 +7,7 @@ export class Tile {
/** @type {string} How should this tile be rendered on the minimap.*/
minimapColor;
/** @type {boolean} Should this be rendered as a wall? */
- isWall;
+ looksLikeWall;
/** @type {boolean} Can the player walk here? */
isTraversable;
/** @type {boolean} is this tile occupied by an encounter? */
@@ -28,12 +29,19 @@ export class Tile {
/** @type {number|string} id the encounter located on this tile */
encounterId;
/** @type {boolean} Can/does this tile wander around on empty tiles? */
- isWandering;
+ isRoaming;
/** @type {Orientation} */
orientation;
+ /** @type {number} If this is a roaming tile, what is its current x-position on the map */
+ currentPosX;
+ /** @type {number} If this is a roaming tile, what is its current y-position on the map*/
+ currentPosY;
+
+ static wallMinimapChar = "█";
+
/** @param {Tile} options */
- constructor(options) {
+ constructor(options = {}) {
for (let [k, v] of Object.entries(options)) {
if (this[k] !== undefined) {
this[k] = v;
@@ -41,26 +49,36 @@ export class Tile {
}
}
- /** @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);
+ /**
+ * @param {string} char
+ * @param {ParsedCall} opt Options
+ * @param {number} x
+ * @param {number} y
+ */
+ static fromChar(char, opt, x, y) {
+ opt = opt ?? new ParsedCall();
+ if (!(opt instanceof ParsedCall)) {
+ console.error("Invalid options", { char, opt: opt });
+ throw new Error("Invalid options");
}
+ if (char === " ") return new FloorTile();
+ if (char === "#") return new WallTile();
+ if (char === "P") return new PlayerStartTile(opt.getValue("orientation", 0));
+ if (char === "E") return new EncounterTile(x, y, opt.getValue("encounterId", 0), opt.getValue("textureId", 1));
+ if (char === "O") return new SecretOneWayPortalEntryTile(opt.getValue("channel", 0));
+ if (char === "o") return new SecretOneWayPortalExitTile(opt.getValue("channel", 0));
+ if (char === "Z") return new SecretTwoWayPortalTile(opt.getValue("channel", 0));
+
+ console.warn("Unknown character", { char, options: opt });
+ return new FloorTile();
+ }
+
+ hasTexture() {
+ if (this.textureId === "") {
+ return false;
+ }
+
+ return typeof this.textureId === "number" || typeof this.textureId === "string";
}
clone() {
@@ -70,14 +88,15 @@ export class Tile {
export class FloorTile extends Tile {
isTraversable = true;
- minimapChar = " ";
+ minimapChar = "·";
+ minimapColor = "#555";
internalMapChar = " ";
}
export class PlayerStartTile extends Tile {
isTraversable = true;
isStartLocation = true;
- MinimapChar = "▤"; // stairs
+ minimapChar = "▤"; // stairs
orientation = Orientation.NORTH;
/** @param {Orientation} orientation */
@@ -87,17 +106,34 @@ export class PlayerStartTile extends Tile {
}
export class WallTile extends Tile {
- textureId = 0;
+ textureId = "wall";
isTraversable = false;
- isWall = true;
- minimapChar = "#";
+ looksLikeWall = true;
internalMapChar = "#";
+ minimapChar = Tile.wallMinimapChar;
+ minimapColor = "#aaa";
}
export class EncounterTile extends Tile {
isEncounter = true;
- constructor(textureId, encounterId) {
- super({ textureId, encounterId });
+ isRoaming = true;
+ minimapChar = "†";
+ minimapColor = "#faa";
+
+ constructor(x, y, encounterId, textureId) {
+ super();
+ this.textureId = textureId ?? encounterId;
+ this.encounterId = encounterId;
+ this.currentPosX = x;
+ this.currentPosY = y;
+ console.info("creating encounter", { encounter: this });
+ }
+}
+export class SpriteTile extends Tile {
+ isTraversable = true;
+ constructor(textureId, orientation) {
+ console.debug({ textureId, orientation });
+ super({ textureId, orientation: orientation ?? Orientation.NORTH });
}
}
@@ -106,42 +142,46 @@ export class EncounterTile extends Tile {
* probe for them, or otherwise unlock their location.
* You can walk into them, and then the magic happens
*/
-export class SecretOneWayPortalEntryTile extends Tile {
+export class SecretOneWayPortalEntryTile extends WallTile {
textureId = 0;
- isWall = true;
+ looksLikeWall = true;
isTraversable = true; // we can walk in to it?
isOneWayPortalEntry = true;
internalMapChar = "O";
- minimapChar = "#"; // Change char when the portal has been uncovered
isUncovered = false;
+ // Change minimap char once the tile's secret has been uncovered.
+
constructor(channel) {
super({ channel });
}
}
-export class SecretOneWayPortalExitTile extends Tile {
- isTraversable = true;
+export class SecretOneWayPortalExitTile extends FloorTile {
isOneWayPortalExit = true;
internalMapChar = "o";
- minimapChar = " "; // Change char when the portal has been uncovered
isUncovered = false;
+ //
+ // Change minimap char once the tile's secret has been uncovered.
constructor(channel) {
super({ channel });
}
}
-export class SecretTwoWayPortalTile extends Tile {
- textureId = 0;
- isWall = true;
+export class SecretTwoWayPortalTile extends WallTile {
isTraversable = true;
isTwoWayPortalEntry = true;
internalMapChar = "0";
- minimapChar = "#"; // Change char when the portal has been uncovered
isUncovered = false;
+ // Change minimap char once the tile's secret has been uncovered.
+
constructor(channel) {
super({ channel });
}
}
+
+if (Math.PI < 0 && ParsedCall && Orientation && Vector2i) {
+ ("STFU Linda");
+}
diff --git a/frontend/ascii_types.js b/frontend/ascii_types.js
index dd73106..65bf4bd 100755
--- a/frontend/ascii_types.js
+++ b/frontend/ascii_types.js
@@ -5,13 +5,13 @@ export const PI_OVER_TWO = Math.PI / 2;
* @constant @readonly @enum {number}
*/
export const Orientation = {
- /** @constant @readonly @type {number} Going east increases X */
- EAST: 0,
- /** @constant @readonly @type {number} Going south decreases Y */
+ /** @constant @readonly @type {number} */
+ WEST: 0,
+ /** @constant @readonly @type {number} */
SOUTH: 1,
- /** @constant @readonly @type {number} Going west decreases X */
- WEST: 2,
- /** @constant @readonly @type {number} Going south increases Y */
+ /** @constant @readonly @type {number} */
+ EAST: 2,
+ /** @constant @readonly @type {number} */
NORTH: 3,
};
diff --git a/frontend/eobRedWall.png b/frontend/wall.png
old mode 100644
new mode 100755
similarity index 100%
rename from frontend/eobRedWall.png
rename to frontend/wall.png
diff --git a/utils/callParser.js b/utils/callParser.js
index d0f5dcc..b3fa062 100755
--- a/utils/callParser.js
+++ b/utils/callParser.js
@@ -1,97 +1,175 @@
-export class FunctionCallParser {
- /**
- * @typedef {{name: string, args: Array}} CallType
- */
+/** A call represents the name of a function as well as the arguments passed to it */
+export class ParsedCall {
+ /** @type {string} Name of the function */ name;
+ /** @type {ParsedArg[]} Args passed to function */ args;
- /**
- *
- * @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;
+ constructor(name, args) {
+ this.name = name;
+ this.args = args;
}
- /** @protected */
- parseArguments(argsStr) {
- if (!argsStr) return [];
+ /**
+ * Find an arg by name, but fall back to an index position
+ *
+ * @param {string} name
+ * @param {number?} position
+ *
+ * @returns {ParsedArg|null}
+ */
+ getArg(name, position) {
+ for (let idx in this.args) {
+ const arg = this.args[idx];
- 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 (name === arg.key) {
+ return arg;
}
}
- if (current.trim()) {
- tokens.push(current.trim());
- }
-
- return tokens;
+ return this.args[position] ?? null;
}
- /** @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;
+ getValue(name, position, fallbackValue = undefined) {
+ const arg = this.getArg(name, position);
+ return arg ? arg.value : fallbackValue;
}
}
+
+/** An argument passed to a function. Can be positional or named */
+export class ParsedArg {
+ /** @type {string|number} */ key;
+ /** @type {string|number|boolean|null|undefined} */ value;
+ constructor(key, value) {
+ this.key = key;
+ this.value = value;
+ }
+}
+
+/**
+ * Parse a string that includes a number of function calls separated by ";" semicolons
+ *
+ * @param {string} input
+ *
+ * @returns {ParsedCall[]}
+ *
+ * @example
+ * // returns
+ * // [
+ * // {name="O", args=[{ key: 0, value: 1 }]},
+ * // {name="P", args=[{ key: "orientation", value: "north" }]},
+ * // {name="E", args=[{ key: 0, value: "Gnolls" }, { key: "texture", value: "gnolls" }]},
+ * // ];
+ *
+ * parse(`O(1); P(orientation=north); E(Gnolls, texture=gnolls)`)
+ *
+ */
+export default function parse(input) {
+ const calls = [];
+ const pattern = /(\w+)\s*\(([^)]*)\)/g; // TODO: expand so identifiers can be more than just \w characters - also limit identifiers to a single letter (maybne)
+ let match;
+
+ while ((match = pattern.exec(input)) !== null) {
+ let name = match[1];
+ const argsStr = match[2].trim();
+ const args = parseArguments(argsStr);
+
+ // Hack to allow special characters in function names
+ // If function name is "__", then
+ // the actual function name is given by arg 0.
+ // Arg zero is automatically removed when the
+ // name is changed.
+ //
+ // So
+ // __(foo, 1,2,3) === foo(1,2,3)
+ // __("·", 1,2,3) === ·(1,2,3)
+ // __("(", 1,2,3) === ((1,2,3)
+ // __('"', 1,2,3) === '(1,2,3)
+
+ if (name === "__") {
+ name = args.shift().value;
+ }
+
+ calls.push(new ParsedCall(name, args));
+ }
+
+ return calls;
+}
+
+/**
+ * @param {string} argsStr
+ * @returns {ParsedArg[]}
+ */
+function parseArguments(argsStr) {
+ if (!argsStr) return [];
+
+ /** @type {ParsedArg[]} */
+ const args = [];
+ const tokens = tokenize(argsStr);
+
+ for (const pos in tokens) {
+ const token = tokens[pos];
+ const namedMatch = token.match(/^(\w+)=(.+)$/);
+ if (namedMatch) {
+ args.push(new ParsedArg(namedMatch[1], parseValue(namedMatch[2])));
+ } else {
+ args.push(new ParsedArg(Number.parseInt(pos), parseValue(token)));
+ }
+ }
+
+ return args;
+}
+
+/** @protected */
+function 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 */
+function 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;
+}