diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index 987bf11..803d06e 100644 --- a/PROJECT_DESCRIPTION.md +++ b/PROJECT_DESCRIPTION.md @@ -36,7 +36,7 @@ npm run build | Need | Algorithm(s) | Module | Example | | ---- | ------------ | ------ | ------- | | Grid pathfinding | `astar`, `dijkstra`, `jumpPointSearch`, `computeFlowField`, `buildNavMesh`, `findNavMeshPath`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts`, `pathfinding/flowField.ts`, `pathfinding/navMesh.ts` | `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts` | -| Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts` | +| Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts` | | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | Web performance & UI throttling | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts` | | Text & search | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | diff --git a/README.md b/README.md index 6c0fcd0..58fbf44 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ CDN usage: | Goal | Algorithms | Import From | Example | | ---- | ---------- | ----------- | ------- | | Pathfinding & navigation | `astar`, `dijkstra`, `jumpPointSearch`, `computeFlowField`, `buildNavMesh`, `findNavMeshPath`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts`, `pathfinding/flowField.ts`, `pathfinding/navMesh.ts` | `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts` | -| Procedural generation | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts` | +| Procedural generation | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts` | | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | AI behaviours & crowds | `seek`, `flee`, `arrive`, `pursue`, `wander`, `updateBoids`, `BehaviorTree`, `rvoStep` | `ai/steering.ts`, `ai/boids.ts`, `ai/behaviorTree.ts`, `ai/rvo.ts` | `examples/steering.ts`, `examples/boids.ts`, `examples/rvo.ts` | | Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts` | @@ -52,7 +52,7 @@ npm run size # Enforce bundle size budget - Milestone 0.2 next targets crowd-flow integrations (RVO + flow fields) and behaviour-tree decorators for richer AI control. - Milestone 0.4 plans a procedural + gameplay systems toolkit (Wave Function Collapse, dungeon suite, L-systems, game loop, camera, particles, inventory, combat, save/load, and more). -Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. +Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. ## Contributing 1. Fork the repository. diff --git a/ROADMAP.md b/ROADMAP.md index de3b1a9..40ab4a0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,7 +41,7 @@ - [x] Voronoi diagram helpers for biome/territory generation - [x] Diamond-square terrain height map generator - [x] L-system generator for foliage and organic structures - - [ ] Dungeon generation suite (BSP subdivision, rooms & corridors variants) + - [x] Dungeon generation suite (BSP subdivision, rooms & corridors variants) - [ ] Maze algorithms pack (Recursive backtracking, Prim's, Kruskal's, Wilson's, Aldous–Broder, Recursive Division) - Gameplay systems & utilities: - [ ] Fixed-timestep game loop utility with interpolation helpers diff --git a/docs/index.d.ts b/docs/index.d.ts index cc23548..9400e55 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -51,6 +51,7 @@ export const examples: { readonly computeVoronoiDiagram: 'examples/voronoi.ts'; readonly diamondSquare: 'examples/diamondSquare.ts'; readonly generateLSystem: 'examples/lSystem.ts'; + readonly generateBspDungeon: 'examples/dungeonBsp.ts'; }; readonly spatial: { readonly Quadtree: 'examples/sat.ts'; @@ -526,6 +527,58 @@ export interface LSystemResult { */ export function generateLSystem(options: LSystemOptions): LSystemResult; +/** + * BSP dungeon generation options. + * Use for: configuring room sizes, recursion depth, deterministic seeds. + * Import: procedural/dungeonBsp.ts + */ +export interface DungeonGeneratorOptions { + width: number; + height: number; + minimumRoomSize?: number; + maximumRoomSize?: number; + maxDepth?: number; + corridorWidth?: number; + seed?: number; +} + +/** + * BSP dungeon room description. + * Use for: placing furniture, connecting gameplay triggers. + * Import: procedural/dungeonBsp.ts + */ +export interface DungeonRoom extends Rect { + id: number; + center: Point; +} + +/** + * Corridor carved between rooms in a BSP dungeon. + * Import: procedural/dungeonBsp.ts + */ +export interface DungeonCorridor { + path: Point[]; +} + +/** + * Result returned by the BSP dungeon generator. + * Use for: rendering tiles, analysing connectivity, gameplay logic. + * Import: procedural/dungeonBsp.ts + */ +export interface DungeonBspResult { + grid: number[][]; + rooms: DungeonRoom[]; + corridors: DungeonCorridor[]; +} + +/** + * Generates a room-and-corridor dungeon using binary space partitioning. + * Use for: roguelike maps, procedural dungeons, level blocking. + * Performance: O(width × height) carving plus O(nodes) splitting. + * Import: procedural/dungeonBsp.ts + */ +export function generateBspDungeon(options: DungeonGeneratorOptions): DungeonBspResult; + /** * Simplex noise generator for smooth gradients without directional artifacts. * Use for: large terrain synthesis, animated textures, volumetric noise. diff --git a/examples/dungeonBsp.ts b/examples/dungeonBsp.ts new file mode 100644 index 0000000..1ce9417 --- /dev/null +++ b/examples/dungeonBsp.ts @@ -0,0 +1,14 @@ +import { generateBspDungeon } from '../src/index.js'; + +const dungeon = generateBspDungeon({ + width: 40, + height: 24, + seed: 2024, + minimumRoomSize: 4, + maximumRoomSize: 8, + maxDepth: 4, +}); + +console.log('Rooms:', dungeon.rooms.length); +console.log('Corridors:', dungeon.corridors.length); +console.log('Sample grid row:', dungeon.grid[12]?.join('')); diff --git a/src/index.ts b/src/index.ts index b5526dc..7a7519f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ export const examples = { computeVoronoiDiagram: 'examples/voronoi.ts', diamondSquare: 'examples/diamondSquare.ts', generateLSystem: 'examples/lSystem.ts', + generateBspDungeon: 'examples/dungeonBsp.ts', }, spatial: { Quadtree: 'examples/sat.ts', @@ -266,6 +267,13 @@ export { diamondSquare } from './procedural/diamondSquare.js'; */ export { generateLSystem } from './procedural/lSystem.js'; +/** + * Binary space partition dungeon generator. + * + * Example file: examples/dungeonBsp.ts + */ +export { generateBspDungeon } from './procedural/dungeonBsp.js'; + // ============================================================================ // 🎯 SPATIAL & COLLISION // ============================================================================ diff --git a/src/procedural/dungeonBsp.ts b/src/procedural/dungeonBsp.ts new file mode 100644 index 0000000..c1139ee --- /dev/null +++ b/src/procedural/dungeonBsp.ts @@ -0,0 +1,254 @@ +import { createLinearCongruentialGenerator } from '../util/prng.js'; +import type { Point, Rect } from '../types.js'; + +export interface DungeonGeneratorOptions { + width: number; + height: number; + minimumRoomSize?: number; + maximumRoomSize?: number; + maxDepth?: number; + corridorWidth?: number; + seed?: number; +} + +export interface DungeonRoom extends Rect { + id: number; + center: Point; +} + +export interface DungeonCorridor { + path: Point[]; +} + +export interface DungeonBspResult { + grid: number[][]; + rooms: DungeonRoom[]; + corridors: DungeonCorridor[]; +} + +interface Node { + bounds: Rect; + depth: number; + left?: Node; + right?: Node; + room?: DungeonRoom; +} + +const DEFAULT_MIN_ROOM = 4; +const DEFAULT_MAX_ROOM = 10; +const DEFAULT_MAX_DEPTH = 5; + +/** + * Generates a dungeon layout using binary space partitioning (BSP). + * Useful for: roguelike level layouts, room-and-corridor maps. + * Performance: O(width × height) for carving plus O(nodes) splitting. + */ +export function generateBspDungeon({ + width, + height, + minimumRoomSize = DEFAULT_MIN_ROOM, + maximumRoomSize = DEFAULT_MAX_ROOM, + maxDepth = DEFAULT_MAX_DEPTH, + corridorWidth = 1, + seed = Date.now(), +}: DungeonGeneratorOptions): DungeonBspResult { + if (width <= 0 || height <= 0) { + throw new Error('width and height must be positive integers.'); + } + if (minimumRoomSize < 3) { + throw new Error('minimumRoomSize must be >= 3.'); + } + if (maximumRoomSize < minimumRoomSize) { + throw new Error('maximumRoomSize must be >= minimumRoomSize.'); + } + + const random = createLinearCongruentialGenerator(seed); + const root: Node = { + bounds: { x: 0, y: 0, width, height }, + depth: 0, + }; + + splitNode(root, minimumRoomSize, maxDepth, random); + const rooms: DungeonRoom[] = []; + carveRooms(root, random, minimumRoomSize, maximumRoomSize, rooms); + + const corridors: DungeonCorridor[] = []; + connectRooms(root, corridors); + + const grid = Array.from({ length: height }, () => Array(width).fill(1)); + for (const room of rooms) { + carveRectangle(grid, room, 0); + } + for (const corridor of corridors) { + for (const { x, y } of corridor.path) { + for (let dx = -Math.floor(corridorWidth / 2); dx <= Math.floor(corridorWidth / 2); dx += 1) { + for (let dy = -Math.floor(corridorWidth / 2); dy <= Math.floor(corridorWidth / 2); dy += 1) { + const nx = x + dx; + const ny = y + dy; + if (nx >= 0 && nx < width && ny >= 0 && ny < height) { + grid[ny][nx] = 0; + } + } + } + } + } + + return { grid, rooms, corridors }; +} + +function splitNode(node: Node, minRoomSize: number, maxDepth: number, random: () => number): void { + if (node.depth >= maxDepth) { + return; + } + + const { bounds } = node; + const canSplitHorizontally = bounds.height >= minRoomSize * 2; + const canSplitVertically = bounds.width >= minRoomSize * 2; + + if (!canSplitHorizontally && !canSplitVertically) { + return; + } + + let splitHorizontally = false; + if (canSplitHorizontally && canSplitVertically) { + splitHorizontally = random() < 0.5; + } else { + splitHorizontally = canSplitHorizontally; + } + + if (splitHorizontally) { + const split = randomRange(random, minRoomSize, bounds.height - minRoomSize); + node.left = { + bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: split }, + depth: node.depth + 1, + }; + node.right = { + bounds: { + x: bounds.x, + y: bounds.y + split, + width: bounds.width, + height: bounds.height - split, + }, + depth: node.depth + 1, + }; + } else { + const split = randomRange(random, minRoomSize, bounds.width - minRoomSize); + node.left = { + bounds: { x: bounds.x, y: bounds.y, width: split, height: bounds.height }, + depth: node.depth + 1, + }; + node.right = { + bounds: { + x: bounds.x + split, + y: bounds.y, + width: bounds.width - split, + height: bounds.height, + }, + depth: node.depth + 1, + }; + } + + if (node.left) { + splitNode(node.left, minRoomSize, maxDepth, random); + } + if (node.right) { + splitNode(node.right, minRoomSize, maxDepth, random); + } +} + +function carveRooms( + node: Node, + random: () => number, + minRoom: number, + maxRoom: number, + rooms: DungeonRoom[], + nextId: { value: number } = { value: 0 } +): void { + if (!node.left && !node.right) { + const maxRoomWidth = Math.min(node.bounds.width - 2, maxRoom); + const maxRoomHeight = Math.min(node.bounds.height - 2, maxRoom); + const roomWidth = randomRange(random, minRoom, maxRoomWidth); + const roomHeight = randomRange(random, minRoom, maxRoomHeight); + const roomX = node.bounds.x + randomRange(random, 1, node.bounds.width - roomWidth - 1); + const roomY = node.bounds.y + randomRange(random, 1, node.bounds.height - roomHeight - 1); + nextId.value += 1; + const room: DungeonRoom = { + id: nextId.value, + x: roomX, + y: roomY, + width: roomWidth, + height: roomHeight, + center: { x: Math.floor(roomX + roomWidth / 2), y: Math.floor(roomY + roomHeight / 2) }, + }; + node.room = room; + rooms.push(room); + return; + } + + if (node.left) { + carveRooms(node.left, random, minRoom, maxRoom, rooms, nextId); + } + if (node.right) { + carveRooms(node.right, random, minRoom, maxRoom, rooms, nextId); + } +} + +function connectRooms(node: Node, corridors: DungeonCorridor[]): void { + if (!node.left || !node.right) { + return; + } + + const leftRoom = findRoom(node.left); + const rightRoom = findRoom(node.right); + if (leftRoom && rightRoom) { + const path = carveCorridor(leftRoom.center, rightRoom.center); + corridors.push({ path }); + } + + connectRooms(node.left, corridors); + connectRooms(node.right, corridors); +} + +function findRoom(node: Node): DungeonRoom | null { + if (node.room) { + return node.room; + } + const left = node.left ? findRoom(node.left) : null; + if (left) { + return left; + } + return node.right ? findRoom(node.right) : null; +} + +function carveCorridor(from: Point, to: Point): Point[] { + const path: Point[] = []; + let x = from.x; + let y = from.y; + while (x !== to.x) { + path.push({ x, y }); + x += x < to.x ? 1 : -1; + } + while (y !== to.y) { + path.push({ x, y }); + y += y < to.y ? 1 : -1; + } + path.push({ x: to.x, y: to.y }); + return path; +} + +function carveRectangle(grid: number[][], rect: Rect, value: number): void { + for (let y = rect.y; y < rect.y + rect.height; y += 1) { + for (let x = rect.x; x < rect.x + rect.width; x += 1) { + if (grid[y] && grid[y][x] !== undefined) { + grid[y][x] = value; + } + } + } +} + +function randomRange(random: () => number, min: number, max: number): number { + if (max <= min) { + return min; + } + return Math.floor(random() * (max - min + 1)) + min; +} diff --git a/tests/dungeonBsp.test.ts b/tests/dungeonBsp.test.ts new file mode 100644 index 0000000..4d4b05e --- /dev/null +++ b/tests/dungeonBsp.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { generateBspDungeon } from '../src/index.js'; + +describe('generateBspDungeon', () => { + it('produces deterministic layouts for identical seeds', () => { + const options = { + width: 40, + height: 24, + minimumRoomSize: 4, + maximumRoomSize: 8, + maxDepth: 4, + seed: 111, + } as const; + + const a = generateBspDungeon(options); + const b = generateBspDungeon(options); + + expect(a.rooms).toEqual(b.rooms); + expect(a.corridors).toEqual(b.corridors); + expect(a.grid).toEqual(b.grid); + }); + + it('creates walkable tiles and rooms within bounds', () => { + const dungeon = generateBspDungeon({ + width: 32, + height: 20, + seed: 7, + }); + + const floorTiles = dungeon.grid.flat().filter((cell) => cell === 0).length; + expect(floorTiles).toBeGreaterThan(0); + + for (const room of dungeon.rooms) { + expect(room.x).toBeGreaterThanOrEqual(0); + expect(room.y).toBeGreaterThanOrEqual(0); + expect(room.x + room.width).toBeLessThanOrEqual(32); + expect(room.y + room.height).toBeLessThanOrEqual(20); + } + + expect(dungeon.corridors.every((corridor) => corridor.path.length > 0)).toBe(true); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 03db706..2699f9a 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -13,6 +13,7 @@ describe('package entry point', () => { expect(examples.procedural.computeVoronoiDiagram).toBe('examples/voronoi.ts'); expect(examples.procedural.diamondSquare).toBe('examples/diamondSquare.ts'); expect(examples.procedural.generateLSystem).toBe('examples/lSystem.ts'); + expect(examples.procedural.generateBspDungeon).toBe('examples/dungeonBsp.ts'); expect(examples.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); }); @@ -56,6 +57,7 @@ describe('package entry point', () => { | 'computeVoronoiDiagram' | 'diamondSquare' | 'generateLSystem' + | 'generateBspDungeon' >(); }); });