wahuuga
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
display: inline-block;
|
||||
padding: 2px;
|
||||
border: 5px solid #666;
|
||||
color: #666;
|
||||
/* color: #666; */
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<body>
|
||||
<div id="gameContainer">
|
||||
<div id="viewport"></div>
|
||||
<div id="compass">orientation</div>
|
||||
<div id="status">orientation</div>
|
||||
<div id="minimap"></div>
|
||||
|
||||
<div id="threejs" style="border: 2px solid green; background-color: #222"></div>
|
||||
@@ -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)
|
||||
######################### # ##### # # # # ######## ###
|
||||
######################### # # ###
|
||||
######################### ####################### # ###
|
||||
|
||||
@@ -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(
|
||||
"<div>%s</div><div>%s</div>",
|
||||
this.player._posV,
|
||||
Object.keys(Orientation)[this.player.orientation].toLowerCase(),
|
||||
document.getElementById("status").innerHTML = sprintf(
|
||||
[
|
||||
"<div>",
|
||||
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),
|
||||
"</div>",
|
||||
"<div>",
|
||||
// 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"),
|
||||
"</div>",
|
||||
"<div>",
|
||||
sprintf("Ahead of you is %s", "TODO: more hallway | an enemy | etc"),
|
||||
"</div>",
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string|number,THREE.Texture} Textures - one per unique textureId (i.e. filename) */
|
||||
this.textures = new Map();
|
||||
/** @type {Map<string|number,THREE.Material} Sprite materials - one material per unique sprite texture */
|
||||
this.spriteMaterials = new Map();
|
||||
/** @type {THREE.Sprite[]} All roaming tiles that regularly needs their positions updated */
|
||||
this.roamers = [];
|
||||
|
||||
//
|
||||
this.initMap();
|
||||
@@ -91,9 +87,47 @@ export class FirstPersonRenderer {
|
||||
this.renderFrame();
|
||||
}
|
||||
|
||||
getTexture(textureId) {
|
||||
console.debug("fetching texture", { textureId });
|
||||
let texture = this.textures.get(textureId);
|
||||
if (!texture) {
|
||||
console.debug(" miss... loading texture", { textureId });
|
||||
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.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<number,Array} */
|
||||
this.map.forEach((/** @type {Tile} */ tile, /** @type {number} */ x, /** @type {number} y */ y) => {
|
||||
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");
|
||||
|
||||
203
frontend/ascii_minimap.js
Executable file
203
frontend/ascii_minimap.js
Executable file
@@ -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");
|
||||
}
|
||||
@@ -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<string,Tile} legend
|
||||
*/
|
||||
static fromText(str) {
|
||||
static fromHumanText(str) {
|
||||
str = str.trim();
|
||||
const lines = str.split("\n");
|
||||
/** @type {Array<Array<Tile>>} */
|
||||
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<Tile,ParsedCall>} 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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
0
frontend/eobRedWall.png → frontend/wall.png
Normal file → Executable file
0
frontend/eobRedWall.png → frontend/wall.png
Normal file → Executable file
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@@ -1,97 +1,175 @@
|
||||
export class FunctionCallParser {
|
||||
/**
|
||||
* @typedef {{name: string, args: Array<number|string|boolean|null>}} 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user