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; +}