shellquest
Version:
Terminal-based procedurally generated dungeon crawler
334 lines (284 loc) • 10.8 kB
text/typescript
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;
}
}