shellquest
Version:
Terminal-based procedurally generated dungeon crawler
698 lines (609 loc) • 22.1 kB
text/typescript
/**
* DungeonLevel - A level type that uses procedural dungeon generation
* Uses the dungeon generator system ported from the web wall-gen tool
*/
import {TILE_SIZE, type TileMap} from '../../TileMap.ts';
import type {LevelTile, TileInput} from '../types.ts';
import {rotatedTile, toTileRef} from '../types.ts';
import type {RenderTile} from '../../drawing/layeredRenderer.ts';
import {Entity} from '../../entities/Entity.ts';
import {Player} from '../../entities/Player.ts';
import {
setupDungeonTileDefinitions,
computeAutotile,
getCeilingTile,
getGroundTile,
} from './DungeonTileDefinitions.ts';
import {
CELL_FLOOR,
CELL_WALL,
getNeighbors,
isWall,
isFloor,
isWallVisible,
getTileNoise,
getDecalDefinition,
} from './DungeonUtils.ts';
import type {TileGrid, DecalPlacement, TrackPath, FencePlacement} from './DungeonUtils.ts';
import {generateDungeon} from './DungeonMapGenerator.ts';
import type {DungeonGeneratorConfig} from './DungeonMapGenerator.ts';
// Light settings for shadow rendering
interface LightSettings {
bottomLeft: boolean;
bottomRight: boolean;
}
const DEFAULT_LIGHT_SETTINGS: LightSettings = {
bottomLeft: true,
bottomRight: false,
};
export class DungeonLevel {
private tiles: LevelTile[][];
private shadowTiles: RenderTile[] = []; // Shadows rendered as a separate layer UNDER entities
private entities: Entity[] = [];
private grid: TileGrid = [];
private decals: DecalPlacement[] = [];
private tracks: TrackPath[] = [];
private fences: FencePlacement[] = [];
private spawnX: number = 0;
private spawnY: number = 0;
private lightSettings: LightSettings = DEFAULT_LIGHT_SETTINGS;
private exploredTiles: Set<string> = new Set(); // Track explored tiles for minimap
get player(): Player {
return this.entities.find((e) => e instanceof Player) as Player;
}
constructor(
private width: number,
private height: number,
) {
// Initialize tiles array with void
this.tiles = [];
for (let y = 0; y < height; y++) {
this.tiles[y] = [];
for (let x = 0; x < width; x++) {
this.tiles[y][x] = {
bottomTile: 'dungeon-ceiling-base',
solid: true,
};
}
}
}
setTile(x: number, y: number, tile: LevelTile): void {
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
this.tiles[y][x] = tile;
}
}
getTile(x: number, y: number): LevelTile | null {
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
return this.tiles[y][x];
}
return null;
}
isSolid(x: number, y: number): boolean {
const tile = this.getTile(x, y);
return tile ? tile.solid : true;
}
getBottomLayerTiles(): TileInput[][] {
return this.tiles.map((row) => row.map((tile) => tile.bottomTile));
}
/**
* Get shadow tiles - rendered UNDER entities (on sprite layer before entities)
*/
getShadowTiles(): RenderTile[] {
return this.shadowTiles;
}
getTopLayerTiles(): RenderTile[] {
const topTiles: RenderTile[] = [];
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
if (this.tiles[y][x].topTile) {
topTiles.push({
tile: this.tiles[y][x].topTile!,
x: x * TILE_SIZE,
y: y * TILE_SIZE,
});
}
if (this.tiles[y][x].topTile2) {
topTiles.push({
tile: this.tiles[y][x].topTile2!,
x: x * TILE_SIZE,
y: y * TILE_SIZE,
});
}
}
}
return topTiles;
}
/**
* Get sprite-layer tiles that need Y-sorting with entities.
* These are tiles that entities can walk in front of or behind.
* Returns tiles with sortY set to their "foot" position for proper depth ordering.
*/
getYSortableTiles(): RenderTile[] {
const sortableTiles: RenderTile[] = [];
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
const tile = this.tiles[y][x];
const pixelX = x * TILE_SIZE;
const pixelY = y * TILE_SIZE;
// Check if this tile position is a wall with floor below it (wall face)
const isWallFace = isWall(this.grid, x, y) && isFloor(this.grid, x, y + 1);
if (tile.topTile) {
// Wall faces: sortY is at the bottom of the wall (where it meets the floor)
// This makes entities appear behind the wall when above it, in front when below
const sortY = isWallFace ? (y + 1) * TILE_SIZE : pixelY + TILE_SIZE;
sortableTiles.push({
tile: tile.topTile,
x: pixelX,
y: pixelY,
sortY,
});
}
if (tile.topTile2) {
const sortY = isWallFace ? (y + 1) * TILE_SIZE : pixelY + TILE_SIZE;
sortableTiles.push({
tile: tile.topTile2,
x: pixelX,
y: pixelY,
sortY,
});
}
}
}
return sortableTiles;
}
/**
* Get tiles that should always render on top (wall crowns, UI elements).
* These are not Y-sorted with entities.
*/
getOverlayTiles(): RenderTile[] {
const overlayTiles: RenderTile[] = [];
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
// Wall crowns are placed on the tile ABOVE the wall face
// They should render on top of everything
const isCrownPosition = isWall(this.grid, x, y + 1) && isFloor(this.grid, x, y + 2);
if (isCrownPosition && this.tiles[y][x].topTile) {
const tileName = typeof this.tiles[y][x].topTile === 'string'
? this.tiles[y][x].topTile as string
: (this.tiles[y][x].topTile as {name: string}).name;
// Only include crown tiles in overlay
if (tileName.includes('crown') || tileName.includes('top')) {
overlayTiles.push({
tile: this.tiles[y][x].topTile!,
x: x * TILE_SIZE,
y: y * TILE_SIZE,
});
}
}
}
}
return overlayTiles;
}
addEntity(entity: Entity): void {
this.entities.push(entity);
}
removeEntity(entity: Entity): void {
const index = this.entities.indexOf(entity);
if (index !== -1) {
this.entities.splice(index, 1);
}
}
getEntities(): Entity[] {
return this.entities;
}
getSpawnPoint(): {x: number; y: number} {
return {x: this.spawnX, y: this.spawnY};
}
// ===========================================================================
// MAP GENERATION
// ===========================================================================
generateMap(seed: string = 'default', config: Partial<DungeonGeneratorConfig> = {}): void {
const seedNum = this.hashString(seed);
const decalSeedNum = this.hashString(seed + '_decals');
const result = generateDungeon(seedNum, decalSeedNum, this.width, this.height, config);
this.grid = result.grid;
this.decals = result.decals;
this.tracks = result.tracks;
this.fences = result.fences;
this.spawnX = result.spawnPoint.x;
this.spawnY = result.spawnPoint.y;
this.convertGridToTiles();
}
private hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash);
}
private convertGridToTiles(): void {
// Pass 1: Set base tiles (ceiling for walls, ground for floors)
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
const cellType = this.grid[y]?.[x] ?? CELL_WALL;
const noise = getTileNoise(x, y);
if (cellType === CELL_WALL) {
this.tiles[y][x] = {
bottomTile: getCeilingTile(noise),
solid: true,
};
} else {
this.tiles[y][x] = {
bottomTile: getGroundTile(noise),
solid: false,
};
}
}
}
// Pass 2: Render shadows to separate array (rendered under entities)
this.renderShadows();
// Pass 3: Add tracks
this.renderTracks();
// Pass 4: Add fences
this.renderFences();
// Pass 5: Add ground decals
this.renderGroundDecals();
// Pass 6: Add walls with autotiling
this.renderWalls();
// Pass 7: Add wall decals and pillars
this.renderWallDecals();
}
// ===========================================================================
// SHADOW RENDERING - Full implementation from dungeonRenderer.ts
// ===========================================================================
private renderShadows(): void {
this.shadowTiles = [];
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
if (!isFloor(this.grid, x, y)) continue;
const wallN = isWall(this.grid, x, y - 1);
const wallS = isWall(this.grid, x, y + 1);
const wallE = isWall(this.grid, x + 1, y);
const wallW = isWall(this.grid, x - 1, y);
const wallNW = isWall(this.grid, x - 1, y - 1);
const wallNE = isWall(this.grid, x + 1, y - 1);
const wallSW = isWall(this.grid, x - 1, y + 1);
const wallSE = isWall(this.grid, x + 1, y + 1);
const noise = getTileNoise(x, y);
// Bottom-left light source: shadows cast to top-right
if (this.lightSettings.bottomLeft) {
this.renderShadowBottomLeft(x, y, wallN, wallS, wallW, wallNW, wallSW, noise);
}
// Bottom-right light source: shadows cast to top-left
if (this.lightSettings.bottomRight) {
this.renderShadowBottomRight(
x,
y,
wallN,
wallS,
wallE,
wallW,
wallNE,
wallSE,
noise,
this.lightSettings.bottomLeft,
);
}
}
}
}
private addShadowTile(x: number, y: number, tile: TileInput): void {
this.shadowTiles.push({
tile,
x: x * TILE_SIZE,
y: y * TILE_SIZE,
});
}
// Shadow from bottom-left light source
private renderShadowBottomLeft(
x: number,
y: number,
wallN: boolean,
wallS: boolean,
wallW: boolean,
wallNW: boolean,
wallSW: boolean,
noise: number,
): void {
// Inner corner shadow (wall to S and W)
if (wallS && wallW) {
this.addShadowTile(x, y, rotatedTile('dungeon-shadow-inner-corner', 180));
}
// Shadow from wall to the south
else if (wallS) {
const shadowTile = noise < 70 ? 'dungeon-shadow-from-north' : 'dungeon-shadow-from-north-alt';
this.addShadowTile(x, y, rotatedTile(shadowTile, 180));
}
// Shadow from wall to the west
else if (wallW) {
this.addShadowTile(x, y, rotatedTile('dungeon-shadow-from-north', 270));
}
// Outer corner shadow (diagonal wall only)
else if (wallSW) {
this.addShadowTile(x, y, rotatedTile('dungeon-shadow-outer-corner', 270));
}
// Outer corner shadow from NW diagonal
else if (wallNW && !wallW && !wallN) {
this.addShadowTile(x, y, rotatedTile('dungeon-shadow-outer-corner', 0));
}
// Additional shadow from wall to the north
if (wallN) {
if (wallW) {
this.addShadowTile(x, y, rotatedTile('dungeon-shadow-inner-corner', 270));
} else {
const shadowTile =
noise < 70 ? 'dungeon-shadow-from-north' : 'dungeon-shadow-from-north-alt';
this.addShadowTile(x, y, rotatedTile(shadowTile, 0));
}
}
}
// Shadow from bottom-right light source
private renderShadowBottomRight(
x: number,
y: number,
wallN: boolean,
wallS: boolean,
wallE: boolean,
wallW: boolean,
wallNE: boolean,
wallSE: boolean,
noise: number,
isBottomLeftToo: boolean,
): void {
// Inner corner shadow (wall to S and E)
if (wallS && wallE) {
this.addShadowTile(x, y, rotatedTile('dungeon-shadow-inner-corner', 90));
}
// Shadow from wall to the south
else if (wallS) {
const shadowTile = noise < 70 ? 'dungeon-shadow-from-north' : 'dungeon-shadow-from-north-alt';
this.addShadowTile(x, y, rotatedTile(shadowTile, 180));
}
// Shadow from wall to the east
else if (wallE) {
this.addShadowTile(x, y, rotatedTile('dungeon-shadow-from-north', 90));
}
// Outer corner shadow (diagonal wall only)
else if (wallSE) {
this.addShadowTile(x, y, rotatedTile('dungeon-shadow-outer-corner', 180));
}
// Outer corner shadow from NE diagonal
else if (wallNE && !wallE) {
this.addShadowTile(x, y, rotatedTile('dungeon-shadow-outer-corner', 90));
}
// Additional shadow from wall to the north
if (wallN) {
if (wallE) {
this.addShadowTile(x, y, rotatedTile('dungeon-shadow-inner-corner', 0));
} else {
if (isBottomLeftToo) {
if (wallW) {
this.addShadowTile(x, y, rotatedTile('dungeon-shadow-inner-corner', 270));
} else {
const shadowTile =
noise < 70 ? 'dungeon-shadow-from-north' : 'dungeon-shadow-from-north-alt';
this.addShadowTile(x, y, rotatedTile(shadowTile, 0));
}
} else {
const shadowTile =
noise < 70 ? 'dungeon-shadow-from-north' : 'dungeon-shadow-from-north-alt';
this.addShadowTile(x, y, rotatedTile(shadowTile, 0));
}
}
}
}
// ===========================================================================
// TRACK RENDERING
// ===========================================================================
private renderTracks(): void {
for (const track of this.tracks) {
for (const segment of track.segments) {
const {x, y, tileId} = segment;
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
this.tiles[y][x].topTile = tileId;
}
}
}
}
// ===========================================================================
// FENCE RENDERING
// ===========================================================================
private renderFences(): void {
for (const fence of this.fences) {
for (const segment of fence.segments) {
const {x, y, tileId} = segment;
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
this.tiles[y][x].topTile = tileId;
this.tiles[y][x].solid = true;
}
}
}
}
// ===========================================================================
// GROUND DECAL RENDERING
// ===========================================================================
private renderGroundDecals(): void {
for (const decal of this.decals) {
const def = getDecalDefinition(decal.definitionId);
if (!def || def.placement !== 'ground') continue;
const {x, y} = decal;
for (let i = 0; i < def.tiles.length; i++) {
const tileY = y - i;
if (tileY >= 0 && tileY < this.height && x >= 0 && x < this.width) {
const tileName = def.tiles[i];
if (!this.tiles[tileY][x].topTile) {
this.tiles[tileY][x].topTile = tileName;
} else {
this.tiles[tileY][x].topTile2 = tileName;
}
}
}
}
}
// ===========================================================================
// WALL RENDERING WITH AUTOTILING
// ===========================================================================
private renderWalls(): void {
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
if (!isWall(this.grid, x, y)) continue;
if (!isWallVisible(this.grid, x, y)) continue;
const neighbors = getNeighbors(this.grid, x, y);
const autotile = computeAutotile(neighbors);
if (autotile.face) {
this.tiles[y][x].topTile = autotile.face;
}
if (autotile.crown && y > 0) {
if (!this.tiles[y - 1][x].topTile) {
this.tiles[y - 1][x].topTile = autotile.crown;
} else if (!this.tiles[y - 1][x].topTile2) {
this.tiles[y - 1][x].topTile2 = autotile.crown;
}
}
}
}
}
// ===========================================================================
// WALL DECAL AND PILLAR RENDERING
// ===========================================================================
private renderWallDecals(): void {
for (const decal of this.decals) {
const def = getDecalDefinition(decal.definitionId);
if (!def) continue;
if (def.placement === 'ground') continue;
const {x, y} = decal;
if (def.placement === 'wall_face') {
if (def.width === 2 && def.tiles.length >= 2) {
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
this.tiles[y][x].topTile2 = def.tiles[0];
}
if (x + 1 >= 0 && x + 1 < this.width && y >= 0 && y < this.height) {
this.tiles[y][x + 1].topTile2 = def.tiles[1];
}
} else {
for (let i = 0; i < def.tiles.length; i++) {
const tileY = y - i;
if (tileY >= 0 && tileY < this.height && x >= 0 && x < this.width) {
const tileName = def.tiles[i];
if (!this.tiles[tileY][x].topTile2) {
this.tiles[tileY][x].topTile2 = tileName;
}
}
}
}
} else if (def.placement === 'wall_to_ground') {
if (def.tiles.length >= 3) {
if (y + 1 >= 0 && y + 1 < this.height && x >= 0 && x < this.width) {
this.tiles[y + 1][x].topTile2 = def.tiles[0];
}
if (y >= 0 && y < this.height && x >= 0 && x < this.width) {
this.tiles[y][x].topTile2 = def.tiles[1];
}
if (y - 1 >= 0 && y - 1 < this.height && x >= 0 && x < this.width) {
this.tiles[y - 1][x].topTile2 = def.tiles[2];
}
} else if (def.tiles.length === 2) {
if (y + 1 >= 0 && y + 1 < this.height && x >= 0 && x < this.width) {
this.tiles[y + 1][x].topTile2 = def.tiles[0];
}
if (y >= 0 && y < this.height && x >= 0 && x < this.width) {
this.tiles[y][x].topTile2 = def.tiles[1];
}
}
}
}
}
// ===========================================================================
// TILE DEFINITIONS SETUP
// ===========================================================================
setupTileDefinitions(mainTileMap: TileMap): void {
setupDungeonTileDefinitions(mainTileMap);
}
getWidth(): number {
return this.width;
}
getHeight(): number {
return this.height;
}
// ===========================================================================
// UTILITY
// ===========================================================================
isGridWall(x: number, y: number): boolean {
if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
return true;
}
return this.grid[y]?.[x] === CELL_WALL;
}
isGridFloor(x: number, y: number): boolean {
if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
return false;
}
return this.grid[y]?.[x] === CELL_FLOOR;
}
/**
* Get the internal grid for lighting system torch placement
*/
getGrid(): TileGrid {
return this.grid;
}
// ===========================================================================
// MINIMAP / EXPLORATION
// ===========================================================================
/**
* Mark tiles around a position as explored (for minimap fog of war)
* @param centerX - Center X position in pixels
* @param centerY - Center Y position in pixels
* @param radius - Exploration radius in tiles
*/
exploreAroundPosition(centerX: number, centerY: number, radius: number = 8): void {
const tileX = Math.floor(centerX / TILE_SIZE);
const tileY = Math.floor(centerY / TILE_SIZE);
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const x = tileX + dx;
const y = tileY + dy;
// Circular exploration radius
if (dx * dx + dy * dy <= radius * radius) {
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
this.exploredTiles.add(`${x},${y}`);
}
}
}
}
}
/**
* Check if a tile has been explored
*/
isExplored(tileX: number, tileY: number): boolean {
return this.exploredTiles.has(`${tileX},${tileY}`);
}
/**
* Get minimap data for rendering
* Returns a 2D array where:
* - null = unexplored
* - true = wall (explored)
* - false = floor (explored)
*/
getMinimapData(): (boolean | null)[][] {
const data: (boolean | null)[][] = [];
for (let y = 0; y < this.height; y++) {
data[y] = [];
for (let x = 0; x < this.width; x++) {
if (this.isExplored(x, y)) {
data[y][x] = this.grid[y]?.[x] === CELL_WALL;
} else {
data[y][x] = null;
}
}
}
return data;
}
/**
* Get explored tiles set (for efficient minimap rendering)
*/
getExploredTiles(): Set<string> {
return this.exploredTiles;
}
}