UNPKG

shellquest

Version:

Terminal-based procedurally generated dungeon crawler

698 lines (609 loc) 22.1 kB
/** * 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; } }