This commit is contained in:
Kim Ravn Hansen
2025-10-09 15:51:40 +02:00
parent 2bf6272291
commit 1f97ea63e2
20 changed files with 1173 additions and 855 deletions

View File

@@ -41,7 +41,7 @@ export class SourceCell {
potentialNeighbours(other, direction) {
// sadly, we're not allowed to be friends with ourselves.
if (this === other) {
console.log("WTF were checking to be friends with ourselves!", { _this: this, other, direction });
console.warn("WTF were checking to be friends with ourselves!", { _this: this, other, direction });
// throw new Error("WTF were checking to be friends with ourselves!", { _this: this, other, direction });
}
return (

View File

@@ -13,12 +13,12 @@ export class WfcCell {
*/
constructor(i, x, y, options) {
if (!options.length) {
console.log("Bad >>options<< arg in WfcCell constructor. Must not be empty.", options);
console.warn("Bad >>options<< arg in WfcCell constructor. Must not be empty.", options);
throw Error("Bad >>options<< arg in WfcCell constructor. Must not be empty.", options);
}
if (!(options[0] instanceof SourceCell)) {
console.log("Bad >>options<< arg in WfcCell constructor. Must be array of WfcCells, but wasn't.", options);
console.warn("Bad >>options<< arg in WfcCell constructor. Must be array of WfcCells, but wasn't.", options);
throw Error("Bad >>options<< arg in WfcCell constructor. Must be array of WfcCells, but wasn't.", options);
}

View File

@@ -34,7 +34,6 @@ export class WfcGrid {
}
reset() {
console.log("Resetting Cells");
const [w, h] = [this.width, this.height];
const len = w * h;
this.cells = [];
@@ -44,7 +43,6 @@ export class WfcGrid {
this.cells.push(new WfcCell(i, x, y, this.sourceGrid.clone().cells));
}
console.log("Done");
}
/**
@@ -87,7 +85,7 @@ export class WfcGrid {
});
if (this.lowEntropyCellIdCache.length === 0) {
console.log("Found zero lowest-entropy cells.", { entropy: this.lowestEntropy });
console.info("Found zero lowest-entropy cells.", { entropy: this.lowestEntropy });
}
}

View File

@@ -42,8 +42,8 @@
}
#minimap {
grid-area: minimap;
font-size: 12px;
line-height: 11.5px;
font-size: 14px;
line-height: 13px;
white-space: pre;
display: inline-block;
padding: 2px;
@@ -96,46 +96,44 @@
<div id="minimap"></div>
<div id="mapInput">
<textarea id="mapText" rows="10" wrap="off">
############################################################
############################################################
############################################################
## # ################# ########################
#### # ################### # ## ########################
## P Z###############Z # ## ################ ::: P(east) Z(a, b, west) Z(b, a, east) // Comments
## ######################## # ## #### ####
### E # # ## # #### # # #### ::: E(gnoll)
#### ################## ## #### # ####
##### ################### # ## # # #### ####
######E#################### # #### ::: E(Goblins, gnoll) // These are comments
###### #################### ########## #### ####
###### #################### ########## # # #### # # ####
###### #################### ########## #### # # ####
###### #################### #################### ####
###### #################### ############################
###### #################### # ############################
###### #################### # ############################
######E#################### # ############################ ::: E(gnoll)
###### ## ##### ## ############################ :::
###### ## Z#### ## # # ############################ ::: Z(c, d, west)
###### ## ####Z ## ######## ############ ::: Z(d, c, east)
###### ## ## # ########### ## ######## ############
######E## # #E ############ ::: E(Dwarves, gnoll) ; E(Gelatinous_Cube, gnoll)
###### # # # ############
######### # ## ########### # ######### # ############
######### # # ########### # ######### # # ############
######### ########### # ######### ############
###########Z############### # ######### #### ############### ::: Z(e, f, null)
########################### # ######### #### ###############
########################### # ######### #### ###############
########################### # ######### #### ###############
########################### # #### ###############
######################### # #### # # # ######## ###
########################Z # # ######## # ### ::: Z(f, null, east) // you can teleport here, but you cannot teleport back
######################### # ##### # # # # ######## ###
######################### # # ###
######################### ####################### # ###
##################################################### ###
############################################################
#########################################################
# ################# ######################
### # ################### # ## ######################
# P _Z###############Z_ # ## ############## ::: P(east) _(portalA,west) Z(portalB) Z(portalA) _(portalB,west)
# ######################## # ## #### ##
## E ################# # # ## # #### # # ## ::: E(gnoll)
### ################## ## #### # ##
#### ################### # ## # # #### ##
#####E#################### # ## ::: E(skelebones)
##### #################### ########## #### ##
##### #################### ########## # # #### # # ##
##### #################### ########## #### # # ##
##### #################### #################### ##
##### #################### ##########################
##### #################### # ##########################
#####E#################### # ########################## ::: E(gnoll)
##### ###############_#### # ########################## ::: _(portalC, south)
##### ## ##### ## ########################## :::
##### ## Z#### ## # # ########################## ::: Z(portalC)
##### ## _####Z ## ######## ########## ::: _(portalD, west) Z(portalD)
##### ## ## # ########### ## ######## ##########
#####E## # #E ########## ::: E(Dwarves, gnoll) E(Gelatinous_Cube, gnoll)
##### # # # ##########
######## # ## ########### # ######### # ##########
######## # # ########### # ######### # # ##########
######## ########### # ######### ##########
##########Z############### # ######### #### ############# ::: Z(portalE)
########################## # ######### #### #############
########################## # ######### #### #############
########################## # ######### #### #############
########################## # #### #############
######################## # #### # # # ######## #
#######################_ # # ######## # # ::: _e(portalE, east)
######################## # ##### # # # # ######## #
######################## # # #
######################## ####################### # #
#################################################### #
#########################################################
</textarea
>
<button onclick="game.loadMap()">Load Map</button>

View File

@@ -40,6 +40,7 @@ class Player {
}
set orientation(o) {
console.log({ o });
//
// Sanitize o
o = ((o | 0) + 4) % 4;
@@ -137,19 +138,23 @@ class DungeonCrawler {
* @param {number} angle the orientation of the camera in radians around the unit circle.
*/
render(camX = this.player.x, camY = this.player.y, angle = this.player.angle) {
if (!this.rendering.firstPersonRenderer) {
console.log("Renderer not ready yet");
if (!(this.rendering.firstPersonRenderer && this.rendering.firstPersonRenderer.ready)) {
console.warn("Renderer not ready yet");
return;
}
this.rendering.firstPersonRenderer.renderFrame(
camX, // add .5 to get camera into center of cell
camY, // add .5 to get camera into center of cell
angle,
);
queueMicrotask(() => {
this.rendering.firstPersonRenderer.renderFrame(
camX, // add .5 to get camera into center of cell
camY, // add .5 to get camera into center of cell
angle,
);
});
}
renderMinimap() {
this.rendering.miniMapRenderer.draw(this.player.x, this.player.y, this.player.orientation);
queueMicrotask(() => {
this.rendering.miniMapRenderer.draw(this.player.x, this.player.y, this.player.orientation);
});
}
loadMap() {
@@ -158,6 +163,8 @@ class DungeonCrawler {
this.map = TileMap.fromHumanText(mapString);
this.player._posV = this.map.findFirstV({ isStartLocation: true });
this.player.orientation = this.map.findFirstTile({ isStartLocation: true }).orientation;
console.log(this.player);
if (!this.player._posV) {
throw new Error("Could not find a start location for the player");
@@ -236,8 +243,8 @@ class DungeonCrawler {
// Bumping into a door will open/remove it.
// Bumping into stairs will go down/up (requires confirmation, unless disabled)
// Bumping into a wall sconce will pick up the torch (losing the light on the wall, but gaining a torch that lasts for X turns)
// Bumping into a trap activates it.
// Bumping into a treasure opens it.
// Bumping into a trap activates it (or reveals it if someone on the team detects it, or of a detect trap spell is running)
// Bumping into loot reveals it
console.info(
"bumped into %s at %s (mypos: %s), direction=%d",
@@ -328,10 +335,10 @@ class DungeonCrawler {
//
// Guard: stop animation if it took too long
if (this.animation.targetTime <= performance.now()) {
this.animation = {};
this.render(this.player.x, this.player.y, this.player.angle);
this.renderMinimap();
this.renderStatus();
this.animation = {};
return false;
}
@@ -399,7 +406,7 @@ class DungeonCrawler {
renderStatus() {
//
//
// Update the compass
// Update the compass and status
document.getElementById("status").innerHTML = sprintf(
[
"<div>",

View File

@@ -82,23 +82,48 @@ export class FirstPersonRenderer {
/** @type {THREE.Sprite[]} All roaming tiles that regularly needs their positions updated */
this.roamers = [];
/** @type {number} how many asynchronous function returns are we waiting for? */
this.openAsyncs = 0;
/** @type {boolean} Are we ready to render? (have all resources been loaded?) */
this.ready = false;
/** @type {function} called when the renderer is ready and all resources have been loaded */
this.onReady = null;
//
this.initMap();
//
this.renderer.setSize(this.asciiWidth * 1, this.asciiHeight * 1);
this.renderFrame();
const waitForAsyncs = () => {
if (this.ready) {
return;
}
if (this.openAsyncs > 0) {
setTimeout(waitForAsyncs, 100);
return;
}
this.ready = true;
if (typeof this.onReady === "function") {
this.onReady();
return;
}
this.renderFrame();
};
setTimeout(waitForAsyncs, 100);
}
getTexture(textureId) {
console.debug("fetching texture", { textureId });
let texture = this.textures.get(textureId);
if (!texture) {
console.debug(" miss... loading texture", { textureId });
this.openAsyncs++;
texture = new THREE.TextureLoader().load(`${textureId}.png`, (t) => {
t.magFilter = THREE.NearestFilter; // no smoothing when scaling up
t.minFilter = THREE.NearestFilter; // no mipmaps / no smoothing when scaling down
t.generateMipmaps = false; // dont build mipmaps
this.openAsyncs--;
});
this.textures.set(textureId, texture);
}
@@ -111,12 +136,9 @@ export class FirstPersonRenderer {
}
getSpriteMaterial(textureId) {
console.debug("fetching material", { textureId });
let material = this.spriteMaterials.get(textureId);
if (!material) {
console.log("Creating material", { textureId });
material = new THREE.SpriteMaterial({
map: this.getTexture(textureId),
transparent: true,
@@ -150,7 +172,6 @@ export class FirstPersonRenderer {
this.mainCamera.lookAt(x, y - 1, 0);
this.torch.position.copy(this.mainCamera.position);
console.log("Initial Camera Position:", this.mainCamera.position);
return;
}
@@ -184,7 +205,8 @@ export class FirstPersonRenderer {
// ---------------------------
const floorGeo = new THREE.PlaneGeometry(this.map.width, this.map.height);
const floorMat = new THREE.MeshStandardMaterial({
color: this.floorColor /* side: THREE.DoubleSide */,
color: this.floorColor,
/* side: THREE.DoubleSide */
});
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.position.set(this.map.width / 2, this.map.height / 2, -0.5);

View File

@@ -35,8 +35,6 @@ export class MiniMap {
* @param {Orientation} orientation
*/
draw(pX, pY, orientation) {
console.log("Updating minimap", { px: pX, py: pY, orientation });
//
// 2D array of tiles that are visible
const visibleTiles = new Array(this.map.height).fill().map(() => new Array(this.map.width).fill(false));
@@ -151,12 +149,12 @@ export class MiniMap {
invertY = true;
break;
case Orientation.EAST:
invertY = true;
invertX = true;
switchXY = true;
break;
case Orientation.WEST:
switchXY = true;
invertY = true;
invertX = true;
break;
}

View File

@@ -1,7 +1,35 @@
import parseOptions, { ParsedCall } from "../utils/callParser.js";
import { Tile } from "./ascii_tile_types.js";
import parseOptions, { TileOptions } from "../utils/tileOptionsParser.js";
import { Tile, WallTile } from "./ascii_tile_types.js";
import { Vector2i } from "./ascii_types.js";
/**
* @typedef {object} TileWithCoords
* @property {Tile} tile
* @property {number} x
* @property {number} y
*/
/**
* @typedef {Map<number,TileWithCoords>} TileCoordsHashTable
*/
/**
* @callback TileMapForEachCallback
* @param {Tile} tile
* @param {number} x
* @param {number} y
* @returns {undefined|any} If undefined is returned, the looping continues, but if anything else is returned, the loop halts, and the return value is passed along to the caller
*/
/**
* @readonly @constant @enum {string}
*/
export const CharType = {
SYSTEM: "internalMapChar",
MINIMAP: "minimapChar",
MINIMAP_REVEALED: "revealedMinimapChar",
};
export class TileMap {
/**
* @param {string} str
@@ -22,7 +50,6 @@ export class TileMap {
if (y === 0) {
// Infer the width of the map from the first line
mapWidth = tileStr.length;
console.log({ mapWidth });
}
// Create a new row in the 2d tiles array
@@ -32,8 +59,6 @@ export class TileMap {
const options = optionStr ? parseOptions(optionStr) : [];
let lineWidth = 0;
options.length && console.log({ options, y });
tileStr.split("").forEach((char, x) => {
//
// Check if there are options in the queue that matches the current character
@@ -59,26 +84,26 @@ export class TileMap {
/**
* @param {Tile[][]} tiles
* @param {Map<Tile,ParsedCall>} options
*/
constructor(tiles) {
/** @constant @readonly @type {number} */
this.height = tiles.length;
/** @constant @readonly @type {number} */
this.width = tiles[0].length;
/** @constant @readonly @type {Tile[][]} */
this.tiles = tiles;
/** @type {Tile} when probing a coordinate outside the map, this is the tile that is returned */
this.outOfBoundsWall = this.findFirstV({ looksLikeWall: true });
/** @type {number} */ this.height = tiles.length;
/** @type {number} */ this.width = tiles[0].length;
/** @type {Tile[][]} */ this.tiles = tiles;
/** @type {number} */ this.playerStartX = undefined;
/** @type {number} */ this.playerStartT = undefined;
/** @type {Tile} */ this.outOfBoundsWall = this.getReferenceWallTile();
}
toString() {
/**
* @param {CharType} charType
* @returns {string}
*/
toString(charType = CharType.SYSTEM) {
let result = "";
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
const tile = this.tiles[y][x];
result += tile.minimapChar;
result += tile[charType];
}
result += "\n";
}
@@ -96,12 +121,12 @@ export class TileMap {
return this.tiles[y][x];
}
get(x, y) {
get(x, y, outOfBounds = this.outOfBoundsWall) {
x |= 0;
y |= 0;
if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
return this.outOfBoundsWall;
return outOfBounds;
}
return this.tiles[y][x];
@@ -178,7 +203,7 @@ export class TileMap {
* but _stops_ if fn() returns anything but `undefined`,
* and then that return value is returned from `forEach`
*
* @param { (tile, x,y) => any|undefined ) } fn
* @param {TileMapForEachCallback} fn
* @returns any|undefined
*/
forEach(fn) {
@@ -192,23 +217,82 @@ export class TileMap {
}
}
getArea(xMin, yMin, xMax, yMax) {
if (xMin > xMax) {
[xMin, xMax] = [xMax, xMin];
}
if (yMin > yMax) {
[yMin, yMax] = [yMax, yMin];
/**
* @returns {number}
*/
getTraversableTileCount() {
let sum = 0;
this.forEach((tile) => {
if (tile.isTraversable) {
sum++;
}
});
return sum;
}
/**
* @param {number} x
* @param {number} y
* @param {typeof Tile} tileClass
* @returns {TileWithCoords[]}
*/
getCardinalAdjacentTiles(x, y, tileClass) {
/** @type {TileWithCoords[]} */
const result = [];
const testCoords = [
[x + 1, y],
[x - 1, y],
[x, y + 1],
[x, y + 1],
];
for (const [_x, _y] of testCoords) {
const _tile = this.get(_x, _y, false);
if (_tile === false) {
// _x, _y was out of bounds, do not add it to result
continue;
}
if (tileClass && !(_tile instanceof tileClass)) {
// _tile was of invalid type, do not add it to result
continue;
}
result.push({ tile: _tile, x: _x, y: _y });
}
const w = xMax - xMin + 1;
const h = yMax - yMin + 1;
return result;
}
/**
* @param {number} minX
* @param {number} minY
* @param {number} maxX
* @param {number} maxY
*
* @returns {TileMap}
*/
getArea(minX, minY, maxX, maxY) {
if (minX > maxX) {
[minX, maxX] = [maxX, minX];
}
if (minY > maxY) {
[minY, maxY] = [maxY, minY];
}
const w = maxX - minX + 1;
const h = maxY - minY + 1;
let iX = 0;
let iY = 0;
const tiles = new Array(h).fill().map(() => new Array(w));
for (let y = yMin; y <= yMax; y++) {
for (let x = xMin; x <= xMax; x++) {
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const tile = this.tiles[y][x];
if (!tile) {
throw new Error("Dafuqq is happing here?");
@@ -222,11 +306,68 @@ export class TileMap {
return new TileMap(w, h, tiles);
}
getAreaAround(x, y, radius) {
return this.getArea(x - radius, y - radius, x + radius, y + radius);
/**
* @param {number} x
* @param {number} y
* @param {number} manhattanRadius
*/
getAreaAround(x, y, manhattanRadius) {
return this.getArea(
x - manhattanRadius, // minX
y - manhattanRadius, // minY
x + manhattanRadius, // maxX
y + manhattanRadius, // maxY
);
}
/**
* @param {number} startX
* @param {number} startY
* @returns {TileCoordsHashTable}
*/
getAllTraversableTilesConnectedTo(startX, startY) {
/** @type {TileCoordsHashTable} */
const result = new Map();
const allTilesFlat = new Array(this.width * this.height).fill();
this.forEach((tile, x, y) => {
const idx = x + y * this.width;
allTilesFlat[idx] = { tile, x, y };
});
const inspectionStack = [startX + startY * this.width];
while (inspectionStack.length > 0) {
const idx = inspectionStack.pop();
const { tile, x, y } = allTilesFlat[idx];
if (!tile.isTraversable) {
continue; // Can't walk there, move on
}
if (result.has(idx)) {
continue; // Already been here, move on
}
result.set(idx, allTilesFlat[idx]);
// Add neighbors
const [minX, minY] = [1, 1];
const maxX = this.width - 2;
const maxY = this.height - 2;
if (y >= minY) inspectionStack.push(idx - this.width); // up
if (y <= maxY) inspectionStack.push(idx + this.width); // down
if (x >= minX) inspectionStack.push(idx - 1); // left
if (x <= maxX) inspectionStack.push(idx + 1); // right
}
return result;
}
}
if (Math.PI < 0 && ParsedCall) {
if (Math.PI < 0 && TileOptions && WallTile) {
("STFU Linda");
}

View File

@@ -1,105 +1,211 @@
import { ParsedCall } from "../utils/callParser";
import { Orientation, Vector2i } from "./ascii_types";
import { mustBe, mustBeString } from "../utils/mustbe.js";
import shallowCopy from "../utils/shallowCopy.js";
import { TileOptions } from "../utils/tileOptionsParser.js";
import { Orientation, Vector2i } from "./ascii_types.js";
/**
* Array of __internal__ characters used to identify tile types.
* These are __not__ necessarily the characters used to display
* the tile on the minimap - but they are used when serializing
* the maps into a semi-human-readable text-format.
*
* @constant {Record<string,string}
*/
export const TileChars = Object.freeze({
FLOOR: " ",
WALL: "#",
SECRET_PORTAL: "Z",
TELPORTATION_TARGET: "_",
ENCOUNTER_START_POINT: "E",
PLAYER_START_POINT: "P",
});
const REQUIRED_ID = Symbol("REQUIRED_ID");
const REQUIRED_ORIENTATION = Symbol("REQUIRED_ORIENTATION");
const REQUIRED_OCCUPANTS = Symbol("REQUIRED_OCCUPANTS");
/** @type {Record<string,Tile>} */
export const TileTypes = {
[TileChars.FLOOR]: {
minimapChar: "·",
traversable: true,
},
[TileChars.WALL]: {
minimapChar: "█",
minimapColor: "#aaa",
textureId: "wall",
traversable: false,
looksLikeWall: true,
},
[TileChars.SECRET_PORTAL]: {
disguiseAs: TileChars.WALL,
revealedMinimapChar: "Ω",
revealedMinimapColor: "#EE82EE", //purple
revealedTextureId: "secret_portal_revealed",
portalTargetId: REQUIRED_ID,
looksLikeWall: true,
},
[TileChars.TELPORTATION_TARGET]: {
is: TileChars.FLOOR,
id: REQUIRED_ID,
orientation: REQUIRED_ORIENTATION,
disguiseAs: TileChars.FLOOR,
revealedMinimapChar: "𝑥",
revealedMinimapColor: "#EE82EE", // purple
},
[TileChars.ENCOUNTER_START_POINT]: {
is: TileChars.FLOOR, // this is actually just a floor tile that is occupied by an encounter when the map is loaded
encounterId: REQUIRED_ID,
textureId: REQUIRED_ID,
occupants: REQUIRED_OCCUPANTS,
},
[TileChars.PLAYER_START_POINT]: {
is: TileChars.FLOOR,
orientation: REQUIRED_ORIENTATION,
minimapChar: "▤", // stairs/ladder
minimapColor: "#FFF",
},
};
export class Tile {
/** @type {string|number} What is the id of this tile - only interactive tiles have IDs */
/** @readonly {string?|number?} Unique (but optional) instance if of this tile */
id;
/** @type {string} Icon char of tile */
minimapChar;
/** @type {string} Icon char of tile after tile's secrets have been revealed */
revealedMinimapChar;
/** @type {string} Icon of tile */
/** @type {string} Color of the icon of tile */
minimapColor;
/** @type {string} Icon char of tile after tile's secrets have been revealed */
revealedMinimapColor;
/** @type {boolean} Can the player walk here? */
isTraversable;
/** @type {boolean} Should this be rendered as a wall? */
looksLikeWall;
/** @type {boolean} Is this where they player starts? */
isStartLocation;
/** @type {boolean} Is this a portal exit and/or entry */
isPortal;
/** @type {string|number} Where is the player transported if they enter the portal */
portalTargetId;
/** @type {boolean} Should this be rendered as a wall? */
looksLikeWall;
/** @type {boolean} Can the player walk here? */
isTraversable;
/** @type {boolean} is this tile occupied by an encounter? */
isEncounter;
/** @type {boolean} Is this where they player starts? */
isStartLocation;
/** @type {boolean} Has the secret properties of this tile been revealed? */
isRevealed;
/** @type {string|number} */
hasBumpEvent;
/** @type {string|number} The portals "channel" - each tile in a portal pair must have the same channel */
channel;
/** @type {number|string} id of texture to use */
textureId;
/** @type {number|string} id of texture to use after the secrets of this tile has been revealed */
revealedTextureId;
/** @type {number|string} type of encounter located on this tile. May or may not be unique*/
encounterType;
/** @type {boolean} Can/does this tile wander around on empty tiles? */
isRoaming;
/** @type {number|string} type of trap located on this tile. May or may not be unique*/
trapType;
/** @type {Orientation} */
orientation;
/** @type {number} If this is a roaming tile, what is its current x-position on the map */
currentPosX;
/** @type {number} If this is a roaming tile, what is its current y-position on the map*/
currentPosY;
/** @type {TileType} This tile disguises itself as another tile, and its true properties are revealed later if event is triggered */
disguiseAs;
/** @type {boolean} Has the secret properties of this tile been revealed? */
revealed;
/** @type {string} Icon char of tile after tile's secrets have been revealed */
revealedMinimapChar;
/** @type {string} Color of the icon char of tile after tile's secrets have been revealed */
revealedMinimapColor;
/** @type {number|string} id of texture to use after the secrets of this tile has been revealed */
revealedTextureId;
static wallMinimapChar = "█";
/** @param {Tile} properties */
constructor(properties) {
mustBe(properties, "object");
/** @param {Tile} options */
constructor(options = {}) {
for (let [k, v] of Object.entries(options)) {
if (this[k] !== undefined) {
this[k] = v;
//
// Copy props from properties.
//
for (const [key, val] of Object.entries(properties)) {
if (typeof val === "symbol" && val.description.startsWith("REQUIRED_")) {
console.error(
[
"REQUIRED_ symbol encountered in Tile constructor. ",
"REQUIRED_ is a placeholder, and cannot be used as a value directly",
].join("\n"),
{ key, val, options: properties },
);
throw new Error("Incomplete data in constructor. Args may not contain a data placeholder");
}
if (!Object.hasOwn(this, key) /* Object.prototype.hasOwnProperty.call(this, key) */) {
console.warn("Unknown tile property", { key, val, properties });
}
}
//
// If this tile is disguised, copy its attributes, but
// do not overwrite own attributes.
//
if (this.disguiseAs !== undefined) {
this.revealed = false;
const other = shallowCopy(TileTypes[this.is]);
for (const [pKey, pVal] of Object.entries(other)) {
if (this.key !== undefined) {
this[pKey] = pVal;
}
}
}
//
// If this tile "inherits" properties from another tile type,
// copy those properties, but do not overwrite own attributes.
//
if (this.is !== undefined) {
//
const other = shallowCopy(TileTypes[this.is]);
for (const [pKey, pVal] of Object.entries(other)) {
if (this.key !== undefined) {
this[pKey] = pVal;
}
}
}
//
// Normalize Orientation
//
if (this.orientation !== undefined && typeof this.orientation === "string") {
const valueMap = {
north: Orientation.NORTH,
south: Orientation.SOUTH,
east: Orientation.EAST,
west: Orientation.WEST,
};
this.orientation = mustBeString(valueMap[this.orientation.toLowerCase()]);
}
if (this.id !== undefined) {
mustBe(this.id, "number", "string");
}
if (this.textureId !== undefined) {
mustBe(this.textureId, "number", "string");
}
if (this.portalTargetId !== undefined) {
mustBe(this.portalTargetId, "number", "string");
}
}
/**
* @param {string} char
* @param {ParsedCall} opt Options
* @param {TileOptions} options Options
* @param {number} x
* @param {number} y
*/
static fromChar(char, opt, x, y) {
opt = opt ?? new ParsedCall();
if (!(opt instanceof ParsedCall)) {
console.error("Invalid options", { char, opt: opt });
static fromChar(char, options) {
//
// Validate Options
options = options ?? new TileOptions();
if (!(options instanceof TileOptions)) {
console.error("Invalid options", { char, opt: options });
throw new Error("Invalid options");
}
if (char === " ") return new FloorTile();
if (char === "#") return new WallTile();
if (char === "P") return new PlayerStartTile(opt.getValue("orientation", 0));
if (char === "E")
return new EncounterTile(x, y, opt.getValue("encounterType", 0), opt.getValue("textureId", 1));
if (char === "Z")
return new SecretPortalTile(
opt.getValue("id", 0),
opt.getValue("destinationid", 1),
opt.getValue("orientation", 3),
);
console.warn("Unknown character", { char, options: opt });
return new FloorTile();
}
const typeInfo = TileTypes[char];
hasTexture() {
if (typeof this.textureId === "number") {
return true;
let optionPos = 0;
const creationArgs = {};
const getOption = (name) => options.getValue(name, optionPos++);
for (let [key, val] of Object.entries(typeInfo)) {
//
const fetchFromOption = typeof val === "symbol" && val.descript.startsWith("REQUIRED_");
creationArgs[key] = fetchFromOption ? getOption(key) : shallowCopy(val);
}
if (typeof this.textureId === "string" && this.textureId !== "") {
return true;
}
return false;
}
getBumpEvent() {
return null;
}
clone() {
@@ -107,77 +213,6 @@ export class Tile {
}
}
export class FloorTile extends Tile {
isTraversable = true;
minimapChar = "·";
minimapColor = "#555";
internalMapChar = " ";
}
export class PlayerStartTile extends Tile {
isTraversable = true;
isStartLocation = true;
minimapChar = "▤"; // stairs
orientation = Orientation.NORTH;
/** @param {Orientation} orientation */
constructor(orientation) {
super({ orientation });
}
}
export class WallTile extends Tile {
textureId = "wall";
isTraversable = false;
looksLikeWall = true;
internalMapChar = "#";
minimapChar = Tile.wallMinimapChar;
minimapColor = "#aaa";
}
export class EncounterTile extends Tile {
isEncounter = true;
isRoaming = true;
minimapChar = "†";
minimapColor = "#f44";
hasBumpEvent = true;
/**
* @param {number} x x-component of the encounter's initial position
* @param {number} y y-component of the encounter's initial position
* @param {string|number} encounterType name/id of the encounter that will be triggered when player bumps into this tile
* @param {string|number} textureId id of the texture to use.
*/
constructor(x, y, encounterType, textureId) {
super();
this.textureId = textureId ?? encounterType;
this.encounterType = encounterType;
this.currentPosX = x;
this.currentPosY = y;
this.id = `E_${encounterType}_${x}_${y}`;
console.info("creating encounter", { encounter: this });
}
getBumpEvent() {
return ["attack", { encounterType: this.encounterType }];
}
}
export class SecretPortalTile extends WallTile {
revealedTextureId = "secretTwoWayPortal";
isPortal = true;
internalMapChar = "Z";
isRevealed = false;
revealedMinimapChar = "Ω";
revealedMinimapColor = "#4f4";
// Change minimap char once the tile's secret has been uncovered.
constructor(id, portalTargetId, orientation) {
super({ id, portalTargetId, orientation });
}
}
if (Math.PI < 0 && ParsedCall && Orientation && Vector2i) {
if (Math.PI < 0 && TileOptions && Orientation && Vector2i) {
("STFU Linda");
}

View File

@@ -4,16 +4,47 @@ export const PI_OVER_TWO = Math.PI / 2;
* Enum Cardinal Direction (east north west south)
* @constant @readonly @enum {number}
*/
export const Orientation = {
export class Orientation {
/** @constant @readonly @type {number} */
EAST: 0,
static EAST = 0;
/** @constant @readonly @type {number} */
SOUTH: 1,
static SOUTH = 1;
/** @constant @readonly @type {number} */
WEST: 2,
static WEST = 2;
/** @constant @readonly @type {number} */
NORTH: 3,
};
static NORTH = 3;
/**
* @param {string} str
* @returns {Orientation}
*/
static fromString(str) {
if (typeof str !== "string") {
console.error(
"Invalid data type when converting string to orientation. >>str<< is not a string be string.",
{ str },
);
return undefined;
}
str = str.toLowerCase();
if (str === "east") return Orientation.EAST;
if (str === "west") return Orientation.WEST;
if (str === "north") return Orientation.NORTH;
if (str === "south") return Orientation.SOUTH;
}
/**
* @param {string|number} val
* @returns {Orientation}
*/
static normalize(val) {
if (typeof val === "string") {
return Orientation.fromString(val);
}
return val % 4;
}
}
/**
* Enum Relative Direction (forward, left, right, backwards)
@@ -190,3 +221,7 @@ export class Vector2i {
return `[${this.x} , ${this.y}]`;
}
}
const o = Orientation.fromString("south");
console.log(o);

View File

@@ -76,8 +76,6 @@ class MUDClient {
// TODO Fix. Port should not be hardcoded
const wsUrl = `${protocol}//${window.location.host}`.replace(/:\d+$/, ":3000");
console.log(wsUrl);
this.updateStatus("Connecting...", "connecting");
try {
@@ -106,7 +104,7 @@ class MUDClient {
};
this.websocket.onerror = (error) => {
console.log("Websocket error", error);
console.warn("Websocket error", error);
this.updateStatus("Connection Error", "error");
this.writeToOutput("Connection error occurred. Retrying...", { class: "error" });
};
@@ -137,7 +135,7 @@ class MUDClient {
* @param {...any} rest
*/
send(messageType, ...args) {
console.log("sending", messageType, args);
console.debug("sending", messageType, args);
if (args.length === 0) {
this.websocket.send(JSON.stringify([messageType]));
@@ -202,7 +200,6 @@ class MUDClient {
// The quit command has its own message type
let help = helpRegex.exec(inputText);
if (help) {
console.log("here");
help[1] ? this.send(MessageType.HELP, help[1].trim()) : this.send(MessageType.HELP);
this.echo(inputText);
return;

View File

@@ -1,573 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ASCII Dungeon Generator</title>
<style>
body {
font-family: "Courier New", monospace;
background-color: #1a1a1a;
color: #00ff00;
margin: 0;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
text-align: center;
color: #00ff00;
text-shadow: 0 0 10px #00ff00;
}
.controls {
text-align: center;
margin-bottom: 20px;
}
button {
background-color: #333;
color: #00ff00;
border: 2px solid #00ff00;
padding: 10px 20px;
margin: 5px;
cursor: pointer;
font-family: "Courier New", monospace;
font-size: 14px;
transition: all 0.3s;
}
button:hover {
background-color: #00ff00;
color: #1a1a1a;
box-shadow: 0 0 10px #00ff00;
}
.dungeon-display {
background-color: #000;
border: 2px solid #00ff00;
padding: 15px;
font-size: 12px;
line-height: 1;
overflow-x: auto;
white-space: pre;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.3);
}
.settings {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.setting {
display: flex;
align-items: center;
gap: 5px;
}
input[type="range"] {
background-color: #333;
}
label {
color: #00ff00;
font-size: 14px;
}
.legend {
margin-top: 20px;
padding: 15px;
background-color: #222;
border: 1px solid #444;
border-radius: 5px;
}
.legend h3 {
margin-top: 0;
color: #00ff00;
}
.legend-item {
margin: 5px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>⚔️ ASCII DUNGEON GENERATOR ⚔️</h1>
<div class="settings">
<div class="setting">
<label for="width">Width:</label>
<input type="range" id="width" min="40" max="100" value="60" />
<span id="widthValue">60</span>
</div>
<div class="setting">
<label for="height">Height:</label>
<input type="range" id="height" min="30" max="60" value="40" />
<span id="heightValue">40</span>
</div>
<div class="setting">
<label for="roomCount">Rooms:</label>
<input type="range" id="roomCount" min="5" max="20" value="10" />
<span id="roomCountValue">10</span>
</div>
</div>
<div class="controls">
<button onclick="generateDungeon()">Generate New Dungeon</button>
<button onclick="downloadDungeon()">Download as Text</button>
</div>
<div class="dungeon-display" id="dungeonDisplay"></div>
<div class="legend">
<h3>Legend:</h3>
<div class="legend-item"><strong>#</strong> - Wall</div>
<div class="legend-item"><strong>.</strong> - Floor</div>
<div class="legend-item"><strong>+</strong> - Door</div>
<div class="legend-item"><strong>@</strong> - Player Start</div>
<div class="legend-item"><strong>$</strong> - Treasure</div>
<div class="legend-item"><strong>!</strong> - Monster</div>
<div class="legend-item"><strong>^</strong> - Trap</div>
</div>
</div>
<script>
class Tile {
static FLOOR = new Tile(" ", true);
static RESERVED = new Tile(" ", true);
static PILLAR = new Tile("◯", false);
// static TRAP = new Tile("◡", true);
static TRAP = new Tile("☠", true);
static MONSTER = new Tile("!", true);
static WALL = new Tile("#", false);
static PLAYER_START = new Tile("@", true);
static DOOR = new Tile("░", true);
/**
* @param {string} the utf-8 character that symbolizes this tile on the map
* @param {boolean} walkable can adventurers walk on this tile
*/
constructor(symbol, walkable = false) {
this.symbol = symbol;
this.walkable = walkable;
}
toString() {
return this.symbol;
}
}
class DungeonGenerator {
constructor(width, height, roomCount) {
this.width = width;
this.height = height;
this.roomCount = roomCount;
this.grid = [];
this.rooms = [];
this.corridors = [];
}
generate() {
this.initializeGrid();
this.generateRooms();
this.connectRooms();
this.addDoors();
this.addPillarsToBigRooms();
this.addFeatures();
this.checkAccessibility();
return this.gridToString();
}
initializeGrid() {
this.grid = Array(this.height)
.fill()
.map(() => Array(this.width).fill(Tile.WALL));
}
generateRooms() {
this.rooms = [];
const maxAttempts = this.roomCount * 10;
let attempts = 0;
while (this.rooms.length < this.roomCount && attempts < maxAttempts) {
const room = this.generateRoom();
if (room && !this.roomOverlaps(room)) {
this.rooms.push(room);
this.carveRoom(room);
}
attempts++;
}
}
generateRoom() {
const minSize = 4;
const maxSize = Math.min(12, Math.floor(Math.min(this.width, this.height) / 4));
const width = this.random(minSize, maxSize);
const height = this.random(minSize, maxSize);
const x = this.random(1, this.width - width - 1);
const y = this.random(1, this.height - height - 1);
return { x, y, width, height };
}
roomOverlaps(newRoom) {
return this.rooms.some(
(room) =>
newRoom.x < room.x + room.width + 2 &&
newRoom.x + newRoom.width + 2 > room.x &&
newRoom.y < room.y + room.height + 2 &&
newRoom.y + newRoom.height + 2 > room.y,
);
}
carveRoom(room) {
for (let y = room.y; y < room.y + room.height; y++) {
for (let x = room.x; x < room.x + room.width; x++) {
this.grid[y][x] = Tile.FLOOR;
}
}
}
connectRooms() {
if (this.rooms.length < 2) return;
// Connect each room to at least one other room
for (let i = 1; i < this.rooms.length; i++) {
const roomA = this.rooms[i - 1];
const roomB = this.rooms[i];
this.createCorridor(roomA, roomB);
}
// Add some extra connections for more interesting layouts
const extraConnections = Math.floor(this.rooms.length / 3);
for (let i = 0; i < extraConnections; i++) {
const roomA = this.rooms[this.random(0, this.rooms.length - 1)];
const roomB = this.rooms[this.random(0, this.rooms.length - 1)];
if (roomA !== roomB) {
this.createCorridor(roomA, roomB);
}
}
}
createCorridor(roomA, roomB) {
const startX = Math.floor(roomA.x + roomA.width / 2);
const startY = Math.floor(roomA.y + roomA.height / 2);
const endX = Math.floor(roomB.x + roomB.width / 2);
const endY = Math.floor(roomB.y + roomB.height / 2);
// Create L-shaped corridor
if (Math.random() < 0.5) {
// Horizontal first, then vertical
this.carveLine(startX, startY, endX, startY);
this.carveLine(endX, startY, endX, endY);
} else {
// Vertical first, then horizontal
this.carveLine(startX, startY, startX, endY);
this.carveLine(startX, endY, endX, endY);
}
}
carveLine(x1, y1, x2, y2) {
const dx = Math.sign(x2 - x1);
const dy = Math.sign(y2 - y1);
let x = x1;
let y = y1;
while (x !== x2 || y !== y2) {
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
this.grid[y][x] = Tile.FLOOR;
}
if (x !== x2) x += dx;
if (y !== y2 && x === x2) y += dy;
}
// Ensure endpoint is carved
if (x2 >= 0 && x2 < this.width && y2 >= 0 && y2 < this.height) {
this.grid[y2][x2] = Tile.FLOOR;
}
}
addDoors() {
this.rooms.forEach((room) => {
const doors = [];
// Check each wall of the room for potential doors
for (let x = room.x; x < room.x + room.width; x++) {
// Top wall
if (
room.y > 0 &&
this.grid[room.y - 1][x] === Tile.FLOOR &&
this.grid[room.y][x] === Tile.FLOOR
) {
doors.push({ x, y: room.y });
}
// Bottom wall
if (
room.y + room.height < this.height &&
this.grid[room.y + room.height][x] === Tile.FLOOR &&
this.grid[room.y + room.height - 1][x] === Tile.FLOOR
) {
doors.push({ x, y: room.y + room.height - 1 });
}
}
for (let y = room.y; y < room.y + room.height; y++) {
// Left wall
if (
room.x > 0 &&
this.grid[y][room.x - 1] === Tile.FLOOR &&
this.grid[y][room.x] === Tile.FLOOR
) {
doors.push({ x: room.x, y });
}
// Right wall
if (
room.x + room.width < this.width &&
this.grid[y][room.x + room.width] === Tile.FLOOR &&
this.grid[y][room.x + room.width - 1] === Tile.FLOOR
) {
doors.push({ x: room.x + room.width - 1, y });
}
}
// Add a few doors randomly
doors.forEach((door) => {
if (Math.random() < 0.3) {
this.grid[door.y][door.x] = Tile.DOOR;
}
});
});
}
addPillarsToBigRooms() {
const walkabilityCache = [];
let i = 0;
for (let y = 1; y < this.height - 1; y++) {
//
for (let x = 1; x < this.width - 1; x++) {
i++;
const cell = this.grid[y][x];
if (!cell) {
console.log("out of bounds [%d, %d] (%s)", x, y, typeof cell);
continue;
}
if (this.grid[y][x].walkable) {
walkabilityCache.push([i, x, y]);
}
}
}
const shuffle = (arr) => {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); // random index 0..i
[arr[i], arr[j]] = [arr[j], arr[i]]; // swap
}
return arr;
};
shuffle(walkabilityCache);
for (let [i, x, y] of walkabilityCache) {
const walkable = (offsetX, offsetY) => {
const c = this.grid[y + offsetY][x + offsetX];
return c.walkable;
};
const surroundingFloorCount =
0 +
// top row ------------|-----------
walkable(-1, -1) + // | north west
walkable(+0, -1) + // | north
walkable(+1, -1) + // | north east
// middle row ---------|-----------
walkable(-1, +0) + // | west
// | self
walkable(+1, +0) + // | east
// bottom row ---------|-----------
walkable(-1, +1) + // | south west
walkable(+0, +1) + // | south
walkable(+1, +1); // | south east
// ----------------------------|-----------
if (surroundingFloorCount === 8) {
this.grid[y][x] = Tile.PILLAR;
continue;
}
if (surroundingFloorCount >= 7) {
this.grid[y][x] = Tile.WALL;
}
}
}
// Check that all rooms are accessibly from the start
checkAccessibility() {
let playerStartIdx;
let walkableTileCount = 0;
const walkabilityCache = [];
// Create a flat linear version of the grid, consisting
// only of booleans: true of passable, false if obstacle.
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
const cell = this.grid[y][x];
const isObstacle = cell === Tile.WALL || cell === Tile.PILLAR;
if (cell === Tile.PLAYER_START) {
playerStartIdx = walkabilityCache.length;
}
if (!isObstacle) {
walkableTileCount++;
walkabilityCache.push(true);
} else {
walkabilityCache.push(false);
}
}
}
const toXy = (idx) => {
return [idx % this.width, Math.floor(idx / this.width)];
};
const stack = [playerStartIdx];
/** @type {Set} */
const visited = new Set();
while (stack.length > 0) {
const idx = stack.pop();
if (!walkabilityCache[idx]) {
continue;
}
if (visited.has(idx)) {
continue;
}
visited.add(idx);
// Add neighbors
const [x, y] = toXy(idx);
const [minX, minY] = [1, 1];
const maxX = this.width - 2;
const maxY = this.height - 2;
if (y >= minY) stack.push(idx - this.width); // up
if (y <= maxY) stack.push(idx + this.width); // down
if (x >= minX) stack.push(idx - 1); // left
if (x <= maxX) stack.push(idx + 1); // right
}
if (visited.size !== walkableTileCount) {
console.log(
"unpassable! There are %d floor tiles, but the player can only visit %d of them",
walkableTileCount,
visited.size,
);
}
}
//
//
addFeatures() {
const floorTiles = [];
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
if (this.grid[y][x] === Tile.FLOOR) {
floorTiles.push({ x, y });
}
}
}
if (floorTiles.length === 0) return;
// Add player start
const playerStart = floorTiles[this.random(0, floorTiles.length - 1)];
this.grid[playerStart.y][playerStart.x] = "@";
// Add treasures
const treasureCount = Math.min(3, Math.floor(this.rooms.length / 2));
for (let i = 0; i < treasureCount; i++) {
const pos = floorTiles[this.random(0, floorTiles.length - 1)];
if (this.grid[pos.y][pos.x] === Tile.FLOOR) {
this.grid[pos.y][pos.x] = "$";
}
}
// Add monsters
const monsterCount = Math.min(5, this.rooms.length);
for (let i = 0; i < monsterCount; i++) {
const pos = floorTiles[this.random(0, floorTiles.length - 1)];
if (this.grid[pos.y][pos.x] === Tile.FLOOR) {
this.grid[pos.y][pos.x] = Tile.MONSTER;
}
}
// Add traps
const trapCount = Math.floor(floorTiles.length / 30);
for (let i = 0; i < trapCount; i++) {
const pos = floorTiles[this.random(0, floorTiles.length - 1)];
if (this.grid[pos.y][pos.x] === Tile.FLOOR) {
this.grid[pos.y][pos.x] = Tile.TRAP;
}
}
}
gridToString() {
return this.grid.map((row) => row.join("")).join("\n");
}
random(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
}
let currentDungeon = "";
function generateDungeon() {
const width = parseInt(document.getElementById("width").value);
const height = parseInt(document.getElementById("height").value);
const roomCount = parseInt(document.getElementById("roomCount").value);
const generator = new DungeonGenerator(width, height, roomCount);
currentDungeon = generator.generate();
document.getElementById("dungeonDisplay").textContent = currentDungeon;
}
function downloadDungeon() {
if (!currentDungeon) {
generateDungeon();
}
const blob = new Blob([currentDungeon], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "dungeon_map.txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Update slider value displays
document.getElementById("width").addEventListener("input", function () {
document.getElementById("widthValue").textContent = this.value;
});
document.getElementById("height").addEventListener("input", function () {
document.getElementById("heightValue").textContent = this.value;
});
document.getElementById("roomCount").addEventListener("input", function () {
document.getElementById("roomCountValue").textContent = this.value;
});
// Generate initial dungeon
generateDungeon();
</script>
</body>
</html>

133
frontend/dungeon_studio.html Executable file
View File

@@ -0,0 +1,133 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ASCII Dungeon Generator</title>
<style>
body {
font-family: "Courier New", monospace;
background-color: #9a9;
color: #aba;
margin: 0;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
text-align: center;
color: #00ff00;
text-shadow: 0 0 10px #00ff00;
}
.controls {
text-align: center;
margin-bottom: 20px;
}
button {
background-color: #333;
color: #00ff00;
border: 2px solid #00ff00;
padding: 10px 20px;
margin: 5px;
cursor: pointer;
font-family: "Courier New", monospace;
font-size: 14px;
transition: all 0.3s;
}
button:hover {
background-color: #00ff00;
color: #1a1a1a;
box-shadow: 0 0 10px #00ff00;
}
.dungeon-display {
font-family: "Courier New", monospace;
background-color: #000;
border: 2px solid #777;
padding: 15px;
font-size: 15px;
line-height: 17px;
overflow-x: auto;
white-space: pre;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.3);
}
.settings {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.setting {
display: flex;
align-items: center;
gap: 5px;
}
input[type="range"] {
background-color: #333;
}
label {
color: #00ff00;
font-size: 14px;
}
.legend {
margin-top: 20px;
padding: 15px;
background-color: #222;
border: 1px solid #444;
border-radius: 5px;
}
.legend h3 {
margin-top: 0;
color: #1f1;
}
.legend-item {
margin: 5px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>⚔️ ASCII DUNGEON GENERATOR ⚔️</h1>
<div class="settings">
<div class="setting">
<label for="width">Width:</label>
<input type="range" id="width" min="40" max="100" value="69" />
<span id="widthValue">69</span>
</div>
<div class="setting">
<label for="height">Height:</label>
<input type="range" id="height" min="30" max="60" value="42" />
<span id="heightValue">42</span>
</div>
<div class="setting">
<label for="roomCount">Rooms:</label>
<input type="range" id="roomCount" min="5" max="20" value="18" />
<span id="roomCountValue">18</span>
</div>
</div>
<div class="controls">
<button onclick="generateDungeon()">Generate New Dungeon</button>
<button onclick="downloadDungeon()">Download as Text</button>
</div>
<div class="dungeon-display" id="dungeonDisplay" contenteditable="true"></div>
<div class="legend">
<h3>Legend:</h3>
<div class="legend-item"><strong>#</strong> - Wall</div>
<div class="legend-item"><strong>.</strong> - Floor</div>
<div class="legend-item"><strong>+</strong> - Door</div>
<div class="legend-item"><strong>@</strong> - Player Start</div>
<div class="legend-item"><strong>$</strong> - Treasure</div>
<div class="legend-item"><strong>!</strong> - Monster</div>
<div class="legend-item"><strong>^</strong> - Trap</div>
</div>
</div>
<script type="module" src="./dungeon_studio.js"></script>
</body>
</html>

446
frontend/dungeon_studio.js Executable file
View File

@@ -0,0 +1,446 @@
import { CharType, TileMap } from "./ascii_tile_map";
import { EncounterTile, FloorTile, PlayerStartTile, TrapTile, LootTile, WallTile } from "./ascii_tile_types";
import { Orientation } from "./ascii_types";
class DungeonGenerator {
constructor(width, height, roomCount) {
this.roomCount = roomCount;
this.rooms = [];
this.corridors = [];
// 2d array of pure wall tiles
const tiles = new Array(height).fill().map(() => Array(width).fill(new WallTile()));
this.map = new TileMap(tiles);
}
get width() {
return this.map.width;
}
get height() {
return this.map.height;
}
generate() {
this.generateRooms();
this.connectRooms();
this.trimMap();
this.addPillarsToBigRooms();
this.addFeatures();
this.addPlayerStart();
this.addPortals();
return this.map.toString(CharType.MINIMAP_REVEALED);
}
generateRooms() {
this.rooms = [];
const maxAttempts = this.roomCount * 10;
let attempts = 0;
while (this.rooms.length < this.roomCount && attempts < maxAttempts) {
const room = this.generateRoom();
if (room && !this.roomOverlaps(room)) {
this.rooms.push(room);
this.carveRoom(room);
}
attempts++;
}
}
generateRoom() {
const minSize = 4;
const maxSize = Math.min(12, Math.floor(Math.min(this.width, this.height) / 4));
const width = this.random(minSize, maxSize);
const height = this.random(minSize, maxSize);
const x = this.random(1, this.width - width - 1);
const y = this.random(1, this.height - height - 1);
return { x, y, width, height };
}
roomOverlaps(newRoom) {
return this.rooms.some(
(room) =>
newRoom.x < room.x + room.width + 2 &&
newRoom.x + newRoom.width + 2 > room.x &&
newRoom.y < room.y + room.height + 2 &&
newRoom.y + newRoom.height + 2 > room.y,
);
}
carveRoom(room) {
for (let y = room.y; y < room.y + room.height; y++) {
for (let x = room.x; x < room.x + room.width; x++) {
this.map.tiles[y][x] = new FloorTile();
}
}
}
connectRooms() {
if (this.rooms.length < 2) return;
// Connect each room to at least one other room
for (let i = 1; i < this.rooms.length >> 1; i++) {
const roomA = this.rooms[i - 1];
const roomB = this.rooms[i];
this.createCorridor(roomA, roomB);
}
// Add some extra connections for more interesting layouts
const extraConnections = Math.floor(this.rooms.length / 3);
for (let i = 0; i < extraConnections; i++) {
const roomA = this.rooms[this.random(0, this.rooms.length - 1)];
const roomB = this.rooms[this.random(0, this.rooms.length - 1)];
if (roomA !== roomB) {
this.createCorridor(roomA, roomB);
}
}
}
// Remove unnecessary walls that frame the rooms
// The dungeon should only be framed by a single
// layer of walls
trimMap() {
let dungeonStartY = undefined;
let dungeonEndY = 0;
let dungeonStartX = this.width; // among all rows, when did we first see a non-wall tile on the west-side of the map?
let dungeonEndX = 0; // among all rows, when did we last see a non-wall tile on the east-side of the map?
for (let y = 0; y < this.height; y++) {
//
let firstNonWallX = undefined; // x-index of the FIRST (westmost) non-wall tile that we encountered on this row
let lastNonWallX = undefined; // x-index of the LAST (eastmost) non-wall tile that we encountered on this row
for (let x = 0; x < this.width; x++) {
const isWall = this.map.get(x, y) instanceof WallTile;
if (isWall) {
continue;
}
if (firstNonWallX === undefined) {
firstNonWallX = x;
}
lastNonWallX = x;
}
const onlyWalls = lastNonWallX === undefined;
if (onlyWalls) {
continue;
}
//
// X-axis bookkeeping
if (dungeonStartX > 0 && lastNonWallX < this.width) {
dungeonStartX = Math.min(dungeonStartX, firstNonWallX);
dungeonEndX = Math.max(dungeonEndX, lastNonWallX);
}
//
// Y-Axis bookkeeping
if (dungeonStartY === undefined) {
dungeonStartY = y;
}
dungeonEndY = y;
}
const newWidth = dungeonEndX - dungeonStartX + 3;
const newTiles = [];
// First row is all walls
newTiles.push(new Array(newWidth).fill(new WallTile()));
// Populate the new grid
for (let y = dungeonStartY; y <= dungeonEndY; y++) {
const row = [];
row.push(new WallTile()); // Initial wall tile on this row
for (let x = dungeonStartX; x <= dungeonEndX; x++) {
/**/
const tile = this.map.get(x, y);
row.push(tile);
}
row.push(new WallTile()); // Final wall tile on this row
newTiles.push(row);
}
// Final row is all walls
newTiles.push(new Array(newWidth).fill(new WallTile()));
this.map = new TileMap(newTiles);
}
createCorridor(roomA, roomB) {
const startX = Math.floor(roomA.x + roomA.width / 2);
const startY = Math.floor(roomA.y + roomA.height / 2);
const endX = Math.floor(roomB.x + roomB.width / 2);
const endY = Math.floor(roomB.y + roomB.height / 2);
// Create L-shaped corridor
if (Math.random() < 0.5) {
// Horizontal first, then vertical
this.carveLine(startX, startY, endX, startY);
this.carveLine(endX, startY, endX, endY);
} else {
// Vertical first, then horizontal
this.carveLine(startX, startY, startX, endY);
this.carveLine(startX, endY, endX, endY);
}
}
carveLine(x1, y1, x2, y2) {
const dx = Math.sign(x2 - x1);
const dy = Math.sign(y2 - y1);
let x = x1;
let y = y1;
while (x !== x2 || y !== y2) {
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
this.map.tiles[y][x] = new FloorTile();
}
if (x !== x2) x += dx;
if (y !== y2 && x === x2) y += dy;
}
// Ensure endpoint is carved
if (x2 >= 0 && x2 < this.width && y2 >= 0 && y2 < this.height) {
this.map.tiles[y2][x2] = new FloorTile();
}
}
addPillarsToBigRooms() {
const walkabilityCache = [];
for (let y = 1; y < this.height - 1; y++) {
//
for (let x = 1; x < this.width - 1; x++) {
const cell = this.map.get(x, y);
if (!cell) {
console.warn("out of bounds [%d, %d] (%s)", x, y, typeof cell);
continue;
}
if (this.map.isTraversable(x, y)) {
walkabilityCache.push([x, y]);
}
}
}
const shuffle = (arr) => {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); // random index 0..i
[arr[i], arr[j]] = [arr[j], arr[i]]; // swap
}
return arr;
};
shuffle(walkabilityCache);
for (let [x, y] of walkabilityCache) {
//
const walkable = (offsetX, offsetY) => this.map.isTraversable(x + offsetX, y + offsetY);
const surroundingFloorCount =
0 +
// top row ------------|-----------
walkable(-1, -1) + // | north west
walkable(+0, -1) + // | north
walkable(+1, -1) + // | north east
// middle row ---------|-----------
walkable(-1, +0) + // | west
// | self
walkable(+1, +0) + // | east
// bottom row ---------|-----------
walkable(-1, +1) + // | south west
walkable(+0, +1) + // | south
walkable(+1, +1); // | south east
// ----------------------------|-----------
if (surroundingFloorCount >= 7) {
// MAGIC NUMBER 7
this.map.tiles[y][x] = new WallTile();
}
}
}
addPlayerStart() {
const walkabilityCache = [];
for (let y = 1; y < this.height - 1; y++) {
//
for (let x = 1; x < this.width - 1; x++) {
const cell = this.map.get(x, y);
if (!cell) {
console.warn("out of bounds [%d, %d] (%s)", x, y, typeof cell);
continue;
}
if (this.map.isTraversable(x, y)) {
walkabilityCache.push([x, y]);
}
}
}
const idx = this.random(0, walkabilityCache.length - 1);
const [x, y] = walkabilityCache[idx];
const walkable = (offsetX, offsetY) => this.map.isTraversable(x + offsetX, y + offsetY);
//
// When spawning in, which direction should the player be oriented?
//
const directions = [];
if (walkable(+1, +0)) directions.push(Orientation.EAST);
if (walkable(+0, +1)) directions.push(Orientation.NORTH);
if (walkable(-1, +0)) directions.push(Orientation.WEST);
if (walkable(+0, -1)) directions.push(Orientation.SOUTH);
const dirIdx = this.random(0, directions.length - 1);
this.map.tiles[y][x] = new PlayerStartTile(directions[dirIdx]);
}
// Add portals to isolated areas
addPortals() {
let traversableTileCount = this.map.getTraversableTileCount();
const result = this.map.getAllTraversableTilesConnectedTo(/** TODO PlayerPos */);
if (result.size === traversableTileCount) {
// There are no isolated areas, return
return;
}
// _____ ___ ____ ___
// |_ _/ _ \| _ \ / _ \
// | || | | | | | | | | |
// | || |_| | |_| | |_| |
// |_| \___/|____/ \___/
//-------------------------------------
// Connect isolated rooms via portals
//-------------------------------------
//
// LET Area0 = getAllTilesConnectedTo(playerStartTile)
// LET Areas = Array containing one item so far: Area0
// FOR EACH tile in this.map
// IF tile not painted
// LET newArea = getAllTilesConnectedTo(tile)
// PUSH newArea ONTO Areas
//
// FOR EACH area IN Areas
// LET index = IndexOf(Areas, area)
// LET next = index + 1 mod LENGTH(Areas)
// entryPos = findValidPortalEntryPositionInArea(area)
// exitPos = findValidPortalExitPositionInArea(area)
//
// this.map[entryPos.y, entryPos.x] = new PortalEntryTile(index)
// this.map[exitPos.y, exitPos.x] = new PortalExitTile(next)
//
//
//
// Start pointing it (another color)
//
console.warn(
"unpassable! There are %d floor tiles, but the player can only visit %d of them",
traversableTileCount,
result.size,
);
}
//
//
addFeatures() {
const floorTiles = [];
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
if (this.map.get(x, y) instanceof FloorTile) {
floorTiles.push({ x, y });
}
}
}
if (floorTiles.length === 0) return;
// Add loot
const lootCount = Math.min(3, Math.floor(this.rooms.length / 2));
for (let i = 0; i < lootCount; i++) {
const pos = floorTiles[this.random(0, floorTiles.length - 1)];
if (this.map.tiles[pos.y][pos.x] instanceof FloorTile) {
this.map.tiles[pos.y][pos.x] = new LootTile(undefined, undefined);
}
}
// Add monsters
const monsterCount = Math.min(5, this.rooms.length);
for (let i = 0; i < monsterCount; i++) {
const pos = floorTiles[this.random(0, floorTiles.length - 1)];
if (this.map.tiles[pos.y][pos.x] instanceof FloorTile) {
this.map.tiles[pos.y][pos.x] = new EncounterTile(pos.x, pos.y, undefined, undefined);
}
}
// Add traps
const trapCount = Math.floor(floorTiles.length / 30);
for (let i = 0; i < trapCount; i++) {
const pos = floorTiles[this.random(0, floorTiles.length - 1)];
if (this.map.tiles[pos.y][pos.x] instanceof FloorTile) {
this.map.tiles[pos.y][pos.x] = new TrapTile();
}
}
}
random(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
}
let currentDungeon = "";
window.generateDungeon = () => {
const width = parseInt(document.getElementById("width").value);
const height = parseInt(document.getElementById("height").value);
const roomCount = parseInt(document.getElementById("roomCount").value);
const generator = new DungeonGenerator(width, height, roomCount);
currentDungeon = generator.generate();
document.getElementById("dungeonDisplay").textContent = currentDungeon;
};
window.downloadDungeon = () => {
if (!currentDungeon) {
window.generateDungeon();
}
const blob = new Blob([currentDungeon], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "dungeon_map.txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
document.getElementById("width").addEventListener("input", function () {
document.getElementById("widthValue").textContent = this.value;
});
document.getElementById("height").addEventListener("input", function () {
document.getElementById("heightValue").textContent = this.value;
});
document.getElementById("roomCount").addEventListener("input", function () {
document.getElementById("roomCountValue").textContent = this.value;
});
// Generate initial dungeon
window.generateDungeon();

BIN
frontend/skelebones.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -202,14 +202,14 @@ class MudServer {
//
// Handle system messages
if (msgObj.isSysMessage()) {
console.log("SYS message", msgObj);
console.debug("SYS message", msgObj);
return;
}
//
// Handle debug messages
if (msgObj.isDebug()) {
console.log("DBG message", msgObj);
console.debug("DBG message", msgObj);
return;
}

17
test.js
View File

@@ -0,0 +1,17 @@
class Nugga {
mufassa = 22;
constructor() {
this.fjæsing = 22;
console.debug(Object.prototype.hasOwnProperty.call(this, "fjæsing"));
}
diller(snaps = this.fjæsing) {
console.log(snaps);
}
}
class Dugga extends Nugga {}
const n = new Dugga();
console.log(n, n.diller(), n instanceof Dugga);

View File

@@ -95,6 +95,3 @@ export class Xorshift32 {
return num + greaterThanOrEqual;
}
}
const rng = new Xorshift32();
console.log(rng.get());

42
utils/shallowCopy.js Executable file
View File

@@ -0,0 +1,42 @@
/**
* Shallow copy any JS value if it makes sense.
* @param {*} value
* @returns {*}
*/
export default function shallowCopy(value) {
if (value === null || typeof value !== "object") {
// primitives, functions, symbols
return value;
}
if (Array.isArray(value)) {
return value.slice();
}
if (value instanceof Date) {
return new Date(value.getTime());
}
if (value instanceof Map) {
return new Map(value);
}
if (value instanceof Set) {
return new Set(value);
}
// Plain objects
if (Object.getPrototypeOf(value) === Object.prototype) {
return Object.assign({}, value);
}
if (typeof value?.clone === "function") {
return value.clone();
}
// Fallback: clone prototype + own props
return Object.create(
Object.getPrototypeOf(value), //
Object.getOwnPropertyDescriptors(value),
);
}

View File

@@ -1,7 +1,7 @@
/** A call represents the name of a function as well as the arguments passed to it */
export class ParsedCall {
export class TileOptions {
/** @type {string} Name of the function */ name;
/** @type {ParsedArg[]} Args passed to function */ args;
/** @type {TileArgs[]} Args passed to function */ args;
constructor(name, args) {
this.name = name;
@@ -14,7 +14,7 @@ export class ParsedCall {
* @param {string} name
* @param {number?} position
*
* @returns {ParsedArg|null}
* @returns {TileArgs|null}
*/
getArg(name, position) {
for (let idx in this.args) {
@@ -32,10 +32,28 @@ export class ParsedCall {
const arg = this.getArg(name, position);
return arg ? arg.value : fallbackValue;
}
/**
* @param {boolean} includePositionals Should the result object include numeric entries for the positional arguments?
* @returns {object} object where the keys are the names of the named args, and the values are the values of those args.
*/
getNamedValues(includePositionals = false) {
const result = {};
for (const arg of this.args) {
const key = arg.key;
if (includePositionals || typeof key === "string") {
result[key] = arg;
}
}
return result;
}
}
/** An argument passed to a function. Can be positional or named */
export class ParsedArg {
export class TileArgs {
/** @type {string|number} */ key;
/** @type {string|number|boolean|null|undefined} */ value;
constructor(key, value) {
@@ -45,11 +63,11 @@ export class ParsedArg {
}
/**
* Parse a string that includes a number of function calls separated by ";" semicolons
* Parse a string of options that looks like function calls separated by ";" semicolons
*
* @param {string} input
*
* @returns {ParsedCall[]}
* @returns {TileOptions[]}
*
* @example
* // returns
@@ -64,7 +82,7 @@ export class ParsedArg {
*/
export default function parse(input) {
const calls = [];
const pattern = /(\w+)\s*\(([^)]*)\)/g; // TODO: expand so identifiers can be more than just \w characters - also limit identifiers to a single letter (maybne)
const pattern = /(\w+)\s*\(([^)]*)\)/gu;
let match;
while ((match = pattern.exec(input)) !== null) {
@@ -72,14 +90,13 @@ export default function parse(input) {
const argsStr = match[2].trim();
const args = parseArguments(argsStr);
// Hack to allow special characters in function names
// If function name is "__", then
// the actual function name is given by arg 0.
// Arg zero is automatically removed when the
// name is changed.
// Hack to allow special characters in option names
// If the option name is "__", then the actual
// option name is given by arg 0, and arg 0 is then
// automatically removed.
//
// So
// __(foo, 1,2,3) === foo(1,2,3)
// __(foo, 1,2,3) === foo(1,2,3)
// __("·", 1,2,3) === ·(1,2,3)
// __("(", 1,2,3) === ((1,2,3)
// __('"', 1,2,3) === '(1,2,3)
@@ -88,7 +105,7 @@ export default function parse(input) {
name = args.shift().value;
}
calls.push(new ParsedCall(name, args));
calls.push(new TileOptions(name, args));
}
return calls;
@@ -96,12 +113,12 @@ export default function parse(input) {
/**
* @param {string} argsStr
* @returns {ParsedArg[]}
* @returns {TileArgs[]}
*/
function parseArguments(argsStr) {
if (!argsStr) return [];
/** @type {ParsedArg[]} */
/** @type {TileArgs[]} */
const args = [];
const tokens = tokenize(argsStr);
@@ -109,9 +126,9 @@ function parseArguments(argsStr) {
const token = tokens[pos];
const namedMatch = token.match(/^(\w+)=(.+)$/);
if (namedMatch) {
args.push(new ParsedArg(namedMatch[1], parseValue(namedMatch[2])));
args.push(new TileArgs(namedMatch[1], parseValue(namedMatch[2])));
} else {
args.push(new ParsedArg(Number.parseInt(pos), parseValue(token)));
args.push(new TileArgs(Number.parseInt(pos), parseValue(token)));
}
}
@@ -156,7 +173,15 @@ function parseValue(str) {
// Try to parse as number
if (/^-?\d+(\.\d+)?$/.test(str)) {
return parseFloat(str);
const f = parseFloat(str);
const rounded = Math.round(f);
const diff = Math.abs(rounded - f);
const epsilon = 1e-6; // MAGIC NUMBER
if (diff < epsilon) {
return rounded;
}
return f;
}
// Boolean