progress
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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() {
|
||||
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>",
|
||||
|
||||
@@ -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);
|
||||
|
||||
const waitForAsyncs = () => {
|
||||
if (this.ready) {
|
||||
return;
|
||||
}
|
||||
if (this.openAsyncs > 0) {
|
||||
setTimeout(waitForAsyncs, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.ready = true;
|
||||
if (typeof this.onReady === "function") {
|
||||
this.onReady();
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderFrame();
|
||||
};
|
||||
setTimeout(waitForAsyncs, 100);
|
||||
}
|
||||
|
||||
getTexture(textureId) {
|
||||
console.debug("fetching texture", { textureId });
|
||||
let texture = this.textures.get(textureId);
|
||||
if (!texture) {
|
||||
console.debug(" miss... loading texture", { textureId });
|
||||
this.openAsyncs++;
|
||||
texture = new THREE.TextureLoader().load(`${textureId}.png`, (t) => {
|
||||
t.magFilter = THREE.NearestFilter; // no smoothing when scaling up
|
||||
t.minFilter = THREE.NearestFilter; // no mipmaps / no smoothing when scaling down
|
||||
t.generateMipmaps = false; // don’t build mipmaps
|
||||
this.openAsyncs--;
|
||||
});
|
||||
this.textures.set(textureId, texture);
|
||||
}
|
||||
@@ -111,12 +136,9 @@ export class FirstPersonRenderer {
|
||||
}
|
||||
|
||||
getSpriteMaterial(textureId) {
|
||||
console.debug("fetching material", { textureId });
|
||||
|
||||
let material = this.spriteMaterials.get(textureId);
|
||||
|
||||
if (!material) {
|
||||
console.log("Creating material", { textureId });
|
||||
material = new THREE.SpriteMaterial({
|
||||
map: this.getTexture(textureId),
|
||||
transparent: true,
|
||||
@@ -150,7 +172,6 @@ export class FirstPersonRenderer {
|
||||
this.mainCamera.lookAt(x, y - 1, 0);
|
||||
this.torch.position.copy(this.mainCamera.position);
|
||||
|
||||
console.log("Initial Camera Position:", this.mainCamera.position);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -184,7 +205,8 @@ export class FirstPersonRenderer {
|
||||
// ---------------------------
|
||||
const floorGeo = new THREE.PlaneGeometry(this.map.width, this.map.height);
|
||||
const floorMat = new THREE.MeshStandardMaterial({
|
||||
color: this.floorColor /* side: THREE.DoubleSide */,
|
||||
color: this.floorColor,
|
||||
/* side: THREE.DoubleSide */
|
||||
});
|
||||
const floor = new THREE.Mesh(floorGeo, floorMat);
|
||||
floor.position.set(this.map.width / 2, this.map.height / 2, -0.5);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
getTraversableTileCount() {
|
||||
let sum = 0;
|
||||
|
||||
this.forEach((tile) => {
|
||||
if (tile.isTraversable) {
|
||||
sum++;
|
||||
}
|
||||
if (yMin > yMax) {
|
||||
[yMin, yMax] = [yMax, yMin];
|
||||
});
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
const w = xMax - xMin + 1;
|
||||
const h = yMax - yMin + 1;
|
||||
/**
|
||||
* @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 });
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
if (typeof this.textureId === "string" && this.textureId !== "") {
|
||||
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_");
|
||||
|
||||
return false;
|
||||
creationArgs[key] = fetchFromOption ? getOption(key) : shallowCopy(val);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
133
frontend/dungeon_studio.html
Executable 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
446
frontend/dungeon_studio.js
Executable 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
BIN
frontend/skelebones.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
@@ -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
17
test.js
@@ -0,0 +1,17 @@
|
||||
class Nugga {
|
||||
mufassa = 22;
|
||||
constructor() {
|
||||
this.fjæsing = 22;
|
||||
console.debug(Object.prototype.hasOwnProperty.call(this, "fjæsing"));
|
||||
}
|
||||
|
||||
diller(snaps = this.fjæsing) {
|
||||
console.log(snaps);
|
||||
}
|
||||
}
|
||||
|
||||
class Dugga extends Nugga {}
|
||||
|
||||
const n = new Dugga();
|
||||
|
||||
console.log(n, n.diller(), n instanceof Dugga);
|
||||
|
||||
@@ -95,6 +95,3 @@ export class Xorshift32 {
|
||||
return num + greaterThanOrEqual;
|
||||
}
|
||||
}
|
||||
|
||||
const rng = new Xorshift32();
|
||||
console.log(rng.get());
|
||||
|
||||
42
utils/shallowCopy.js
Executable file
42
utils/shallowCopy.js
Executable 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),
|
||||
);
|
||||
}
|
||||
@@ -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,11 +90,10 @@ 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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user