UNPKG

shellquest

Version:

Terminal-based procedurally generated dungeon crawler

1,352 lines (1,146 loc) 41.4 kB
/** * 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(); }