shellquest
Version:
Terminal-based procedurally generated dungeon crawler
1,352 lines (1,146 loc) • 41.4 kB
text/typescript
/**
* Dungeon Map Generator
* Ported from web/src/app/routes/wall-gen/dungeonGenerator.ts for TUI game
*/
import {
CELL_FLOOR,
CELL_WALL,
SeededRandom,
setNoiseSeed,
getNoise2D,
DECAL_DEFINITIONS,
} from './DungeonUtils.ts';
import type {
CellType,
TileGrid,
DecalPlacement,
TrackPath,
TrackSegment,
FencePlacement,
FenceSegment,
TrackDirection,
DecalDefinition,
} from './DungeonUtils.ts';
import {TRACK_TILE_NAMES, FENCE_TILE_NAMES} from './DungeonTileDefinitions.ts';
// =============================================================================
// GENERATOR CONFIGURATION
// =============================================================================
export interface DungeonGeneratorConfig {
// Room settings
minRoomSize: number;
maxRoomSize: number;
roomPadding: number;
bspDepth: number;
// Corridor settings
corridorWidth: number;
extraCorridorRatio: number;
// Cave settings
caveCount: number;
caveMinSize: number;
caveMaxSize: number;
caveSmoothingPasses: number;
caveInitialDensity: number;
// Room type weights
roomWeights: {
regular: number;
cave: number;
circular: number;
cross: number;
treasure: number;
arena: number;
};
// Decal settings
decalDensityMultiplier: number;
enableGroundDecals: boolean;
enableWallDecals: boolean;
enablePillars: boolean;
// Track settings
enableTracks: boolean;
trackCount: number;
trackMinLength: number;
trackMaxLength: number;
trackTurnChance: number;
// Fence settings
enableFences: boolean;
fenceCount: number;
fenceMinWidth: number;
fenceMaxWidth: number;
}
export const DEFAULT_DUNGEON_CONFIG: DungeonGeneratorConfig = {
minRoomSize: 6,
maxRoomSize: 18,
roomPadding: 2,
bspDepth: 4,
corridorWidth: 3,
extraCorridorRatio: 0.3,
caveCount: 3,
caveMinSize: 10,
caveMaxSize: 25,
caveSmoothingPasses: 3,
caveInitialDensity: 0.45,
roomWeights: {
regular: 0.4,
cave: 0.15,
circular: 0.15,
cross: 0.1,
treasure: 0.1,
arena: 0.1,
},
decalDensityMultiplier: 1.0,
enableGroundDecals: true,
enableWallDecals: true,
enablePillars: true,
enableTracks: true,
trackCount: 5,
trackMinLength: 5,
trackMaxLength: 20,
trackTurnChance: 0.25,
enableFences: true,
fenceCount: 8,
fenceMinWidth: 3,
fenceMaxWidth: 6,
};
// =============================================================================
// TYPES
// =============================================================================
interface Room {
x: number;
y: number;
width: number;
height: number;
type: RoomType;
connected: boolean;
}
type RoomType = 'regular' | 'cave' | 'circular' | 'cross' | 'treasure' | 'arena';
interface Point {
x: number;
y: number;
}
interface BSPNode {
x: number;
y: number;
width: number;
height: number;
left?: BSPNode;
right?: BSPNode;
room?: Room;
}
// =============================================================================
// GENERATION RESULT
// =============================================================================
export interface DungeonGenerationResult {
grid: TileGrid;
decals: DecalPlacement[];
tracks: TrackPath[];
fences: FencePlacement[];
rooms: Room[];
spawnPoint: Point;
}
// =============================================================================
// DUNGEON GENERATOR CLASS
// =============================================================================
export class DungeonMapGenerator {
private rng: SeededRandom;
private decalRng: SeededRandom;
private grid: TileGrid;
private decals: DecalPlacement[];
private tracks: TrackPath[];
private fences: FencePlacement[];
private rooms: Room[];
private width: number;
private height: number;
private config: DungeonGeneratorConfig;
private occupiedDecalPositions: Set<string>;
private occupiedTrackPositions: Set<string>;
constructor(
seed: number,
decalSeed: number,
width: number,
height: number,
config: Partial<DungeonGeneratorConfig> = {}
) {
this.rng = new SeededRandom(seed);
this.decalRng = new SeededRandom(decalSeed);
this.width = width;
this.height = height;
this.config = {...DEFAULT_DUNGEON_CONFIG, ...config};
this.grid = this.createEmptyGrid(CELL_WALL);
this.decals = [];
this.tracks = [];
this.fences = [];
this.rooms = [];
this.occupiedDecalPositions = new Set();
this.occupiedTrackPositions = new Set();
setNoiseSeed(decalSeed);
}
// ===========================================================================
// GRID UTILITIES
// ===========================================================================
private createEmptyGrid(fillWith: CellType): TileGrid {
return Array(this.height)
.fill(null)
.map(() => Array(this.width).fill(fillWith));
}
private inBounds(x: number, y: number): boolean {
return x >= 0 && x < this.width && y >= 0 && y < this.height;
}
private setCell(x: number, y: number, type: CellType): void {
if (this.inBounds(x, y)) {
this.grid[y][x] = type;
}
}
private getCell(x: number, y: number): CellType {
if (!this.inBounds(x, y)) return CELL_WALL;
return this.grid[y][x];
}
private isWallCell(x: number, y: number): boolean {
return this.getCell(x, y) === CELL_WALL;
}
private isFloorCell(x: number, y: number): boolean {
return this.getCell(x, y) === CELL_FLOOR;
}
// ===========================================================================
// MAIN GENERATION
// ===========================================================================
generate(): DungeonGenerationResult {
// Step 1: Generate base dungeon using BSP
const bspRoot = this.generateBSP(2, 2, this.width - 4, this.height - 4, this.config.bspDepth);
this.createRoomsFromBSP(bspRoot);
// Step 2: Connect all rooms with corridors
this.connectRooms();
// Step 3: Add organic cave sections
this.addCaveSections();
// Step 4: Apply cellular automata smoothing
this.smoothCaves(this.config.caveSmoothingPasses);
// Step 5: Ensure all areas are connected
this.ensureConnectivity();
// Step 6: Clean up thin walls
this.fixThinWalls();
// Step 7: Add border walls
this.addBorder();
// Step 8: Place tracks
if (this.config.enableTracks) {
this.placeTracks();
}
// Step 9: Place fences
if (this.config.enableFences) {
this.placeFences();
}
// Step 10: Place decals
this.placeDecals();
// Find spawn point in first room
const spawnPoint = this.findSpawnPoint();
return {
grid: this.grid,
decals: this.decals,
tracks: this.tracks,
fences: this.fences,
rooms: this.rooms,
spawnPoint,
};
}
private findSpawnPoint(): Point {
if (this.rooms.length > 0) {
const room = this.rooms[0];
return {
x: Math.floor(room.x + room.width / 2),
y: Math.floor(room.y + room.height / 2),
};
}
// Fallback: find any floor tile
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
if (this.isFloorCell(x, y)) {
return {x, y};
}
}
}
return {x: Math.floor(this.width / 2), y: Math.floor(this.height / 2)};
}
// ===========================================================================
// BSP GENERATION
// ===========================================================================
private generateBSP(x: number, y: number, width: number, height: number, depth: number): BSPNode {
const node: BSPNode = {x, y, width, height};
const minSize = this.config.minRoomSize + this.config.roomPadding * 2;
if (depth <= 0 || width < minSize * 1.5 || height < minSize * 1.5) {
return node;
}
const splitHorizontal =
width > height * 1.25 ? false : height > width * 1.25 ? true : this.rng.nextBool();
if (splitHorizontal) {
const splitY = this.rng.nextInt(Math.floor(height * 0.3), Math.floor(height * 0.7));
node.left = this.generateBSP(x, y, width, splitY, depth - 1);
node.right = this.generateBSP(x, y + splitY, width, height - splitY, depth - 1);
} else {
const splitX = this.rng.nextInt(Math.floor(width * 0.3), Math.floor(width * 0.7));
node.left = this.generateBSP(x, y, splitX, height, depth - 1);
node.right = this.generateBSP(x + splitX, y, width - splitX, height, depth - 1);
}
return node;
}
private createRoomsFromBSP(node: BSPNode): void {
if (node.left && node.right) {
this.createRoomsFromBSP(node.left);
this.createRoomsFromBSP(node.right);
} else {
const roomType = this.pickRoomType();
const padding = this.config.roomPadding;
const minSize = this.config.minRoomSize;
const maxSize = this.config.maxRoomSize;
const availableWidth = Math.max(minSize, node.width - padding * 2);
const availableHeight = Math.max(minSize, node.height - padding * 2);
const roomWidth = this.rng.nextInt(
Math.min(minSize, availableWidth),
Math.min(maxSize, availableWidth)
);
const roomHeight = this.rng.nextInt(
Math.min(minSize, availableHeight),
Math.min(maxSize, availableHeight)
);
const roomX =
node.x + this.rng.nextInt(padding, Math.max(padding, node.width - roomWidth - padding));
const roomY =
node.y + this.rng.nextInt(padding, Math.max(padding, node.height - roomHeight - padding));
const room: Room = {
x: roomX,
y: roomY,
width: roomWidth,
height: roomHeight,
type: roomType,
connected: false,
};
node.room = room;
this.rooms.push(room);
this.carveRoom(room);
}
}
private pickRoomType(): RoomType {
const weights = this.config.roomWeights;
const roll = this.rng.next();
let cumulative = 0;
const types: RoomType[] = ['regular', 'cave', 'circular', 'cross', 'treasure', 'arena'];
for (const type of types) {
cumulative += weights[type];
if (roll < cumulative) return type;
}
return 'regular';
}
// ===========================================================================
// ROOM CARVING
// ===========================================================================
private carveRoom(room: Room): void {
switch (room.type) {
case 'regular':
this.carveRectangle(room.x, room.y, room.width, room.height);
break;
case 'cave':
this.carveCave(room.x, room.y, room.width, room.height);
break;
case 'circular':
this.carveCircular(room.x, room.y, room.width, room.height);
break;
case 'cross':
this.carveCross(room.x, room.y, room.width, room.height);
break;
case 'treasure':
this.carveTreasureRoom(room.x, room.y, room.width, room.height);
break;
case 'arena':
this.carveArena(room.x, room.y, room.width, room.height);
break;
}
}
private carveRectangle(x: number, y: number, w: number, h: number): void {
for (let dy = 0; dy < h; dy++) {
for (let dx = 0; dx < w; dx++) {
this.setCell(x + dx, y + dy, CELL_FLOOR);
}
}
}
private carveCave(x: number, y: number, w: number, h: number): void {
const centerX = x + w / 2;
const centerY = y + h / 2;
const radiusX = w / 2;
const radiusY = h / 2;
for (let dy = 0; dy < h; dy++) {
for (let dx = 0; dx < w; dx++) {
const px = x + dx;
const py = y + dy;
const distX = (px - centerX) / radiusX;
const distY = (py - centerY) / radiusY;
const dist = Math.sqrt(distX * distX + distY * distY);
const noise = getNoise2D(px, py, 0.2) * 0.4 - 0.2;
if (dist < 0.85 + noise) {
this.setCell(px, py, CELL_FLOOR);
}
}
}
}
private carveCircular(x: number, y: number, w: number, h: number): void {
const centerX = x + w / 2;
const centerY = y + h / 2;
const radius = Math.min(w, h) / 2 - 1;
for (let dy = 0; dy < h; dy++) {
for (let dx = 0; dx < w; dx++) {
const px = x + dx;
const py = y + dy;
const dist = Math.sqrt(Math.pow(px - centerX, 2) + Math.pow(py - centerY, 2));
if (dist < radius) {
this.setCell(px, py, CELL_FLOOR);
}
}
}
}
private carveCross(x: number, y: number, w: number, h: number): void {
const hBarY = y + Math.floor(h * 0.3);
const hBarH = Math.floor(h * 0.4);
this.carveRectangle(x, hBarY, w, hBarH);
const vBarX = x + Math.floor(w * 0.3);
const vBarW = Math.floor(w * 0.4);
this.carveRectangle(vBarX, y, vBarW, h);
}
private carveTreasureRoom(x: number, y: number, w: number, h: number): void {
this.carveRectangle(x + 2, y + 2, w - 4, h - 4);
const alcoveSize = 3;
if (h > 10) {
this.carveRectangle(x + Math.floor(w / 2) - 1, y, alcoveSize, 2);
this.carveRectangle(x + Math.floor(w / 2) - 1, y + h - 2, alcoveSize, 2);
}
if (w > 10) {
this.carveRectangle(x, y + Math.floor(h / 2) - 1, 2, alcoveSize);
this.carveRectangle(x + w - 2, y + Math.floor(h / 2) - 1, 2, alcoveSize);
}
}
private carveArena(x: number, y: number, w: number, h: number): void {
const centerX = x + w / 2;
const centerY = y + h / 2;
const outerRadius = Math.min(w, h) / 2 - 1;
for (let dy = 0; dy < h; dy++) {
for (let dx = 0; dx < w; dx++) {
const px = x + dx;
const py = y + dy;
const dist = Math.sqrt(Math.pow(px - centerX, 2) + Math.pow(py - centerY, 2));
if (dist < outerRadius) {
this.setCell(px, py, CELL_FLOOR);
}
}
}
}
// ===========================================================================
// CORRIDOR GENERATION
// ===========================================================================
private connectRooms(): void {
if (this.rooms.length < 2) return;
const connected: Room[] = [this.rooms[0]];
this.rooms[0].connected = true;
const remaining = this.rooms.slice(1);
while (remaining.length > 0) {
let bestDist = Infinity;
let bestFrom: Room | null = null;
let bestTo: Room | null = null;
let bestToIdx = -1;
for (const from of connected) {
for (let i = 0; i < remaining.length; i++) {
const to = remaining[i];
const dist = this.roomDistance(from, to);
if (dist < bestDist) {
bestDist = dist;
bestFrom = from;
bestTo = to;
bestToIdx = i;
}
}
}
if (bestFrom && bestTo && bestToIdx >= 0) {
this.carveCorridor(bestFrom, bestTo);
bestTo.connected = true;
connected.push(bestTo);
remaining.splice(bestToIdx, 1);
}
}
// Add extra corridors for loops
const extraCorridors = Math.floor(this.rooms.length * this.config.extraCorridorRatio);
for (let i = 0; i < extraCorridors; i++) {
const from = this.rng.pick(this.rooms);
const to = this.rng.pick(this.rooms.filter((r) => r !== from));
if (this.roomDistance(from, to) < 40) {
this.carveCorridor(from, to);
}
}
}
private roomDistance(a: Room, b: Room): number {
const ax = a.x + a.width / 2;
const ay = a.y + a.height / 2;
const bx = b.x + b.width / 2;
const by = b.y + b.height / 2;
return Math.sqrt(Math.pow(ax - bx, 2) + Math.pow(ay - by, 2));
}
private roomCenter(room: Room): Point {
return {
x: Math.floor(room.x + room.width / 2),
y: Math.floor(room.y + room.height / 2),
};
}
private carveCorridor(from: Room, to: Room): void {
const start = this.roomCenter(from);
const end = this.roomCenter(to);
const style = this.rng.nextInt(0, 3);
switch (style) {
case 0:
this.carveCorridorL(start, end);
break;
case 1:
this.carveCorridorZ(start, end);
break;
case 2:
this.carveCorridorWinding(start, end);
break;
default:
this.carveCorridorDirect(start, end);
break;
}
}
private carveCorridorL(start: Point, end: Point): void {
const width = this.config.corridorWidth;
if (this.rng.nextBool()) {
this.carveHLine(start.x, end.x, start.y, width);
this.carveVLine(start.y, end.y, end.x, width);
} else {
this.carveVLine(start.y, end.y, start.x, width);
this.carveHLine(start.x, end.x, end.y, width);
}
}
private carveCorridorZ(start: Point, end: Point): void {
const width = this.config.corridorWidth;
const midY = Math.floor((start.y + end.y) / 2);
this.carveVLine(start.y, midY, start.x, width);
this.carveHLine(start.x, end.x, midY, width);
this.carveVLine(midY, end.y, end.x, width);
}
private carveCorridorWinding(start: Point, end: Point): void {
const width = this.config.corridorWidth;
let current = {...start};
const segments = this.rng.nextInt(3, 5);
for (let i = 0; i < segments; i++) {
const isLast = i === segments - 1;
const targetX = isLast
? end.x
: this.rng.nextInt(Math.min(current.x, end.x), Math.max(current.x, end.x));
const targetY = isLast
? end.y
: this.rng.nextInt(Math.min(current.y, end.y), Math.max(current.y, end.y));
if (this.rng.nextBool()) {
this.carveHLine(current.x, targetX, current.y, width);
this.carveVLine(current.y, targetY, targetX, width);
} else {
this.carveVLine(current.y, targetY, current.x, width);
this.carveHLine(current.x, targetX, targetY, width);
}
current = {x: targetX, y: targetY};
}
}
private carveCorridorDirect(start: Point, end: Point): void {
const width = this.config.corridorWidth;
this.carveHLine(start.x, end.x, start.y, width);
this.carveVLine(start.y, end.y, end.x, width);
}
private carveHLine(x1: number, x2: number, y: number, width: number): void {
const startX = Math.min(x1, x2);
const endX = Math.max(x1, x2);
for (let x = startX; x <= endX; x++) {
for (let w = 0; w < width; w++) {
this.setCell(x, y + w, CELL_FLOOR);
}
}
}
private carveVLine(y1: number, y2: number, x: number, width: number): void {
const startY = Math.min(y1, y2);
const endY = Math.max(y1, y2);
for (let y = startY; y <= endY; y++) {
for (let w = 0; w < width; w++) {
this.setCell(x + w, y, CELL_FLOOR);
}
}
}
// ===========================================================================
// CAVE GENERATION
// ===========================================================================
private addCaveSections(): void {
for (let i = 0; i < this.config.caveCount; i++) {
const x = this.rng.nextInt(10, this.width - this.config.caveMaxSize - 10);
const y = this.rng.nextInt(10, this.height - this.config.caveMaxSize - 10);
const w = this.rng.nextInt(this.config.caveMinSize, this.config.caveMaxSize);
const h = this.rng.nextInt(this.config.caveMinSize, this.config.caveMaxSize);
let hasFloorNearby = false;
for (let dy = -5; dy < h + 5 && !hasFloorNearby; dy++) {
for (let dx = -5; dx < w + 5 && !hasFloorNearby; dx++) {
if (this.isFloorCell(x + dx, y + dy)) {
hasFloorNearby = true;
}
}
}
if (hasFloorNearby) {
this.generateCaveSection(x, y, w, h);
}
}
}
private generateCaveSection(x: number, y: number, w: number, h: number): void {
const caveGrid: boolean[][] = [];
for (let dy = 0; dy < h; dy++) {
caveGrid[dy] = [];
for (let dx = 0; dx < w; dx++) {
caveGrid[dy][dx] = this.rng.nextBool(this.config.caveInitialDensity);
}
}
for (let iter = 0; iter < 5; iter++) {
const newGrid: boolean[][] = [];
for (let dy = 0; dy < h; dy++) {
newGrid[dy] = [];
for (let dx = 0; dx < w; dx++) {
const neighbors = this.countCaveNeighbors(caveGrid, dx, dy, w, h);
newGrid[dy][dx] = neighbors >= 5 || (neighbors === 0 && iter < 3);
}
}
caveGrid.length = 0;
caveGrid.push(...newGrid);
}
for (let dy = 0; dy < h; dy++) {
for (let dx = 0; dx < w; dx++) {
if (!caveGrid[dy][dx]) {
this.setCell(x + dx, y + dy, CELL_FLOOR);
}
}
}
}
private countCaveNeighbors(
grid: boolean[][],
x: number,
y: number,
w: number,
h: number
): number {
let count = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || nx >= w || ny < 0 || ny >= h) {
count++;
} else if (grid[ny][nx]) {
count++;
}
}
}
return count;
}
private smoothCaves(iterations: number): void {
for (let iter = 0; iter < iterations; iter++) {
const newGrid = this.createEmptyGrid(CELL_WALL);
for (let y = 1; y < this.height - 1; y++) {
for (let x = 1; x < this.width - 1; x++) {
const floorCount = this.countFloorNeighbors(x, y);
if (this.isFloorCell(x, y)) {
newGrid[y][x] = floorCount >= 3 ? CELL_FLOOR : CELL_WALL;
} else {
newGrid[y][x] = floorCount >= 6 ? CELL_FLOOR : CELL_WALL;
}
}
}
this.grid = newGrid;
}
}
private countFloorNeighbors(x: number, y: number): number {
let count = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
if (this.isFloorCell(x + dx, y + dy)) count++;
}
}
return count;
}
// ===========================================================================
// CONNECTIVITY
// ===========================================================================
private ensureConnectivity(): void {
const visited = new Set<string>();
const regions: Set<string>[] = [];
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
if (this.isFloorCell(x, y) && !visited.has(`${x},${y}`)) {
const region = this.floodFill(x, y, visited);
regions.push(region);
}
}
}
if (regions.length > 1) {
regions.sort((a, b) => b.size - a.size);
const mainRegion = regions[0];
for (let i = 1; i < regions.length; i++) {
this.connectRegions(mainRegion, regions[i]);
for (const tile of regions[i]) {
mainRegion.add(tile);
}
}
}
}
private floodFill(startX: number, startY: number, visited: Set<string>): Set<string> {
const region = new Set<string>();
const stack: Point[] = [{x: startX, y: startY}];
while (stack.length > 0) {
const {x, y} = stack.pop()!;
const key = `${x},${y}`;
if (visited.has(key) || !this.isFloorCell(x, y)) continue;
visited.add(key);
region.add(key);
stack.push({x: x - 1, y});
stack.push({x: x + 1, y});
stack.push({x, y: y - 1});
stack.push({x, y: y + 1});
}
return region;
}
private connectRegions(regionA: Set<string>, regionB: Set<string>): void {
let bestDist = Infinity;
let bestA: Point | null = null;
let bestB: Point | null = null;
const tilesA = Array.from(regionA).map((s) => {
const [x, y] = s.split(',').map(Number);
return {x, y};
});
const tilesB = Array.from(regionB).map((s) => {
const [x, y] = s.split(',').map(Number);
return {x, y};
});
const sampleA = this.rng.shuffle([...tilesA]).slice(0, 50);
const sampleB = this.rng.shuffle([...tilesB]).slice(0, 50);
for (const a of sampleA) {
for (const b of sampleB) {
const dist = Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
if (dist < bestDist) {
bestDist = dist;
bestA = a;
bestB = b;
}
}
}
if (bestA && bestB) {
this.carveCorridorDirect(bestA, bestB);
}
}
// ===========================================================================
// CLEANUP
// ===========================================================================
private fixThinWalls(): void {
for (let pass = 0; pass < 3; pass++) {
let changed = false;
for (let y = 1; y < this.height - 1; y++) {
for (let x = 1; x < this.width - 1; x++) {
if (!this.isWallCell(x, y)) continue;
const hasN = this.isWallCell(x, y - 1);
const hasS = this.isWallCell(x, y + 1);
const hasW = this.isWallCell(x - 1, y);
const hasE = this.isWallCell(x + 1, y);
const isHorizontalStrip = (hasW || hasE) && !hasN && !hasS;
const isVerticalStrip = (hasN || hasS) && !hasW && !hasE;
const isIsolated = !hasN && !hasS && !hasW && !hasE;
if (isHorizontalStrip || isIsolated) {
if (y < this.height - 1 && this.isFloorCell(x, y + 1)) {
this.setCell(x, y + 1, CELL_WALL);
changed = true;
} else if (y > 0 && this.isFloorCell(x, y - 1)) {
this.setCell(x, y - 1, CELL_WALL);
changed = true;
}
}
if (isVerticalStrip || isIsolated) {
if (x < this.width - 1 && this.isFloorCell(x + 1, y)) {
this.setCell(x + 1, y, CELL_WALL);
changed = true;
} else if (x > 0 && this.isFloorCell(x - 1, y)) {
this.setCell(x - 1, y, CELL_WALL);
changed = true;
}
}
}
}
if (!changed) break;
}
}
private addBorder(): void {
for (let x = 0; x < this.width; x++) {
this.setCell(x, 0, CELL_WALL);
this.setCell(x, 1, CELL_WALL);
this.setCell(x, this.height - 1, CELL_WALL);
this.setCell(x, this.height - 2, CELL_WALL);
}
for (let y = 0; y < this.height; y++) {
this.setCell(0, y, CELL_WALL);
this.setCell(1, y, CELL_WALL);
this.setCell(this.width - 1, y, CELL_WALL);
this.setCell(this.width - 2, y, CELL_WALL);
}
}
// ===========================================================================
// DECAL PLACEMENT
// ===========================================================================
private placeDecals(): void {
const densityMult = this.config.decalDensityMultiplier;
for (const def of DECAL_DEFINITIONS) {
if (def.placement === 'ground' && !this.config.enableGroundDecals) continue;
if (def.placement === 'wall_face' && !this.config.enableWallDecals) continue;
if (def.placement === 'wall_to_ground' && !this.config.enablePillars) continue;
this.placeDecalType(def, densityMult);
}
}
private placeDecalType(def: DecalDefinition, densityMult: number): void {
const effectiveFrequency = def.frequency * densityMult;
const placedPositions: Point[] = [];
const defIdHash = this.hashString(def.id);
for (let y = 2; y < this.height - 2; y++) {
for (let x = 2; x < this.width - 2; x++) {
if (!this.isValidDecalPosition(def, x, y)) continue;
const tooClose = placedPositions.some(
(p) => Math.abs(p.x - x) + Math.abs(p.y - y) < def.minSpacing
);
if (tooClose) continue;
const posKey = `${x},${y}`;
if (this.occupiedDecalPositions.has(posKey)) continue;
const noiseVal = getNoise2D((x + defIdHash) * 7.3, (y + defIdHash) * 7.3, 0.5);
const rollNoise = getNoise2D((x + defIdHash) * 13.7, (y + defIdHash) * 13.7, 0.3);
const threshold = effectiveFrequency * (0.5 + noiseVal) * 5;
if (rollNoise < threshold) {
this.decals.push({
definitionId: def.id,
x,
y,
});
placedPositions.push({x, y});
this.markDecalOccupied(def, x, y);
}
}
}
}
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) % 1000;
}
private isValidDecalPosition(def: DecalDefinition, x: number, y: number): boolean {
switch (def.placement) {
case 'ground':
if (!this.isFloorCell(x, y)) return false;
if (def.nearWall) {
const hasAdjacentWall =
this.isWallCell(x - 1, y) ||
this.isWallCell(x + 1, y) ||
this.isWallCell(x, y - 1) ||
this.isWallCell(x, y + 1);
if (!hasAdjacentWall) return false;
}
return true;
case 'wall_face':
if (!this.isWallCell(x, y)) return false;
if (!this.isFloorCell(x, y + 1)) return false;
if (def.width === 2) {
if (!this.isWallCell(x + 1, y)) return false;
if (!this.isFloorCell(x + 1, y + 1)) return false;
}
return true;
case 'wall_to_ground':
if (!this.isWallCell(x, y)) return false;
if (!this.isFloorCell(x, y + 1)) return false;
if (!this.isWallCell(x, y - 1)) return false;
if (!this.isWallCell(x, y - 2)) return false;
return true;
default:
return false;
}
}
private markDecalOccupied(def: DecalDefinition, x: number, y: number): void {
this.occupiedDecalPositions.add(`${x},${y}`);
if (def.width === 2) {
this.occupiedDecalPositions.add(`${x + 1},${y}`);
}
for (let i = 0; i < def.tiles.length; i++) {
const tileY = y - i;
this.occupiedDecalPositions.add(`${x},${tileY}`);
if (def.width === 2) {
this.occupiedDecalPositions.add(`${x + 1},${tileY}`);
}
}
}
// ===========================================================================
// TRACK GENERATION
// ===========================================================================
private placeTracks(): void {
const {trackCount, trackMinLength, trackMaxLength} = this.config;
for (let i = 0; i < trackCount; i++) {
const track = this.generateSingleTrack(trackMinLength, trackMaxLength);
if (track && track.segments.length >= trackMinLength) {
this.tracks.push(track);
for (const seg of track.segments) {
this.occupiedTrackPositions.add(`${seg.x},${seg.y}`);
this.occupiedDecalPositions.add(`${seg.x},${seg.y}`);
}
}
}
}
private generateSingleTrack(minLength: number, maxLength: number): TrackPath | null {
const targetLength = this.decalRng.nextInt(minLength, maxLength);
let startX = 0,
startY = 0;
let attempts = 0;
const maxAttempts = 100;
while (attempts < maxAttempts) {
startX = this.decalRng.nextInt(4, this.width - 5);
startY = this.decalRng.nextInt(4, this.height - 5);
if (
this.isFloorCell(startX, startY) &&
!this.occupiedTrackPositions.has(`${startX},${startY}`)
) {
break;
}
attempts++;
}
if (attempts >= maxAttempts) return null;
const segments: TrackSegment[] = [];
let x = startX;
let y = startY;
const directions: TrackDirection[] = ['up', 'down', 'left', 'right'];
let direction = this.decalRng.pick(directions);
let validDirs = this.getValidTrackDirections(x, y, null);
if (validDirs.length === 0) return null;
if (!validDirs.includes(direction)) {
direction = this.decalRng.pick(validDirs);
}
for (let step = 0; step < targetLength; step++) {
validDirs = this.getValidTrackDirections(x, y, direction);
if (validDirs.length === 0) break;
let newDirection = direction;
if (this.decalRng.next() < this.config.trackTurnChance && validDirs.length > 1) {
const turnDirs = validDirs.filter((d) => d !== direction);
if (turnDirs.length > 0) {
newDirection = this.decalRng.pick(turnDirs);
}
} else if (!validDirs.includes(direction)) {
newDirection = this.decalRng.pick(validDirs);
}
const tileId = this.getTrackTileId(direction, newDirection);
segments.push({x, y, tileId});
this.occupiedTrackPositions.add(`${x},${y}`);
const {dx, dy} = this.getDirectionDelta(newDirection);
x += dx;
y += dy;
direction = newDirection;
if (!this.isFloorCell(x, y) || this.occupiedTrackPositions.has(`${x},${y}`)) {
break;
}
}
if (segments.length > 0 && this.isFloorCell(x, y) && !this.occupiedTrackPositions.has(`${x},${y}`)) {
const tileId = this.getExitEndTileId(direction);
segments.push({x, y, tileId});
}
if (segments.length > 0) {
if (segments.length >= 2) {
const first = segments[0];
const second = segments[1];
let entryDir: TrackDirection;
if (second.x > first.x) entryDir = 'right';
else if (second.x < first.x) entryDir = 'left';
else if (second.y > first.y) entryDir = 'down';
else entryDir = 'up';
segments[0].tileId = this.getStartEndTileId(entryDir);
} else {
segments[0].tileId = this.getExitEndTileId(direction);
}
}
return {segments};
}
private getValidTrackDirections(
x: number,
y: number,
currentDir: TrackDirection | null
): TrackDirection[] {
const valid: TrackDirection[] = [];
const directions: TrackDirection[] = ['up', 'down', 'left', 'right'];
for (const dir of directions) {
const {dx, dy} = this.getDirectionDelta(dir);
const nx = x + dx;
const ny = y + dy;
if (this.isFloorCell(nx, ny) && !this.occupiedTrackPositions.has(`${nx},${ny}`)) {
if (currentDir !== null && this.isOppositeDirection(currentDir, dir)) {
continue;
}
valid.push(dir);
}
}
return valid;
}
private isOppositeDirection(dir1: TrackDirection, dir2: TrackDirection): boolean {
return (
(dir1 === 'up' && dir2 === 'down') ||
(dir1 === 'down' && dir2 === 'up') ||
(dir1 === 'left' && dir2 === 'right') ||
(dir1 === 'right' && dir2 === 'left')
);
}
private getDirectionDelta(dir: TrackDirection): {dx: number; dy: number} {
switch (dir) {
case 'up':
return {dx: 0, dy: -1};
case 'down':
return {dx: 0, dy: 1};
case 'left':
return {dx: -1, dy: 0};
case 'right':
return {dx: 1, dy: 0};
}
}
private getTrackTileId(fromDir: TrackDirection, toDir: TrackDirection): string {
if (fromDir === toDir) {
return this.getStraightTrackTileId(fromDir);
}
const entryDir = this.getOppositeDirection(fromDir);
if ((entryDir === 'up' && toDir === 'right') || (entryDir === 'right' && toDir === 'up')) {
return TRACK_TILE_NAMES.cornerUpRight;
}
if ((entryDir === 'left' && toDir === 'down') || (entryDir === 'down' && toDir === 'left')) {
return TRACK_TILE_NAMES.cornerLeftDown;
}
if ((entryDir === 'up' && toDir === 'left') || (entryDir === 'left' && toDir === 'up')) {
return TRACK_TILE_NAMES.cornerUpLeft;
}
if ((entryDir === 'right' && toDir === 'down') || (entryDir === 'down' && toDir === 'right')) {
return TRACK_TILE_NAMES.cornerRightDown;
}
return this.getStraightTrackTileId(toDir);
}
private getOppositeDirection(dir: TrackDirection): TrackDirection {
switch (dir) {
case 'up':
return 'down';
case 'down':
return 'up';
case 'left':
return 'right';
case 'right':
return 'left';
}
}
private getStraightTrackTileId(dir: TrackDirection): string {
if (dir === 'up' || dir === 'down') {
return TRACK_TILE_NAMES.straightVertical;
}
return TRACK_TILE_NAMES.straightHorizontal;
}
private getStartEndTileId(dir: TrackDirection): string {
switch (dir) {
case 'right':
return TRACK_TILE_NAMES.endLeft;
case 'left':
return TRACK_TILE_NAMES.endRight;
case 'down':
return TRACK_TILE_NAMES.endTop;
case 'up':
return TRACK_TILE_NAMES.endBottom;
}
}
private getExitEndTileId(dir: TrackDirection): string {
switch (dir) {
case 'right':
return TRACK_TILE_NAMES.endRight;
case 'left':
return TRACK_TILE_NAMES.endLeft;
case 'down':
return TRACK_TILE_NAMES.endBottom;
case 'up':
return TRACK_TILE_NAMES.endTop;
}
}
// ===========================================================================
// FENCE GENERATION
// ===========================================================================
private placeFences(): void {
const {fenceCount, fenceMinWidth, fenceMaxWidth} = this.config;
let placed = 0;
let attempts = 0;
const maxAttempts = fenceCount * 20;
while (placed < fenceCount && attempts < maxAttempts) {
attempts++;
const fence = this.tryPlaceFence(fenceMinWidth, fenceMaxWidth);
if (fence) {
this.fences.push(fence);
for (const seg of fence.segments) {
this.occupiedDecalPositions.add(`${seg.x},${seg.y}`);
this.occupiedTrackPositions.add(`${seg.x},${seg.y}`);
}
placed++;
}
}
}
private tryPlaceFence(minWidth: number, maxWidth: number): FencePlacement | null {
const startX = this.decalRng.nextInt(4, this.width - maxWidth - 4);
const startY = this.decalRng.nextInt(4, this.height - 4);
if (!this.isFloorCell(startX, startY)) return null;
if (this.occupiedDecalPositions.has(`${startX},${startY}`)) return null;
if (this.occupiedTrackPositions.has(`${startX},${startY}`)) return null;
let width = 0;
const targetWidth = this.decalRng.nextInt(minWidth, maxWidth);
for (let dx = 0; dx < targetWidth && startX + dx < this.width - 2; dx++) {
const x = startX + dx;
if (!this.isFloorCell(x, startY)) break;
if (this.occupiedDecalPositions.has(`${x},${startY}`)) break;
if (this.occupiedTrackPositions.has(`${x},${startY}`)) break;
width++;
}
if (width < minWidth) return null;
const hasWallContext = this.hasFenceWallContext(startX, startY, width);
const segments: FenceSegment[] = [];
const needsEdges = !hasWallContext;
if (needsEdges) {
if (width < 3) return null;
segments.push({x: startX, y: startY, tileId: FENCE_TILE_NAMES.left});
for (let i = 1; i < width - 1; i++) {
segments.push({x: startX + i, y: startY, tileId: FENCE_TILE_NAMES.middle});
}
segments.push({x: startX + width - 1, y: startY, tileId: FENCE_TILE_NAMES.right});
} else {
for (let i = 0; i < width; i++) {
segments.push({x: startX + i, y: startY, tileId: FENCE_TILE_NAMES.middle});
}
}
return {segments};
}
private hasFenceWallContext(startX: number, y: number, width: number): boolean {
const hasWallAtStart =
this.isWallCell(startX - 1, y) ||
this.isWallCell(startX, y - 1) ||
this.isWallCell(startX, y + 1);
const endX = startX + width - 1;
const hasWallAtEnd =
this.isWallCell(endX + 1, y) ||
this.isWallCell(endX, y - 1) ||
this.isWallCell(endX, y + 1);
return hasWallAtStart && hasWallAtEnd;
}
}
// =============================================================================
// HELPER FUNCTION
// =============================================================================
export function generateDungeon(
seed: number,
decalSeed: number,
width: number,
height: number,
config: Partial<DungeonGeneratorConfig> = {}
): DungeonGenerationResult {
const generator = new DungeonMapGenerator(seed, decalSeed, width, height, config);
return generator.generate();
}