UNPKG

shellquest

Version:

Terminal-based procedurally generated dungeon crawler

334 lines (284 loc) 10.8 kB
import seedrandom from 'seedrandom'; import {TILE_SIZE, type TileMap} from '../../TileMap.ts'; import type {HouseData, LevelTile, TileInput} from '../types.ts'; import type {RenderTile} from '../../drawing/layeredRenderer.ts'; import {Entity} from '../../entities/Entity.ts'; import {FenceGenerator} from './FenceGenerator.ts'; import {HouseGenerator} from './HouseGenerator.ts'; import {TerrainGenerator} from './TerrainGenerator.ts'; import {TreeGenerator} from './TreeGenerator.ts'; import {Player} from '../../entities/Player.ts'; import {setupTileDefinitions} from './TileDefinitions.ts'; export class Level { private tiles: LevelTile[][]; private entities: Entity[] = []; private fenceGenerator: FenceGenerator; private houseGenerator: HouseGenerator; private terrainGenerator: TerrainGenerator; private treeGenerator: TreeGenerator; get player(): Player { return this.entities.find((e) => e instanceof Player) as Player; } constructor( private width: number, private height: number, ) { this.tiles = []; for (let y = 0; y < height; y++) { this.tiles[y] = []; for (let x = 0; x < width; x++) { this.tiles[y][x] = { bottomTile: 'void', solid: true, }; } } this.fenceGenerator = new FenceGenerator(this.tiles, width, height); this.houseGenerator = new HouseGenerator(this.tiles, width, height); this.terrainGenerator = new TerrainGenerator(this.tiles, width, height); this.treeGenerator = new TreeGenerator(this.tiles, width, height); } 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)); } 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, }); } // Also include topTile2 for overlapping tiles if (this.tiles[y][x].topTile2) { topTiles.push({ tile: this.tiles[y][x].topTile2!, x: x * TILE_SIZE, y: y * TILE_SIZE, }); } } } return topTiles; } 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; } generateMap(seed: string = 'default'): void { const rng = seedrandom(seed); // Generate base terrain this.terrainGenerator.generateTerrain(rng); // Generate trees and vegetation first (before houses) this.treeGenerator.generateTrees(rng); // Generate houses const houses = this.generateHouses(rng); // Generate decorative town fences after all houses are placed this.fenceGenerator.generateTownFences(rng); } private generateHouses(rng: seedrandom.PRNG): HouseData[] { const houses: HouseData[] = []; // Try to place houses - use grid-based approach for better placement const cellSize = 15; // Grid cells for house placement const gridWidth = Math.floor(this.width / cellSize); const gridHeight = Math.floor(this.height / cellSize); // Shuffle grid positions for random placement const gridPositions: Array<{gx: number; gy: number}> = []; for (let gy = 0; gy < gridHeight; gy++) { for (let gx = 0; gx < gridWidth; gx++) { gridPositions.push({gx, gy}); } } // Fisher-Yates shuffle for (let i = gridPositions.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [gridPositions[i], gridPositions[j]] = [gridPositions[j], gridPositions[i]]; } // Try to place houses in shuffled grid positions for (const {gx, gy} of gridPositions) { if (houses.length >= 15) break; // Limit total houses // Calculate actual position with some randomness within cell const baseX = gx * cellSize + 2; const baseY = gy * cellSize + 2; const x = baseX + Math.floor(rng() * Math.min(5, cellSize - 10)); const y = baseY + Math.floor(rng() * Math.min(5, cellSize - 10)); // Variable house sizes - keep reasonable const width = Math.floor(rng() * 4) + 3; // 3-6 width const height = Math.floor(rng() * 3) + 4; // 4-6 height // Quick bounds check if (x + width >= this.width - 2 || y + height >= this.height - 2) continue; // Check if area is suitable let suitable = true; // Only check the actual house footprint for (let dy = 0; dy < height; dy++) { for (let dx = 0; dx < width; dx++) { const checkX = x + dx; const checkY = y + dy; const tile = this.tiles[checkY][checkX]; // Check for existing houses if (tile.topTile) { suitable = false; break; } } if (!suitable) break; } // Simpler overlap check with less padding if (suitable) { for (const house of houses) { if ( x < house.x + house.width + 1 && x + width + 1 > house.x && y < house.y + house.height + 1 && y + height + 1 > house.y ) { suitable = false; break; } } } if (suitable) { // Choose house type const houseType = rng() < 0.5 ? 'house-1' : 'house-2'; // Generate the main house this.houseGenerator.generateHouse(x, y, width, height, houseType, rng); // Add to list houses.push({x, y, width, height}); // Add attached smaller houses for taller buildings if (height >= 5 && rng() < 0.6) { // Try to attach a smaller house const attachSide = rng() < 0.5 ? 'left' : 'right'; const attachWidth = Math.floor(rng() * 2) + 2; // 2-3 width const attachHeight = Math.floor(rng() * 2) + 3; // 3-4 height let attachX: number; let attachY = y + (height - attachHeight); // Align to bottom if (attachSide === 'left') { attachX = x - attachWidth; } else { attachX = x + width; } // Check if attachment position is valid let attachValid = true; if ( attachX >= 0 && attachX + attachWidth < this.width && attachY >= 0 && attachY + attachHeight < this.height ) { // Check for obstacles for (let dy = 0; dy < attachHeight; dy++) { for (let dx = 0; dx < attachWidth; dx++) { if (this.tiles[attachY + dy][attachX + dx].topTile) { attachValid = false; break; } } if (!attachValid) break; } if (attachValid) { // Choose different house type for variety const attachType = houseType; this.houseGenerator.generateHouse( attachX, attachY, attachWidth, attachHeight, attachType, rng, ); houses.push({x: attachX, y: attachY, width: attachWidth, height: attachHeight}); // Maybe add a third tiny attachment if (rng() < 0.3 && height >= 6) { const secondAttachSide = attachSide === 'left' ? 'right' : 'left'; const tinyWidth = 2; const tinyHeight = 3; const tinyX = secondAttachSide === 'left' ? x - tinyWidth : x + width; const tinyY = y + (height - tinyHeight); let tinyValid = true; if ( tinyX >= 0 && tinyX + tinyWidth < this.width && tinyY >= 0 && tinyY + tinyHeight < this.height ) { for (let dy = 0; dy < tinyHeight; dy++) { for (let dx = 0; dx < tinyWidth; dx++) { if (this.tiles[tinyY + dy][tinyX + dx].topTile) { tinyValid = false; break; } } if (!tinyValid) break; } if (tinyValid) { this.houseGenerator.generateHouse( tinyX, tinyY, tinyWidth, tinyHeight, houseType, rng, ); houses.push({x: tinyX, y: tinyY, width: tinyWidth, height: tinyHeight}); } } } } } } // Create small dirt path in front of main door if (y + height < this.height - 1) { const doorX = width === 3 ? x + 1 : x + Math.floor(width / 2); for (let i = 0; i < 2; i++) { if (y + height + i < this.height) { this.tiles[y + height + i][doorX].bottomTile = 'grass-walking-path'; if (width >= 4 && doorX + 1 < this.width) { this.tiles[y + height + i][doorX - 1].bottomTile = 'grass-walking-path'; } } } } // Add attached fence for some houses if (rng() < 0.6) { this.fenceGenerator.generateHouseFence(x, y, width, height, rng); } } } return houses; } setupTileDefinitions(mainTileMap: TileMap): void { setupTileDefinitions(mainTileMap); } getWidth(): number { return this.width; } getHeight(): number { return this.height; } }