Remove inheritance from Tile class - and remove descendants

Inheritance turned out to be too rigid - all child classes needed all
properties anyway, so no utility gained from polymorphism
This commit is contained in:
Kim Ravn Hansen
2025-10-13 11:29:00 +02:00
parent 96f8781e23
commit ccb3cdb0ce
6 changed files with 164 additions and 83 deletions

View File

@@ -176,16 +176,16 @@ export class FirstPersonRenderer {
} }
if (tile.looksLikeWall) { if (tile.looksLikeWall) {
if (!this.map.looksLikeWall(x, y + 1)) { if (!this.map.behavesLikeWall(x, y + 1)) {
wallPlanes.push([x, y + 0.5, Math.PI * 0.0]); wallPlanes.push([x, y + 0.5, Math.PI * 0.0]);
} }
if (!this.map.looksLikeWall(x + 1, y)) { if (!this.map.behavesLikeWall(x + 1, y)) {
wallPlanes.push([x + 0.5, y, Math.PI * 0.5]); wallPlanes.push([x + 0.5, y, Math.PI * 0.5]);
} }
if (!this.map.looksLikeWall(x, y - 1)) { if (!this.map.behavesLikeWall(x, y - 1)) {
wallPlanes.push([x, y - 0.5, Math.PI * 1.0]); wallPlanes.push([x, y - 0.5, Math.PI * 1.0]);
} }
if (!this.map.looksLikeWall(x - 1, y)) { if (!this.map.behavesLikeWall(x - 1, y)) {
wallPlanes.push([x - 0.5, y, Math.PI * 1.5]); wallPlanes.push([x - 0.5, y, Math.PI * 1.5]);
} }
return; return;

View File

@@ -132,7 +132,7 @@ export class TileMap {
return this.tiles[y][x]; return this.tiles[y][x];
} }
looksLikeWall(x, y) { behavesLikeWall(x, y) {
x |= 0; x |= 0;
y |= 0; y |= 0;
@@ -145,10 +145,10 @@ export class TileMap {
return true; return true;
} }
return this.tiles[y][x].looksLikeWall; return this.tiles[y][x].isWallLike();
} }
isTraversable(x, y) { behavesLikeFloor(x, y) {
x |= 0; x |= 0;
y |= 0; y |= 0;
@@ -156,7 +156,7 @@ export class TileMap {
return true; return true;
} }
return this.tiles[y][x].isTraversable; return this.tiles[y][x].isFloorlike();
} }
/** /**
@@ -220,11 +220,11 @@ export class TileMap {
/** /**
* @returns {number} * @returns {number}
*/ */
getTraversableTileCount() { getFloorlikeTileCount() {
let sum = 0; let sum = 0;
this.forEach((tile) => { this.forEach((tile) => {
if (tile.isTraversable) { if (tile.isFloorlike()) {
sum++; sum++;
} }
}); });

View File

@@ -3,13 +3,15 @@ import shallowCopy from "../utils/shallowCopy.js";
import { TileOptions } from "../utils/tileOptionsParser.js"; import { TileOptions } from "../utils/tileOptionsParser.js";
import { Orientation, Vector2i } from "./ascii_types.js"; import { Orientation, Vector2i } from "./ascii_types.js";
/** @typedef {string} TileTypeId - a string with a length of 1 */
/** /**
* Array of __internal__ characters used to identify tile types. * Array of __internal__ characters used to identify tile types.
* These are __not__ necessarily the characters used to display * These are __not__ necessarily the characters used to display
* the tile on the minimap - but they are used when serializing * the tile on the minimap - but they are used when serializing
* the maps into a semi-human-readable text-format. * the maps into a semi-human-readable text-format.
* *
* @constant {Record<string,string} * @enum {TileTypeId}
*/ */
export const TileChars = Object.freeze({ export const TileChars = Object.freeze({
FLOOR: " ", FLOOR: " ",
@@ -24,7 +26,7 @@ const REQUIRED_ID = Symbol("REQUIRED_ID");
const REQUIRED_ORIENTATION = Symbol("REQUIRED_ORIENTATION"); const REQUIRED_ORIENTATION = Symbol("REQUIRED_ORIENTATION");
const REQUIRED_OCCUPANTS = Symbol("REQUIRED_OCCUPANTS"); const REQUIRED_OCCUPANTS = Symbol("REQUIRED_OCCUPANTS");
/** @type {Record<string,Tile>} */ /** @type {Record<TileTypeId,Tile>} */
export const TileTypes = { export const TileTypes = {
[TileChars.FLOOR]: { [TileChars.FLOOR]: {
minimapChar: "·", minimapChar: "·",
@@ -69,43 +71,70 @@ export const TileTypes = {
export class Tile { export class Tile {
/** @readonly {string?|number?} Unique (but optional) instance if of this tile */ /** @readonly {string?|number?} Unique (but optional) instance if of this tile */
id; id;
/** @type {string} Icon char of tile */
/** @type {TileTypeId} Char that defines this tile */
typeId;
/** @type {TileTypeId} Icon char of tile */
minimapChar; minimapChar;
/** @type {string} Color of the icon of tile */ /** @type {string} Color of the icon of tile */
minimapColor; minimapColor;
/** @type {boolean} Can the player walk here? */ /** @type {boolean} Can the player walk here? */
isTraversable; isTraversable;
/** @type {boolean} Should this be rendered as a wall? */ /** @type {boolean} Should this be rendered as a wall? */
looksLikeWall; looksLikeWall;
/** @type {boolean} Is this where they player starts? */ /** @type {boolean} Is this where they player starts? */
isStartLocation; isStartLocation;
/** @type {boolean} Is this a portal exit and/or entry */ /** @type {boolean} Is this a portal exit and/or entry */
isPortal; isPortal;
/** @type {string|number} Where is the player transported if they enter the portal */ /** @type {string|number} Where is the player transported if they enter the portal */
portalTargetId; portalTargetId;
/** @type {number|string} id of texture to use */ /** @type {number|string} id of texture to use */
textureId; textureId;
/** @type {number|string} type of encounter located on this tile. May or may not be unique*/ /** @type {number|string} type of encounter located on this tile. May or may not be unique*/
encounterType; encounterType;
/** @type {number|string} type of trap located on this tile. May or may not be unique*/ /** @type {number|string} type of trap located on this tile. May or may not be unique*/
trapType; trapType;
/** @type {Orientation} */ /** @type {Orientation} */
orientation; orientation;
/** @type {TileType} This tile disguises itself as another tile, and its true properties are revealed later if event is triggered */
/** @type {TileTypeId} This tile disguises itself as another tile, and its true properties are revealed later if event is triggered */
disguiseAs; disguiseAs;
/** @type {TileTypeId} This tile "inherits" the properties of another tile type */
is;
/** @type {boolean} Has the secret properties of this tile been revealed? */ /** @type {boolean} Has the secret properties of this tile been revealed? */
revealed; revealed;
/** @type {string} Icon char of tile after tile's secrets have been revealed */
/** @type {TileTypeId} Icon char of tile after tile's secrets have been revealed */
revealedMinimapChar; revealedMinimapChar;
/** @type {string} Color of the icon char of tile after tile's secrets have been revealed */ /** @type {string} Color of the icon char of tile after tile's secrets have been revealed */
revealedMinimapColor; revealedMinimapColor;
/** @type {number|string} id of texture to use after the secrets of this tile has been revealed */ /** @type {number|string} id of texture to use after the secrets of this tile has been revealed */
revealedTextureId; revealedTextureId;
/** @param {Tile} properties */ /**
constructor(properties) { * @param {TileTypeId} typeId
* @param {Tile?} properties
*/
constructor(typeId, properties) {
mustBe(properties, "object"); mustBe(properties, "object");
this.typeId = typeId;
// //
// Copy props from properties. // Copy props from properties.
// //
@@ -199,26 +228,50 @@ export class Tile {
} }
} }
static CreateWalLTile() { /** @returns {Tile} */
return this.fromChar(); static createWall() {
return this.fromChar(TileChars.WALL);
}
/** @returns {Tile} */
static createEncounterStartPoint() {
return this.fromChar(TileChars.ENCOUNTER_START_POINT);
}
/** @returns {Tile} */
static createFloor() {
return this.fromChar(TileChars.FLOOR);
}
/** @returns {Tile} */
static createPlayerStart(orientation) {
return this.fromChar(TileChars.PLAYER_START_POINT, { orientation });
} }
/** /**
* @param {string} char * Given a map symbol,
* @param {TileOptions} options Options * @param {TileTypeId} typeId
* * @param {TileOptions|Record<string,string>} options
* @returns {Tile} * @returns {Tile}
*/ */
static fromChar(char, options) { static fromChar(typeId, options) {
// const typeInfo = TileTypes[typeId];
// Validate Options
options = options ?? new TileOptions(); if (!typeInfo) {
if (!(options instanceof TileOptions)) { console.log("unknown type id", { typeId });
console.error("Invalid options", { char, opt: options }); throw new Error(`Unknown typeId >>>${typeId}<<<`);
throw new Error("Invalid options");
} }
const typeInfo = TileTypes[char]; if (options === undefined) {
options = TileOptions.fromObject(typeId, TileTypes[typeId]);
}
//
// Normalize options into a TileOptions object,
//
if (!(options instanceof TileOptions)) {
options = TileOptions.fromObject(options);
}
let optionPos = 0; let optionPos = 0;
const creationArgs = {}; const creationArgs = {};
@@ -230,12 +283,40 @@ export class Tile {
creationArgs[key] = fetchFromOption ? getOption(key) : shallowCopy(val); creationArgs[key] = fetchFromOption ? getOption(key) : shallowCopy(val);
} }
return new Tile(creationArgs); return new Tile(typeId, creationArgs);
} }
clone() { clone() {
return new this.constructor(this); return new this.constructor(this);
} }
isWallLike() {
if (this.is === TileChars.WALL) {
return true;
}
if (this.disguiseAs === TileChars.WALL) {
return true;
}
return this.looksLikeWall && !this.isTraversable;
}
isFloorlike() {
if (this.is === TileChars.FLOOR) {
return true;
}
if (this.disguiseAs === TileChars.FLOOR) {
return true;
}
return this.isTraversable;
}
isFloor() {
return this.typeId === TileChars.FLOOR;
}
} }
if (Math.PI < 0 && TileOptions && Orientation && Vector2i) { if (Math.PI < 0 && TileOptions && Orientation && Vector2i) {

View File

@@ -9,7 +9,7 @@ class DungeonGenerator {
this.corridors = []; this.corridors = [];
// 2d array of pure wall tiles // 2d array of pure wall tiles
const tiles = new Array(height).fill().map(() => Array(width).fill(new WallTile())); const tiles = new Array(height).fill().map(() => Array(width).fill(Tile.createWall()));
this.map = new TileMap(tiles); this.map = new TileMap(tiles);
} }
@@ -73,7 +73,7 @@ class DungeonGenerator {
carveRoom(room) { carveRoom(room) {
for (let y = room.y; y < room.y + room.height; y++) { for (let y = room.y; y < room.y + room.height; y++) {
for (let x = room.x; x < room.x + room.width; x++) { for (let x = room.x; x < room.x + room.width; x++) {
this.map.tiles[y][x] = new FloorTile(); this.map.tiles[y][x] = Tile.createFloor();
} }
} }
} }
@@ -115,7 +115,7 @@ class DungeonGenerator {
let lastNonWallX = undefined; // x-index of the LAST (eastmost) 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++) { for (let x = 0; x < this.width; x++) {
const isWall = this.map.get(x, y) instanceof WallTile; const isWall = this.map.get(x, y).looksLikeWall;
if (isWall) { if (isWall) {
continue; continue;
@@ -152,24 +152,24 @@ class DungeonGenerator {
const newTiles = []; const newTiles = [];
// First row is all walls // First row is all walls
newTiles.push(new Array(newWidth).fill(new WallTile())); newTiles.push(new Array(newWidth).fill(Tile.createWall()));
// Populate the new grid // Populate the new grid
for (let y = dungeonStartY; y <= dungeonEndY; y++) { for (let y = dungeonStartY; y <= dungeonEndY; y++) {
const row = []; const row = [];
row.push(new WallTile()); // Initial wall tile on this row row.push(Tile.createWall()); // Initial wall tile on this row
for (let x = dungeonStartX; x <= dungeonEndX; x++) { for (let x = dungeonStartX; x <= dungeonEndX; x++) {
/**/ /**/
const tile = this.map.get(x, y); const tile = this.map.get(x, y);
row.push(tile); row.push(tile);
} }
row.push(new WallTile()); // Final wall tile on this row row.push(Tile.createWall()); // Final wall tile on this row
newTiles.push(row); newTiles.push(row);
} }
// Final row is all walls // Final row is all walls
newTiles.push(new Array(newWidth).fill(new WallTile())); newTiles.push(new Array(newWidth).fill(Tile.createWall()));
this.map = new TileMap(newTiles); this.map = new TileMap(newTiles);
} }
@@ -201,7 +201,7 @@ class DungeonGenerator {
while (x !== x2 || y !== y2) { while (x !== x2 || y !== y2) {
if (x >= 0 && x < this.width && y >= 0 && y < this.height) { if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
this.map.tiles[y][x] = new FloorTile(); this.map.tiles[y][x] = Tile.createFloor();
} }
if (x !== x2) x += dx; if (x !== x2) x += dx;
@@ -210,7 +210,7 @@ class DungeonGenerator {
// Ensure endpoint is carved // Ensure endpoint is carved
if (x2 >= 0 && x2 < this.width && y2 >= 0 && y2 < this.height) { if (x2 >= 0 && x2 < this.width && y2 >= 0 && y2 < this.height) {
this.map.tiles[y2][x2] = new FloorTile(); this.map.tiles[y2][x2] = Tile.createFloor();
} }
} }
@@ -226,7 +226,7 @@ class DungeonGenerator {
continue; continue;
} }
if (this.map.isTraversable(x, y)) { if (this.map.behavesLikeFloor(x, y)) {
walkabilityCache.push([x, y]); walkabilityCache.push([x, y]);
} }
} }
@@ -244,7 +244,7 @@ class DungeonGenerator {
for (let [x, y] of walkabilityCache) { for (let [x, y] of walkabilityCache) {
// //
const walkable = (offsetX, offsetY) => this.map.isTraversable(x + offsetX, y + offsetY); const walkable = (offsetX, offsetY) => this.map.behavesLikeFloor(x + offsetX, y + offsetY);
const surroundingFloorCount = const surroundingFloorCount =
0 + 0 +
@@ -264,7 +264,7 @@ class DungeonGenerator {
if (surroundingFloorCount >= 7) { if (surroundingFloorCount >= 7) {
// MAGIC NUMBER 7 // MAGIC NUMBER 7
this.map.tiles[y][x] = new WallTile(); this.map.tiles[y][x] = Tile.createWall();
} }
} }
} }
@@ -281,7 +281,7 @@ class DungeonGenerator {
continue; continue;
} }
if (this.map.isTraversable(x, y)) { if (this.map.behavesLikeFloor(x, y)) {
walkabilityCache.push([x, y]); walkabilityCache.push([x, y]);
} }
} }
@@ -290,7 +290,7 @@ class DungeonGenerator {
const idx = this.random(0, walkabilityCache.length - 1); const idx = this.random(0, walkabilityCache.length - 1);
const [x, y] = walkabilityCache[idx]; const [x, y] = walkabilityCache[idx];
const walkable = (offsetX, offsetY) => this.map.isTraversable(x + offsetX, y + offsetY); const walkable = (offsetX, offsetY) => this.map.behavesLikeFloor(x + offsetX, y + offsetY);
// //
// When spawning in, which direction should the player be oriented? // When spawning in, which direction should the player be oriented?
@@ -301,14 +301,16 @@ class DungeonGenerator {
if (walkable(-1, +0)) directions.push(Orientation.WEST); if (walkable(-1, +0)) directions.push(Orientation.WEST);
if (walkable(+0, -1)) directions.push(Orientation.SOUTH); if (walkable(+0, -1)) directions.push(Orientation.SOUTH);
// Player's initial orientation is randomized in such a way that
// they don't face a wall upon spawning.
const dirIdx = this.random(0, directions.length - 1); const dirIdx = this.random(0, directions.length - 1);
this.map.tiles[y][x] = new PlayerStartTile(directions[dirIdx]); this.map.tiles[y][x] = Tile.createPlayerStart(directions[dirIdx]);
} }
// Add portals to isolated areas // Add portals to isolated areas
addPortals() { addPortals() {
let traversableTileCount = this.map.getTraversableTileCount(); let traversableTileCount = this.map.getFloorlikeTileCount();
const result = this.map.getAllTraversableTilesConnectedTo(/** TODO PlayerPos */); const result = this.map.getAllTraversableTilesConnectedTo(/** TODO PlayerPos */);
@@ -322,29 +324,24 @@ class DungeonGenerator {
// | || | | | | | | | | | // | || | | | | | | | | |
// | || |_| | |_| | |_| | // | || |_| | |_| | |_| |
// |_| \___/|____/ \___/ // |_| \___/|____/ \___/
//------------------------------------- //----------------------------------------------
// Connect isolated rooms via portals // Connect isolated rooms via a chain of portals
//------------------------------------- //----------------------------------------------
// //
// LET Area0 = getAllTilesConnectedTo(playerStartTile) // LET Area0 = getAllTilesConnectedTo(playerStartTile)
// LET Areas = Array containing one item so far: Area0 // LET Areas = Array containing one item so far: Area0
// FOR EACH tile in this.map // FOR EACH tile in this.map
// IF tile not painted // IF tile NOT in any Area
// LET newArea = getAllTilesConnectedTo(tile) // LET newArea = getAllTilesConnectedTo(tile)
// PUSH newArea ONTO Areas // PUSH newArea ONTO Areas
// //
// FOR EACH area IN Areas // FOR EACH (index, area) IN Areas
// LET index = IndexOf(Areas, area)
// LET next = index + 1 mod LENGTH(Areas) // LET next = index + 1 mod LENGTH(Areas)
// entryPos = findValidPortalEntryPositionInArea(area) // entryPos = findValidPortalEntryPositionInArea(area) // entry is a pure wall tile that is exactly one adjacent floor tile - and that floor tile must be pure
// exitPos = findValidPortalExitPositionInArea(area) // exitPos = findValidPortalExitPositionInArea(area) // must be a valid pure floor tile with one or more adjacent floor tiles, at least on of which are pure
// //
// this.map[entryPos.y, entryPos.x] = new PortalEntryTile(index) // this.map[entryPos.y, entryPos.x] = new PortalEntryTile(index) // Create a portal in the current area
// this.map[exitPos.y, exitPos.x] = new PortalExitTile(next) // this.map[exitPos.y, exitPos.x] = new PortalExitTile(next) // let the exit to the portal reside in the next area
//
//
//
// Start pointing it (another color)
// //
console.warn( console.warn(
@@ -360,40 +357,43 @@ class DungeonGenerator {
const floorTiles = []; const floorTiles = [];
for (let y = 0; y < this.height; y++) { for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) { for (let x = 0; x < this.width; x++) {
if (this.map.get(x, y) instanceof FloorTile) { if (this.map.get(x, y).isFloor()) {
floorTiles.push({ x, y }); floorTiles.push({ x, y });
} }
} }
} }
if (floorTiles.length === 0) return; if (floorTiles.length === 0) {
return;
}
// Add loot // Add loot
const lootCount = Math.min(3, Math.floor(this.rooms.length / 2)); // const lootCount = Math.min(3, Math.floor(this.rooms.length / 2));
for (let i = 0; i < lootCount; i++) { // for (let i = 0; i < lootCount; i++) {
const pos = floorTiles[this.random(0, floorTiles.length - 1)]; // const pos = floorTiles[this.random(0, floorTiles.length - 1)];
if (this.map.tiles[pos.y][pos.x] instanceof FloorTile) { // if (this.map.tiles[pos.y][pos.x].isFloor()) {
this.map.tiles[pos.y][pos.x] = new LootTile(undefined, undefined); // this.map.tiles[pos.y][pos.x] = new LootTile(undefined, undefined);
} // }
} // }
// Add monsters // Add monsters
const monsterCount = Math.min(5, this.rooms.length); const encouterCount = Math.min(5, this.rooms.length);
for (let i = 0; i < monsterCount; i++) { for (let i = 0; i < encouterCount; i++) {
const pos = floorTiles[this.random(0, floorTiles.length - 1)]; const pos = floorTiles[this.random(0, floorTiles.length - 1)];
if (this.map.tiles[pos.y][pos.x] instanceof FloorTile) { if (this.map.tiles[pos.y][pos.x].isFloor()) {
this.map.tiles[pos.y][pos.x] = new EncounterTile(pos.x, pos.y, undefined, undefined); this.map.tiles[pos.y][pos.x] = Tile.createEncounterStartPoint();
// TODO: Add encounter to the dungeon's "roaming entities" array.
} }
} }
// Add traps // Add traps
const trapCount = Math.floor(floorTiles.length / 30); // const trapCount = Math.floor(floorTiles.length / 30);
for (let i = 0; i < trapCount; i++) { // for (let i = 0; i < trapCount; i++) {
const pos = floorTiles[this.random(0, floorTiles.length - 1)]; // const pos = floorTiles[this.random(0, floorTiles.length - 1)];
if (this.map.tiles[pos.y][pos.x] instanceof FloorTile) { // if (this.map.tiles[pos.y][pos.x].isFloor()) {
this.map.tiles[pos.y][pos.x] = new TrapTile(); // this.map.tiles[pos.y][pos.x] = new TrapTile();
} // }
} // }
} }
random(min, max) { random(min, max) {

0
frontend/gnoll.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

0
frontend/skelebones.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB