From 1f97ea63e2d222bfdcfa06918baf37f4709e5de2 Mon Sep 17 00:00:00 2001 From: Kim Ravn Hansen Date: Thu, 9 Oct 2025 15:51:40 +0200 Subject: [PATCH] progress --- frontend/SourceCell.js | 2 +- frontend/WfcCell.js | 4 +- frontend/WfcGrid.js | 4 +- frontend/ascii_dungeon_crawler.html | 82 ++- frontend/ascii_dungeon_crawler.js | 31 +- frontend/ascii_first_person_renderer.js | 38 +- frontend/ascii_minimap.js | 6 +- frontend/ascii_tile_map.js | 207 ++++++- frontend/ascii_tile_types.js | 319 +++++----- frontend/ascii_types.js | 47 +- frontend/client.js | 7 +- frontend/dungeon_generator.html | 573 ------------------ frontend/dungeon_studio.html | 133 ++++ frontend/dungeon_studio.js | 446 ++++++++++++++ frontend/skelebones.png | Bin 0 -> 6606 bytes server.js | 4 +- test.js | 17 + utils/random.js | 3 - utils/shallowCopy.js | 42 ++ utils/{callParser.js => tileOptionsParser.js} | 63 +- 20 files changed, 1173 insertions(+), 855 deletions(-) delete mode 100644 frontend/dungeon_generator.html create mode 100755 frontend/dungeon_studio.html create mode 100755 frontend/dungeon_studio.js create mode 100644 frontend/skelebones.png create mode 100755 utils/shallowCopy.js rename utils/{callParser.js => tileOptionsParser.js} (68%) diff --git a/frontend/SourceCell.js b/frontend/SourceCell.js index 2a915e0..1076a7e 100755 --- a/frontend/SourceCell.js +++ b/frontend/SourceCell.js @@ -41,7 +41,7 @@ export class SourceCell { potentialNeighbours(other, direction) { // sadly, we're not allowed to be friends with ourselves. if (this === other) { - console.log("WTF were checking to be friends with ourselves!", { _this: this, other, direction }); + console.warn("WTF were checking to be friends with ourselves!", { _this: this, other, direction }); // throw new Error("WTF were checking to be friends with ourselves!", { _this: this, other, direction }); } return ( diff --git a/frontend/WfcCell.js b/frontend/WfcCell.js index e29933e..67c2306 100755 --- a/frontend/WfcCell.js +++ b/frontend/WfcCell.js @@ -13,12 +13,12 @@ export class WfcCell { */ constructor(i, x, y, options) { if (!options.length) { - console.log("Bad >>options<< arg in WfcCell constructor. Must not be empty.", options); + console.warn("Bad >>options<< arg in WfcCell constructor. Must not be empty.", options); throw Error("Bad >>options<< arg in WfcCell constructor. Must not be empty.", options); } if (!(options[0] instanceof SourceCell)) { - console.log("Bad >>options<< arg in WfcCell constructor. Must be array of WfcCells, but wasn't.", options); + console.warn("Bad >>options<< arg in WfcCell constructor. Must be array of WfcCells, but wasn't.", options); throw Error("Bad >>options<< arg in WfcCell constructor. Must be array of WfcCells, but wasn't.", options); } diff --git a/frontend/WfcGrid.js b/frontend/WfcGrid.js index e6f1f52..173de98 100755 --- a/frontend/WfcGrid.js +++ b/frontend/WfcGrid.js @@ -34,7 +34,6 @@ export class WfcGrid { } reset() { - console.log("Resetting Cells"); const [w, h] = [this.width, this.height]; const len = w * h; this.cells = []; @@ -44,7 +43,6 @@ export class WfcGrid { this.cells.push(new WfcCell(i, x, y, this.sourceGrid.clone().cells)); } - console.log("Done"); } /** @@ -87,7 +85,7 @@ export class WfcGrid { }); if (this.lowEntropyCellIdCache.length === 0) { - console.log("Found zero lowest-entropy cells.", { entropy: this.lowestEntropy }); + console.info("Found zero lowest-entropy cells.", { entropy: this.lowestEntropy }); } } diff --git a/frontend/ascii_dungeon_crawler.html b/frontend/ascii_dungeon_crawler.html index ffc534b..21403d4 100755 --- a/frontend/ascii_dungeon_crawler.html +++ b/frontend/ascii_dungeon_crawler.html @@ -42,8 +42,8 @@ } #minimap { grid-area: minimap; - font-size: 12px; - line-height: 11.5px; + font-size: 14px; + line-height: 13px; white-space: pre; display: inline-block; padding: 2px; @@ -96,46 +96,44 @@
diff --git a/frontend/ascii_dungeon_crawler.js b/frontend/ascii_dungeon_crawler.js index 49e2b3c..1716fb1 100755 --- a/frontend/ascii_dungeon_crawler.js +++ b/frontend/ascii_dungeon_crawler.js @@ -40,6 +40,7 @@ class Player { } set orientation(o) { + console.log({ o }); // // Sanitize o o = ((o | 0) + 4) % 4; @@ -137,19 +138,23 @@ class DungeonCrawler { * @param {number} angle the orientation of the camera in radians around the unit circle. */ render(camX = this.player.x, camY = this.player.y, angle = this.player.angle) { - if (!this.rendering.firstPersonRenderer) { - console.log("Renderer not ready yet"); + if (!(this.rendering.firstPersonRenderer && this.rendering.firstPersonRenderer.ready)) { + console.warn("Renderer not ready yet"); return; } - this.rendering.firstPersonRenderer.renderFrame( - camX, // add .5 to get camera into center of cell - camY, // add .5 to get camera into center of cell - angle, - ); + queueMicrotask(() => { + this.rendering.firstPersonRenderer.renderFrame( + camX, // add .5 to get camera into center of cell + camY, // add .5 to get camera into center of cell + angle, + ); + }); } renderMinimap() { - this.rendering.miniMapRenderer.draw(this.player.x, this.player.y, this.player.orientation); + queueMicrotask(() => { + this.rendering.miniMapRenderer.draw(this.player.x, this.player.y, this.player.orientation); + }); } loadMap() { @@ -158,6 +163,8 @@ class DungeonCrawler { this.map = TileMap.fromHumanText(mapString); this.player._posV = this.map.findFirstV({ isStartLocation: true }); + this.player.orientation = this.map.findFirstTile({ isStartLocation: true }).orientation; + console.log(this.player); if (!this.player._posV) { throw new Error("Could not find a start location for the player"); @@ -236,8 +243,8 @@ class DungeonCrawler { // 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. + // Bumping into a trap activates it (or reveals it if someone on the team detects it, or of a detect trap spell is running) + // Bumping into loot reveals it console.info( "bumped into %s at %s (mypos: %s), direction=%d", @@ -328,10 +335,10 @@ class DungeonCrawler { // // Guard: stop animation if it took too long if (this.animation.targetTime <= performance.now()) { + this.animation = {}; this.render(this.player.x, this.player.y, this.player.angle); this.renderMinimap(); this.renderStatus(); - this.animation = {}; return false; } @@ -399,7 +406,7 @@ class DungeonCrawler { renderStatus() { // // - // Update the compass + // Update the compass and status document.getElementById("status").innerHTML = sprintf( [ "
", diff --git a/frontend/ascii_first_person_renderer.js b/frontend/ascii_first_person_renderer.js index b4f84f3..f474003 100755 --- a/frontend/ascii_first_person_renderer.js +++ b/frontend/ascii_first_person_renderer.js @@ -82,23 +82,48 @@ export class FirstPersonRenderer { /** @type {THREE.Sprite[]} All roaming tiles that regularly needs their positions updated */ this.roamers = []; + /** @type {number} how many asynchronous function returns are we waiting for? */ + this.openAsyncs = 0; + /** @type {boolean} Are we ready to render? (have all resources been loaded?) */ + this.ready = false; + /** @type {function} called when the renderer is ready and all resources have been loaded */ + this.onReady = null; + // this.initMap(); // this.renderer.setSize(this.asciiWidth * 1, this.asciiHeight * 1); - this.renderFrame(); + + const waitForAsyncs = () => { + if (this.ready) { + return; + } + if (this.openAsyncs > 0) { + setTimeout(waitForAsyncs, 100); + return; + } + + this.ready = true; + if (typeof this.onReady === "function") { + this.onReady(); + return; + } + + this.renderFrame(); + }; + setTimeout(waitForAsyncs, 100); } getTexture(textureId) { - console.debug("fetching texture", { textureId }); let texture = this.textures.get(textureId); if (!texture) { - console.debug(" miss... loading texture", { textureId }); + this.openAsyncs++; texture = new THREE.TextureLoader().load(`${textureId}.png`, (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.openAsyncs--; }); this.textures.set(textureId, texture); } @@ -111,12 +136,9 @@ export class FirstPersonRenderer { } 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, @@ -150,7 +172,6 @@ export class FirstPersonRenderer { this.mainCamera.lookAt(x, y - 1, 0); this.torch.position.copy(this.mainCamera.position); - console.log("Initial Camera Position:", this.mainCamera.position); return; } @@ -184,7 +205,8 @@ export class FirstPersonRenderer { // --------------------------- const floorGeo = new THREE.PlaneGeometry(this.map.width, this.map.height); const floorMat = new THREE.MeshStandardMaterial({ - color: this.floorColor /* side: THREE.DoubleSide */, + color: this.floorColor, + /* side: THREE.DoubleSide */ }); const floor = new THREE.Mesh(floorGeo, floorMat); floor.position.set(this.map.width / 2, this.map.height / 2, -0.5); diff --git a/frontend/ascii_minimap.js b/frontend/ascii_minimap.js index b3cf01a..2996c46 100755 --- a/frontend/ascii_minimap.js +++ b/frontend/ascii_minimap.js @@ -35,8 +35,6 @@ export class MiniMap { * @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)); @@ -151,12 +149,12 @@ export class MiniMap { invertY = true; break; case Orientation.EAST: + invertY = true; + invertX = true; switchXY = true; break; case Orientation.WEST: switchXY = true; - invertY = true; - invertX = true; break; } diff --git a/frontend/ascii_tile_map.js b/frontend/ascii_tile_map.js index 21801f6..56f7a50 100755 --- a/frontend/ascii_tile_map.js +++ b/frontend/ascii_tile_map.js @@ -1,7 +1,35 @@ -import parseOptions, { ParsedCall } from "../utils/callParser.js"; -import { Tile } from "./ascii_tile_types.js"; +import parseOptions, { TileOptions } from "../utils/tileOptionsParser.js"; +import { Tile, WallTile } from "./ascii_tile_types.js"; import { Vector2i } from "./ascii_types.js"; +/** + * @typedef {object} TileWithCoords + * @property {Tile} tile + * @property {number} x + * @property {number} y + */ + +/** + * @typedef {Map} TileCoordsHashTable + */ + +/** + * @callback TileMapForEachCallback + * @param {Tile} tile + * @param {number} x + * @param {number} y + * @returns {undefined|any} If undefined is returned, the looping continues, but if anything else is returned, the loop halts, and the return value is passed along to the caller + */ + +/** + * @readonly @constant @enum {string} + */ +export const CharType = { + SYSTEM: "internalMapChar", + MINIMAP: "minimapChar", + MINIMAP_REVEALED: "revealedMinimapChar", +}; + export class TileMap { /** * @param {string} str @@ -22,7 +50,6 @@ export class TileMap { if (y === 0) { // Infer the width of the map from the first line mapWidth = tileStr.length; - console.log({ mapWidth }); } // Create a new row in the 2d tiles array @@ -32,8 +59,6 @@ export class TileMap { 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 @@ -59,26 +84,26 @@ export class TileMap { /** * @param {Tile[][]} tiles - * @param {Map} options */ 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.findFirstV({ looksLikeWall: true }); + /** @type {number} */ this.height = tiles.length; + /** @type {number} */ this.width = tiles[0].length; + /** @type {Tile[][]} */ this.tiles = tiles; + /** @type {number} */ this.playerStartX = undefined; + /** @type {number} */ this.playerStartT = undefined; + /** @type {Tile} */ this.outOfBoundsWall = this.getReferenceWallTile(); } - toString() { + /** + * @param {CharType} charType + * @returns {string} + */ + toString(charType = CharType.SYSTEM) { 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.minimapChar; + result += tile[charType]; } result += "\n"; } @@ -96,12 +121,12 @@ export class TileMap { return this.tiles[y][x]; } - get(x, y) { + get(x, y, outOfBounds = this.outOfBoundsWall) { x |= 0; y |= 0; if (x < 0 || x >= this.width || y < 0 || y >= this.height) { - return this.outOfBoundsWall; + return outOfBounds; } return this.tiles[y][x]; @@ -178,7 +203,7 @@ export class TileMap { * but _stops_ if fn() returns anything but `undefined`, * and then that return value is returned from `forEach` * - * @param { (tile, x,y) => any|undefined ) } fn + * @param {TileMapForEachCallback} fn * @returns any|undefined */ forEach(fn) { @@ -192,23 +217,82 @@ export class TileMap { } } - getArea(xMin, yMin, xMax, yMax) { - if (xMin > xMax) { - [xMin, xMax] = [xMax, xMin]; - } - if (yMin > yMax) { - [yMin, yMax] = [yMax, yMin]; + /** + * @returns {number} + */ + getTraversableTileCount() { + let sum = 0; + + this.forEach((tile) => { + if (tile.isTraversable) { + sum++; + } + }); + + return sum; + } + + /** + * @param {number} x + * @param {number} y + * @param {typeof Tile} tileClass + * @returns {TileWithCoords[]} + */ + getCardinalAdjacentTiles(x, y, tileClass) { + /** @type {TileWithCoords[]} */ + const result = []; + + const testCoords = [ + [x + 1, y], + [x - 1, y], + [x, y + 1], + [x, y + 1], + ]; + + for (const [_x, _y] of testCoords) { + const _tile = this.get(_x, _y, false); + + if (_tile === false) { + // _x, _y was out of bounds, do not add it to result + continue; + } + + if (tileClass && !(_tile instanceof tileClass)) { + // _tile was of invalid type, do not add it to result + continue; + } + + result.push({ tile: _tile, x: _x, y: _y }); } - const w = xMax - xMin + 1; - const h = yMax - yMin + 1; + return result; + } + + /** + * @param {number} minX + * @param {number} minY + * @param {number} maxX + * @param {number} maxY + * + * @returns {TileMap} + */ + getArea(minX, minY, maxX, maxY) { + if (minX > maxX) { + [minX, maxX] = [maxX, minX]; + } + if (minY > maxY) { + [minY, maxY] = [maxY, minY]; + } + + const w = maxX - minX + 1; + const h = maxY - minY + 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++) { + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { const tile = this.tiles[y][x]; if (!tile) { throw new Error("Dafuqq is happing here?"); @@ -222,11 +306,68 @@ export class TileMap { return new TileMap(w, h, tiles); } - getAreaAround(x, y, radius) { - return this.getArea(x - radius, y - radius, x + radius, y + radius); + /** + * @param {number} x + * @param {number} y + * @param {number} manhattanRadius + */ + getAreaAround(x, y, manhattanRadius) { + return this.getArea( + x - manhattanRadius, // minX + y - manhattanRadius, // minY + x + manhattanRadius, // maxX + y + manhattanRadius, // maxY + ); + } + + /** + * @param {number} startX + * @param {number} startY + * @returns {TileCoordsHashTable} + */ + getAllTraversableTilesConnectedTo(startX, startY) { + /** @type {TileCoordsHashTable} */ + const result = new Map(); + + const allTilesFlat = new Array(this.width * this.height).fill(); + + this.forEach((tile, x, y) => { + const idx = x + y * this.width; + allTilesFlat[idx] = { tile, x, y }; + }); + + const inspectionStack = [startX + startY * this.width]; + + while (inspectionStack.length > 0) { + const idx = inspectionStack.pop(); + + const { tile, x, y } = allTilesFlat[idx]; + + if (!tile.isTraversable) { + continue; // Can't walk there, move on + } + + if (result.has(idx)) { + continue; // Already been here, move on + } + + result.set(idx, allTilesFlat[idx]); + + // Add neighbors + const [minX, minY] = [1, 1]; + const maxX = this.width - 2; + const maxY = this.height - 2; + + if (y >= minY) inspectionStack.push(idx - this.width); // up + if (y <= maxY) inspectionStack.push(idx + this.width); // down + if (x >= minX) inspectionStack.push(idx - 1); // left + if (x <= maxX) inspectionStack.push(idx + 1); // right + } + + return result; } } -if (Math.PI < 0 && ParsedCall) { +if (Math.PI < 0 && TileOptions && WallTile) { ("STFU Linda"); } diff --git a/frontend/ascii_tile_types.js b/frontend/ascii_tile_types.js index ea6e115..0289297 100755 --- a/frontend/ascii_tile_types.js +++ b/frontend/ascii_tile_types.js @@ -1,105 +1,211 @@ -import { ParsedCall } from "../utils/callParser"; -import { Orientation, Vector2i } from "./ascii_types"; +import { mustBe, mustBeString } from "../utils/mustbe.js"; +import shallowCopy from "../utils/shallowCopy.js"; +import { TileOptions } from "../utils/tileOptionsParser.js"; +import { Orientation, Vector2i } from "./ascii_types.js"; + +/** + * Array of __internal__ characters used to identify tile types. + * These are __not__ necessarily the characters used to display + * the tile on the minimap - but they are used when serializing + * the maps into a semi-human-readable text-format. + * + * @constant {Record} */ +export const TileTypes = { + [TileChars.FLOOR]: { + minimapChar: "·", + traversable: true, + }, + [TileChars.WALL]: { + minimapChar: "█", + minimapColor: "#aaa", + textureId: "wall", + traversable: false, + looksLikeWall: true, + }, + [TileChars.SECRET_PORTAL]: { + disguiseAs: TileChars.WALL, + revealedMinimapChar: "Ω", + revealedMinimapColor: "#EE82EE", //purple + revealedTextureId: "secret_portal_revealed", + portalTargetId: REQUIRED_ID, + looksLikeWall: true, + }, + [TileChars.TELPORTATION_TARGET]: { + is: TileChars.FLOOR, + id: REQUIRED_ID, + orientation: REQUIRED_ORIENTATION, + disguiseAs: TileChars.FLOOR, + revealedMinimapChar: "𝑥", + revealedMinimapColor: "#EE82EE", // purple + }, + [TileChars.ENCOUNTER_START_POINT]: { + is: TileChars.FLOOR, // this is actually just a floor tile that is occupied by an encounter when the map is loaded + encounterId: REQUIRED_ID, + textureId: REQUIRED_ID, + occupants: REQUIRED_OCCUPANTS, + }, + [TileChars.PLAYER_START_POINT]: { + is: TileChars.FLOOR, + orientation: REQUIRED_ORIENTATION, + minimapChar: "▤", // stairs/ladder + minimapColor: "#FFF", + }, +}; export class Tile { - /** @type {string|number} What is the id of this tile - only interactive tiles have IDs */ + /** @readonly {string?|number?} Unique (but optional) instance if of this tile */ id; - /** @type {string} Icon char of tile */ minimapChar; - /** @type {string} Icon char of tile after tile's secrets have been revealed */ - revealedMinimapChar; - /** @type {string} Icon of tile */ + /** @type {string} Color of the icon of tile */ minimapColor; - /** @type {string} Icon char of tile after tile's secrets have been revealed */ - revealedMinimapColor; - + /** @type {boolean} Can the player walk here? */ + isTraversable; + /** @type {boolean} Should this be rendered as a wall? */ + looksLikeWall; + /** @type {boolean} Is this where they player starts? */ + isStartLocation; /** @type {boolean} Is this a portal exit and/or entry */ isPortal; /** @type {string|number} Where is the player transported if they enter the portal */ portalTargetId; - - /** @type {boolean} Should this be rendered as a wall? */ - looksLikeWall; - /** @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} Has the secret properties of this tile been revealed? */ - isRevealed; - /** @type {string|number} */ - hasBumpEvent; - /** @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 of texture to use after the secrets of this tile has been revealed */ - revealedTextureId; /** @type {number|string} type of encounter located on this tile. May or may not be unique*/ encounterType; - /** @type {boolean} Can/does this tile wander around on empty tiles? */ - isRoaming; + /** @type {number|string} type of trap located on this tile. May or may not be unique*/ + trapType; /** @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; + /** @type {TileType} This tile disguises itself as another tile, and its true properties are revealed later if event is triggered */ + disguiseAs; + /** @type {boolean} Has the secret properties of this tile been revealed? */ + revealed; + /** @type {string} Icon char of tile after tile's secrets have been revealed */ + revealedMinimapChar; + /** @type {string} Color of the icon char of tile after tile's secrets have been revealed */ + revealedMinimapColor; + /** @type {number|string} id of texture to use after the secrets of this tile has been revealed */ + revealedTextureId; - static wallMinimapChar = "█"; + /** @param {Tile} properties */ + constructor(properties) { + mustBe(properties, "object"); - /** @param {Tile} options */ - constructor(options = {}) { - for (let [k, v] of Object.entries(options)) { - if (this[k] !== undefined) { - this[k] = v; + // + // Copy props from properties. + // + for (const [key, val] of Object.entries(properties)) { + if (typeof val === "symbol" && val.description.startsWith("REQUIRED_")) { + console.error( + [ + "REQUIRED_ symbol encountered in Tile constructor. ", + "REQUIRED_ is a placeholder, and cannot be used as a value directly", + ].join("\n"), + { key, val, options: properties }, + ); + throw new Error("Incomplete data in constructor. Args may not contain a data placeholder"); } + + if (!Object.hasOwn(this, key) /* Object.prototype.hasOwnProperty.call(this, key) */) { + console.warn("Unknown tile property", { key, val, properties }); + } + } + + // + // If this tile is disguised, copy its attributes, but + // do not overwrite own attributes. + // + if (this.disguiseAs !== undefined) { + this.revealed = false; + + const other = shallowCopy(TileTypes[this.is]); + for (const [pKey, pVal] of Object.entries(other)) { + if (this.key !== undefined) { + this[pKey] = pVal; + } + } + } + + // + // If this tile "inherits" properties from another tile type, + // copy those properties, but do not overwrite own attributes. + // + if (this.is !== undefined) { + // + const other = shallowCopy(TileTypes[this.is]); + for (const [pKey, pVal] of Object.entries(other)) { + if (this.key !== undefined) { + this[pKey] = pVal; + } + } + } + + // + // Normalize Orientation + // + if (this.orientation !== undefined && typeof this.orientation === "string") { + const valueMap = { + north: Orientation.NORTH, + south: Orientation.SOUTH, + east: Orientation.EAST, + west: Orientation.WEST, + }; + this.orientation = mustBeString(valueMap[this.orientation.toLowerCase()]); + } + + if (this.id !== undefined) { + mustBe(this.id, "number", "string"); + } + if (this.textureId !== undefined) { + mustBe(this.textureId, "number", "string"); + } + if (this.portalTargetId !== undefined) { + mustBe(this.portalTargetId, "number", "string"); } } /** * @param {string} char - * @param {ParsedCall} opt Options + * @param {TileOptions} options 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 }); + static fromChar(char, options) { + // + // Validate Options + options = options ?? new TileOptions(); + if (!(options instanceof TileOptions)) { + console.error("Invalid options", { char, opt: options }); 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("encounterType", 0), opt.getValue("textureId", 1)); - if (char === "Z") - return new SecretPortalTile( - opt.getValue("id", 0), - opt.getValue("destinationid", 1), - opt.getValue("orientation", 3), - ); - console.warn("Unknown character", { char, options: opt }); - return new FloorTile(); - } + const typeInfo = TileTypes[char]; - hasTexture() { - if (typeof this.textureId === "number") { - return true; + let optionPos = 0; + const creationArgs = {}; + const getOption = (name) => options.getValue(name, optionPos++); + for (let [key, val] of Object.entries(typeInfo)) { + // + const fetchFromOption = typeof val === "symbol" && val.descript.startsWith("REQUIRED_"); + + creationArgs[key] = fetchFromOption ? getOption(key) : shallowCopy(val); } - if (typeof this.textureId === "string" && this.textureId !== "") { - return true; - } - - return false; - } - - getBumpEvent() { - return null; } clone() { @@ -107,77 +213,6 @@ export class Tile { } } -export class FloorTile extends Tile { - isTraversable = true; - minimapChar = "·"; - minimapColor = "#555"; - 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 = "wall"; - isTraversable = false; - looksLikeWall = true; - internalMapChar = "#"; - minimapChar = Tile.wallMinimapChar; - minimapColor = "#aaa"; -} - -export class EncounterTile extends Tile { - isEncounter = true; - isRoaming = true; - minimapChar = "†"; - minimapColor = "#f44"; - hasBumpEvent = true; - - /** - * @param {number} x x-component of the encounter's initial position - * @param {number} y y-component of the encounter's initial position - * @param {string|number} encounterType name/id of the encounter that will be triggered when player bumps into this tile - * @param {string|number} textureId id of the texture to use. - */ - constructor(x, y, encounterType, textureId) { - super(); - this.textureId = textureId ?? encounterType; - this.encounterType = encounterType; - this.currentPosX = x; - this.currentPosY = y; - this.id = `E_${encounterType}_${x}_${y}`; - console.info("creating encounter", { encounter: this }); - } - - getBumpEvent() { - return ["attack", { encounterType: this.encounterType }]; - } -} - -export class SecretPortalTile extends WallTile { - revealedTextureId = "secretTwoWayPortal"; - isPortal = true; - internalMapChar = "Z"; - isRevealed = false; - revealedMinimapChar = "Ω"; - revealedMinimapColor = "#4f4"; - - // Change minimap char once the tile's secret has been uncovered. - - constructor(id, portalTargetId, orientation) { - super({ id, portalTargetId, orientation }); - } -} - -if (Math.PI < 0 && ParsedCall && Orientation && Vector2i) { +if (Math.PI < 0 && TileOptions && Orientation && Vector2i) { ("STFU Linda"); } diff --git a/frontend/ascii_types.js b/frontend/ascii_types.js index 518c918..784bb5f 100755 --- a/frontend/ascii_types.js +++ b/frontend/ascii_types.js @@ -4,16 +4,47 @@ export const PI_OVER_TWO = Math.PI / 2; * Enum Cardinal Direction (east north west south) * @constant @readonly @enum {number} */ -export const Orientation = { +export class Orientation { /** @constant @readonly @type {number} */ - EAST: 0, + static EAST = 0; /** @constant @readonly @type {number} */ - SOUTH: 1, + static SOUTH = 1; /** @constant @readonly @type {number} */ - WEST: 2, + static WEST = 2; /** @constant @readonly @type {number} */ - NORTH: 3, -}; + static NORTH = 3; + + /** + * @param {string} str + * @returns {Orientation} + */ + static fromString(str) { + if (typeof str !== "string") { + console.error( + "Invalid data type when converting string to orientation. >>str<< is not a string be string.", + { str }, + ); + return undefined; + } + str = str.toLowerCase(); + if (str === "east") return Orientation.EAST; + if (str === "west") return Orientation.WEST; + if (str === "north") return Orientation.NORTH; + if (str === "south") return Orientation.SOUTH; + } + + /** + * @param {string|number} val + * @returns {Orientation} + */ + static normalize(val) { + if (typeof val === "string") { + return Orientation.fromString(val); + } + + return val % 4; + } +} /** * Enum Relative Direction (forward, left, right, backwards) @@ -190,3 +221,7 @@ export class Vector2i { return `[${this.x} , ${this.y}]`; } } + +const o = Orientation.fromString("south"); + +console.log(o); diff --git a/frontend/client.js b/frontend/client.js index 266fd82..6a5a78f 100755 --- a/frontend/client.js +++ b/frontend/client.js @@ -76,8 +76,6 @@ class MUDClient { // TODO Fix. Port should not be hardcoded const wsUrl = `${protocol}//${window.location.host}`.replace(/:\d+$/, ":3000"); - console.log(wsUrl); - this.updateStatus("Connecting...", "connecting"); try { @@ -106,7 +104,7 @@ class MUDClient { }; this.websocket.onerror = (error) => { - console.log("Websocket error", error); + console.warn("Websocket error", error); this.updateStatus("Connection Error", "error"); this.writeToOutput("Connection error occurred. Retrying...", { class: "error" }); }; @@ -137,7 +135,7 @@ class MUDClient { * @param {...any} rest */ send(messageType, ...args) { - console.log("sending", messageType, args); + console.debug("sending", messageType, args); if (args.length === 0) { this.websocket.send(JSON.stringify([messageType])); @@ -202,7 +200,6 @@ class MUDClient { // The quit command has its own message type let help = helpRegex.exec(inputText); if (help) { - console.log("here"); help[1] ? this.send(MessageType.HELP, help[1].trim()) : this.send(MessageType.HELP); this.echo(inputText); return; diff --git a/frontend/dungeon_generator.html b/frontend/dungeon_generator.html deleted file mode 100644 index 7f2f602..0000000 --- a/frontend/dungeon_generator.html +++ /dev/null @@ -1,573 +0,0 @@ - - - - - - ASCII Dungeon Generator - - - -
-

⚔️ ASCII DUNGEON GENERATOR ⚔️

- -
-
- - - 60 -
-
- - - 40 -
-
- - - 10 -
-
- -
- - -
- -
- -
-

Legend:

-
# - Wall
-
. - Floor
-
+ - Door
-
@ - Player Start
-
$ - Treasure
-
! - Monster
-
^ - Trap
-
-
- - - - diff --git a/frontend/dungeon_studio.html b/frontend/dungeon_studio.html new file mode 100755 index 0000000..51b9523 --- /dev/null +++ b/frontend/dungeon_studio.html @@ -0,0 +1,133 @@ + + + + + + ASCII Dungeon Generator + + + +
+

⚔️ ASCII DUNGEON GENERATOR ⚔️

+ +
+
+ + + 69 +
+
+ + + 42 +
+
+ + + 18 +
+
+ +
+ + +
+ +
+ +
+

Legend:

+
# - Wall
+
. - Floor
+
+ - Door
+
@ - Player Start
+
$ - Treasure
+
! - Monster
+
^ - Trap
+
+
+ + + + diff --git a/frontend/dungeon_studio.js b/frontend/dungeon_studio.js new file mode 100755 index 0000000..0a9b4d0 --- /dev/null +++ b/frontend/dungeon_studio.js @@ -0,0 +1,446 @@ +import { CharType, TileMap } from "./ascii_tile_map"; +import { EncounterTile, FloorTile, PlayerStartTile, TrapTile, LootTile, WallTile } from "./ascii_tile_types"; +import { Orientation } from "./ascii_types"; + +class DungeonGenerator { + constructor(width, height, roomCount) { + this.roomCount = roomCount; + this.rooms = []; + this.corridors = []; + + // 2d array of pure wall tiles + const tiles = new Array(height).fill().map(() => Array(width).fill(new WallTile())); + + this.map = new TileMap(tiles); + } + + get width() { + return this.map.width; + } + + get height() { + return this.map.height; + } + + generate() { + this.generateRooms(); + this.connectRooms(); + this.trimMap(); + this.addPillarsToBigRooms(); + this.addFeatures(); + this.addPlayerStart(); + this.addPortals(); + return this.map.toString(CharType.MINIMAP_REVEALED); + } + + generateRooms() { + this.rooms = []; + const maxAttempts = this.roomCount * 10; + let attempts = 0; + + while (this.rooms.length < this.roomCount && attempts < maxAttempts) { + const room = this.generateRoom(); + if (room && !this.roomOverlaps(room)) { + this.rooms.push(room); + this.carveRoom(room); + } + attempts++; + } + } + + generateRoom() { + const minSize = 4; + const maxSize = Math.min(12, Math.floor(Math.min(this.width, this.height) / 4)); + + const width = this.random(minSize, maxSize); + const height = this.random(minSize, maxSize); + const x = this.random(1, this.width - width - 1); + const y = this.random(1, this.height - height - 1); + + return { x, y, width, height }; + } + + roomOverlaps(newRoom) { + return this.rooms.some( + (room) => + newRoom.x < room.x + room.width + 2 && + newRoom.x + newRoom.width + 2 > room.x && + newRoom.y < room.y + room.height + 2 && + newRoom.y + newRoom.height + 2 > room.y, + ); + } + + carveRoom(room) { + for (let y = room.y; y < room.y + room.height; y++) { + for (let x = room.x; x < room.x + room.width; x++) { + this.map.tiles[y][x] = new FloorTile(); + } + } + } + + connectRooms() { + if (this.rooms.length < 2) return; + + // Connect each room to at least one other room + for (let i = 1; i < this.rooms.length >> 1; i++) { + const roomA = this.rooms[i - 1]; + const roomB = this.rooms[i]; + this.createCorridor(roomA, roomB); + } + + // Add some extra connections for more interesting layouts + const extraConnections = Math.floor(this.rooms.length / 3); + for (let i = 0; i < extraConnections; i++) { + const roomA = this.rooms[this.random(0, this.rooms.length - 1)]; + const roomB = this.rooms[this.random(0, this.rooms.length - 1)]; + if (roomA !== roomB) { + this.createCorridor(roomA, roomB); + } + } + } + + // Remove unnecessary walls that frame the rooms + // The dungeon should only be framed by a single + // layer of walls + trimMap() { + let dungeonStartY = undefined; + let dungeonEndY = 0; + + let dungeonStartX = this.width; // among all rows, when did we first see a non-wall tile on the west-side of the map? + let dungeonEndX = 0; // among all rows, when did we last see a non-wall tile on the east-side of the map? + + for (let y = 0; y < this.height; y++) { + // + let firstNonWallX = undefined; // x-index of the FIRST (westmost) non-wall tile that we encountered on this row + let lastNonWallX = undefined; // x-index of the LAST (eastmost) non-wall tile that we encountered on this row + + for (let x = 0; x < this.width; x++) { + const isWall = this.map.get(x, y) instanceof WallTile; + + if (isWall) { + continue; + } + + if (firstNonWallX === undefined) { + firstNonWallX = x; + } + lastNonWallX = x; + } + + const onlyWalls = lastNonWallX === undefined; + if (onlyWalls) { + continue; + } + + // + // X-axis bookkeeping + if (dungeonStartX > 0 && lastNonWallX < this.width) { + dungeonStartX = Math.min(dungeonStartX, firstNonWallX); + dungeonEndX = Math.max(dungeonEndX, lastNonWallX); + } + + // + // Y-Axis bookkeeping + if (dungeonStartY === undefined) { + dungeonStartY = y; + } + dungeonEndY = y; + } + + const newWidth = dungeonEndX - dungeonStartX + 3; + + const newTiles = []; + + // First row is all walls + newTiles.push(new Array(newWidth).fill(new WallTile())); + + // Populate the new grid + for (let y = dungeonStartY; y <= dungeonEndY; y++) { + const row = []; + + row.push(new WallTile()); // Initial wall tile on this row + for (let x = dungeonStartX; x <= dungeonEndX; x++) { + /**/ + const tile = this.map.get(x, y); + row.push(tile); + } + row.push(new WallTile()); // Final wall tile on this row + newTiles.push(row); + } + + // Final row is all walls + newTiles.push(new Array(newWidth).fill(new WallTile())); + + this.map = new TileMap(newTiles); + } + + createCorridor(roomA, roomB) { + const startX = Math.floor(roomA.x + roomA.width / 2); + const startY = Math.floor(roomA.y + roomA.height / 2); + const endX = Math.floor(roomB.x + roomB.width / 2); + const endY = Math.floor(roomB.y + roomB.height / 2); + + // Create L-shaped corridor + if (Math.random() < 0.5) { + // Horizontal first, then vertical + this.carveLine(startX, startY, endX, startY); + this.carveLine(endX, startY, endX, endY); + } else { + // Vertical first, then horizontal + this.carveLine(startX, startY, startX, endY); + this.carveLine(startX, endY, endX, endY); + } + } + + carveLine(x1, y1, x2, y2) { + const dx = Math.sign(x2 - x1); + const dy = Math.sign(y2 - y1); + + let x = x1; + let y = y1; + + while (x !== x2 || y !== y2) { + if (x >= 0 && x < this.width && y >= 0 && y < this.height) { + this.map.tiles[y][x] = new FloorTile(); + } + + if (x !== x2) x += dx; + if (y !== y2 && x === x2) y += dy; + } + + // Ensure endpoint is carved + if (x2 >= 0 && x2 < this.width && y2 >= 0 && y2 < this.height) { + this.map.tiles[y2][x2] = new FloorTile(); + } + } + + addPillarsToBigRooms() { + const walkabilityCache = []; + for (let y = 1; y < this.height - 1; y++) { + // + for (let x = 1; x < this.width - 1; x++) { + const cell = this.map.get(x, y); + + if (!cell) { + console.warn("out of bounds [%d, %d] (%s)", x, y, typeof cell); + continue; + } + + if (this.map.isTraversable(x, y)) { + walkabilityCache.push([x, y]); + } + } + } + + const shuffle = (arr) => { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); // random index 0..i + [arr[i], arr[j]] = [arr[j], arr[i]]; // swap + } + return arr; + }; + + shuffle(walkabilityCache); + + for (let [x, y] of walkabilityCache) { + // + const walkable = (offsetX, offsetY) => this.map.isTraversable(x + offsetX, y + offsetY); + + const surroundingFloorCount = + 0 + + // top row ------------|----------- + walkable(-1, -1) + // | north west + walkable(+0, -1) + // | north + walkable(+1, -1) + // | north east + // middle row ---------|----------- + walkable(-1, +0) + // | west + // | self + walkable(+1, +0) + // | east + // bottom row ---------|----------- + walkable(-1, +1) + // | south west + walkable(+0, +1) + // | south + walkable(+1, +1); // | south east + // ----------------------------|----------- + + if (surroundingFloorCount >= 7) { + // MAGIC NUMBER 7 + this.map.tiles[y][x] = new WallTile(); + } + } + } + + addPlayerStart() { + const walkabilityCache = []; + for (let y = 1; y < this.height - 1; y++) { + // + for (let x = 1; x < this.width - 1; x++) { + const cell = this.map.get(x, y); + + if (!cell) { + console.warn("out of bounds [%d, %d] (%s)", x, y, typeof cell); + continue; + } + + if (this.map.isTraversable(x, y)) { + walkabilityCache.push([x, y]); + } + } + } + + const idx = this.random(0, walkabilityCache.length - 1); + const [x, y] = walkabilityCache[idx]; + + const walkable = (offsetX, offsetY) => this.map.isTraversable(x + offsetX, y + offsetY); + + // + // When spawning in, which direction should the player be oriented? + // + const directions = []; + if (walkable(+1, +0)) directions.push(Orientation.EAST); + if (walkable(+0, +1)) directions.push(Orientation.NORTH); + if (walkable(-1, +0)) directions.push(Orientation.WEST); + if (walkable(+0, -1)) directions.push(Orientation.SOUTH); + + const dirIdx = this.random(0, directions.length - 1); + + this.map.tiles[y][x] = new PlayerStartTile(directions[dirIdx]); + } + + // Add portals to isolated areas + addPortals() { + let traversableTileCount = this.map.getTraversableTileCount(); + + const result = this.map.getAllTraversableTilesConnectedTo(/** TODO PlayerPos */); + + if (result.size === traversableTileCount) { + // There are no isolated areas, return + return; + } + + // _____ ___ ____ ___ + // |_ _/ _ \| _ \ / _ \ + // | || | | | | | | | | | + // | || |_| | |_| | |_| | + // |_| \___/|____/ \___/ + //------------------------------------- + // Connect isolated rooms via portals + //------------------------------------- + // + // LET Area0 = getAllTilesConnectedTo(playerStartTile) + // LET Areas = Array containing one item so far: Area0 + // FOR EACH tile in this.map + // IF tile not painted + // LET newArea = getAllTilesConnectedTo(tile) + // PUSH newArea ONTO Areas + // + // FOR EACH area IN Areas + // LET index = IndexOf(Areas, area) + // LET next = index + 1 mod LENGTH(Areas) + // entryPos = findValidPortalEntryPositionInArea(area) + // exitPos = findValidPortalExitPositionInArea(area) + // + // this.map[entryPos.y, entryPos.x] = new PortalEntryTile(index) + // this.map[exitPos.y, exitPos.x] = new PortalExitTile(next) + // + // + // + // Start pointing it (another color) + // + + console.warn( + "unpassable! There are %d floor tiles, but the player can only visit %d of them", + traversableTileCount, + result.size, + ); + } + + // + // + addFeatures() { + const floorTiles = []; + for (let y = 0; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + if (this.map.get(x, y) instanceof FloorTile) { + floorTiles.push({ x, y }); + } + } + } + + if (floorTiles.length === 0) return; + + // Add loot + const lootCount = Math.min(3, Math.floor(this.rooms.length / 2)); + for (let i = 0; i < lootCount; i++) { + const pos = floorTiles[this.random(0, floorTiles.length - 1)]; + if (this.map.tiles[pos.y][pos.x] instanceof FloorTile) { + this.map.tiles[pos.y][pos.x] = new LootTile(undefined, undefined); + } + } + + // Add monsters + const monsterCount = Math.min(5, this.rooms.length); + for (let i = 0; i < monsterCount; i++) { + const pos = floorTiles[this.random(0, floorTiles.length - 1)]; + if (this.map.tiles[pos.y][pos.x] instanceof FloorTile) { + this.map.tiles[pos.y][pos.x] = new EncounterTile(pos.x, pos.y, undefined, undefined); + } + } + + // Add traps + const trapCount = Math.floor(floorTiles.length / 30); + for (let i = 0; i < trapCount; i++) { + const pos = floorTiles[this.random(0, floorTiles.length - 1)]; + if (this.map.tiles[pos.y][pos.x] instanceof FloorTile) { + this.map.tiles[pos.y][pos.x] = new TrapTile(); + } + } + } + + random(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } +} + +let currentDungeon = ""; + +window.generateDungeon = () => { + const width = parseInt(document.getElementById("width").value); + const height = parseInt(document.getElementById("height").value); + const roomCount = parseInt(document.getElementById("roomCount").value); + + const generator = new DungeonGenerator(width, height, roomCount); + currentDungeon = generator.generate(); + + document.getElementById("dungeonDisplay").textContent = currentDungeon; +}; + +window.downloadDungeon = () => { + if (!currentDungeon) { + window.generateDungeon(); + } + + const blob = new Blob([currentDungeon], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "dungeon_map.txt"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +}; + +document.getElementById("width").addEventListener("input", function () { + document.getElementById("widthValue").textContent = this.value; +}); + +document.getElementById("height").addEventListener("input", function () { + document.getElementById("heightValue").textContent = this.value; +}); + +document.getElementById("roomCount").addEventListener("input", function () { + document.getElementById("roomCountValue").textContent = this.value; +}); + +// Generate initial dungeon +window.generateDungeon(); diff --git a/frontend/skelebones.png b/frontend/skelebones.png new file mode 100644 index 0000000000000000000000000000000000000000..f69df7a7e562c6a781c086bfee257e770f3c4e53 GIT binary patch literal 6606 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 MMNUMnLSTXb0JdI@0RR91 literal 0 HcmV?d00001 diff --git a/server.js b/server.js index 0533686..267fc19 100755 --- a/server.js +++ b/server.js @@ -202,14 +202,14 @@ class MudServer { // // Handle system messages if (msgObj.isSysMessage()) { - console.log("SYS message", msgObj); + console.debug("SYS message", msgObj); return; } // // Handle debug messages if (msgObj.isDebug()) { - console.log("DBG message", msgObj); + console.debug("DBG message", msgObj); return; } diff --git a/test.js b/test.js index e69de29..7fa23b3 100755 --- a/test.js +++ b/test.js @@ -0,0 +1,17 @@ +class Nugga { + mufassa = 22; + constructor() { + this.fjæsing = 22; + console.debug(Object.prototype.hasOwnProperty.call(this, "fjæsing")); + } + + diller(snaps = this.fjæsing) { + console.log(snaps); + } +} + +class Dugga extends Nugga {} + +const n = new Dugga(); + +console.log(n, n.diller(), n instanceof Dugga); diff --git a/utils/random.js b/utils/random.js index 5e1f34c..8b67fc0 100644 --- a/utils/random.js +++ b/utils/random.js @@ -95,6 +95,3 @@ export class Xorshift32 { return num + greaterThanOrEqual; } } - -const rng = new Xorshift32(); -console.log(rng.get()); diff --git a/utils/shallowCopy.js b/utils/shallowCopy.js new file mode 100755 index 0000000..7dbdd3a --- /dev/null +++ b/utils/shallowCopy.js @@ -0,0 +1,42 @@ +/** + * Shallow copy any JS value if it makes sense. + * @param {*} value + * @returns {*} + */ +export default function shallowCopy(value) { + if (value === null || typeof value !== "object") { + // primitives, functions, symbols + return value; + } + + if (Array.isArray(value)) { + return value.slice(); + } + + if (value instanceof Date) { + return new Date(value.getTime()); + } + + if (value instanceof Map) { + return new Map(value); + } + + if (value instanceof Set) { + return new Set(value); + } + + // Plain objects + if (Object.getPrototypeOf(value) === Object.prototype) { + return Object.assign({}, value); + } + + if (typeof value?.clone === "function") { + return value.clone(); + } + + // Fallback: clone prototype + own props + return Object.create( + Object.getPrototypeOf(value), // + Object.getOwnPropertyDescriptors(value), + ); +} diff --git a/utils/callParser.js b/utils/tileOptionsParser.js similarity index 68% rename from utils/callParser.js rename to utils/tileOptionsParser.js index b3fa062..8f0e6eb 100755 --- a/utils/callParser.js +++ b/utils/tileOptionsParser.js @@ -1,7 +1,7 @@ /** A call represents the name of a function as well as the arguments passed to it */ -export class ParsedCall { +export class TileOptions { /** @type {string} Name of the function */ name; - /** @type {ParsedArg[]} Args passed to function */ args; + /** @type {TileArgs[]} Args passed to function */ args; constructor(name, args) { this.name = name; @@ -14,7 +14,7 @@ export class ParsedCall { * @param {string} name * @param {number?} position * - * @returns {ParsedArg|null} + * @returns {TileArgs|null} */ getArg(name, position) { for (let idx in this.args) { @@ -32,10 +32,28 @@ export class ParsedCall { const arg = this.getArg(name, position); return arg ? arg.value : fallbackValue; } + + /** + * @param {boolean} includePositionals Should the result object include numeric entries for the positional arguments? + * @returns {object} object where the keys are the names of the named args, and the values are the values of those args. + */ + getNamedValues(includePositionals = false) { + const result = {}; + + for (const arg of this.args) { + const key = arg.key; + + if (includePositionals || typeof key === "string") { + result[key] = arg; + } + } + + return result; + } } /** An argument passed to a function. Can be positional or named */ -export class ParsedArg { +export class TileArgs { /** @type {string|number} */ key; /** @type {string|number|boolean|null|undefined} */ value; constructor(key, value) { @@ -45,11 +63,11 @@ export class ParsedArg { } /** - * Parse a string that includes a number of function calls separated by ";" semicolons + * Parse a string of options that looks like function calls separated by ";" semicolons * * @param {string} input * - * @returns {ParsedCall[]} + * @returns {TileOptions[]} * * @example * // returns @@ -64,7 +82,7 @@ export class ParsedArg { */ 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) + const pattern = /(\w+)\s*\(([^)]*)\)/gu; let match; while ((match = pattern.exec(input)) !== null) { @@ -72,14 +90,13 @@ export default function parse(input) { 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. + // Hack to allow special characters in option names + // If the option name is "__", then the actual + // option name is given by arg 0, and arg 0 is then + // automatically removed. // // So - // __(foo, 1,2,3) === foo(1,2,3) + // __(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) @@ -88,7 +105,7 @@ export default function parse(input) { name = args.shift().value; } - calls.push(new ParsedCall(name, args)); + calls.push(new TileOptions(name, args)); } return calls; @@ -96,12 +113,12 @@ export default function parse(input) { /** * @param {string} argsStr - * @returns {ParsedArg[]} + * @returns {TileArgs[]} */ function parseArguments(argsStr) { if (!argsStr) return []; - /** @type {ParsedArg[]} */ + /** @type {TileArgs[]} */ const args = []; const tokens = tokenize(argsStr); @@ -109,9 +126,9 @@ function parseArguments(argsStr) { const token = tokens[pos]; const namedMatch = token.match(/^(\w+)=(.+)$/); if (namedMatch) { - args.push(new ParsedArg(namedMatch[1], parseValue(namedMatch[2]))); + args.push(new TileArgs(namedMatch[1], parseValue(namedMatch[2]))); } else { - args.push(new ParsedArg(Number.parseInt(pos), parseValue(token))); + args.push(new TileArgs(Number.parseInt(pos), parseValue(token))); } } @@ -156,7 +173,15 @@ function parseValue(str) { // Try to parse as number if (/^-?\d+(\.\d+)?$/.test(str)) { - return parseFloat(str); + const f = parseFloat(str); + const rounded = Math.round(f); + const diff = Math.abs(rounded - f); + const epsilon = 1e-6; // MAGIC NUMBER + if (diff < epsilon) { + return rounded; + } + + return f; } // Boolean