diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index 027e52e..085131c 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`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze` | `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`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts` | +| Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze`, `generateKruskalMaze` | `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`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.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 3fd95be..742affc 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`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze` | `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`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts` | +| Procedural generation | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze`, `generateKruskalMaze` | `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`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.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/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.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/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.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/docs/index.d.ts b/docs/index.d.ts index 9d5dc56..cf06e52 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -54,6 +54,7 @@ export const examples: { readonly generateBspDungeon: 'examples/dungeonBsp.ts'; readonly generateRecursiveMaze: 'examples/mazeRecursive.ts'; readonly generatePrimMaze: 'examples/mazePrim.ts'; + readonly generateKruskalMaze: 'examples/mazeKruskal.ts'; }; readonly spatial: { readonly Quadtree: 'examples/sat.ts'; @@ -619,6 +620,14 @@ export function generateRecursiveMaze(options: MazeOptions): MazeResult; */ export function generatePrimMaze(options: MazeOptions): MazeResult; +/** + * Generates a maze using Kruskal's algorithm with disjoint sets. + * Use for: evenly distributed corridors with minimal bias. + * Performance: O(width × height log cells). + * Import: procedural/maze.ts + */ +export function generateKruskalMaze(options: MazeOptions): MazeResult; + /** * Simplex noise generator for smooth gradients without directional artifacts. * Use for: large terrain synthesis, animated textures, volumetric noise. diff --git a/examples/mazeKruskal.ts b/examples/mazeKruskal.ts new file mode 100644 index 0000000..af680af --- /dev/null +++ b/examples/mazeKruskal.ts @@ -0,0 +1,11 @@ +import { generateKruskalMaze } from '../src/index.js'; + +const { grid, start, end } = generateKruskalMaze({ + width: 21, + height: 21, + seed: 777, +}); + +console.log('Start:', start); +console.log('End:', end); +console.log('Sample row:', grid[10]?.join('')); diff --git a/src/index.ts b/src/index.ts index 918d586..0a1231d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,7 @@ export const examples = { generateBspDungeon: 'examples/dungeonBsp.ts', generateRecursiveMaze: 'examples/mazeRecursive.ts', generatePrimMaze: 'examples/mazePrim.ts', + generateKruskalMaze: 'examples/mazeKruskal.ts', }, spatial: { Quadtree: 'examples/sat.ts', @@ -290,6 +291,13 @@ export { generateRecursiveMaze } from './procedural/maze.js'; */ export { generatePrimMaze } from './procedural/maze.js'; +/** + * Kruskal's maze generator for evenly distributed layouts. + * + * Example file: examples/mazeKruskal.ts + */ +export { generateKruskalMaze } from './procedural/maze.js'; + // ============================================================================ // 🎯 SPATIAL & COLLISION // ============================================================================ diff --git a/src/procedural/maze.ts b/src/procedural/maze.ts index d3965f8..c9e133c 100644 --- a/src/procedural/maze.ts +++ b/src/procedural/maze.ts @@ -44,11 +44,7 @@ export function generateRecursiveMaze({ const next = neighbours.find((candidate) => isWall(candidate, grid)); if (next) { - const mid = { - x: current.x + (next.x - current.x) / 2, - y: current.y + (next.y - current.y) / 2, - }; - carveCell(grid, mid); + carveCorridorBetween(grid, current, next); carveCell(grid, next); stack.push(next); } else { @@ -65,7 +61,7 @@ export function generateRecursiveMaze({ /** * Generates a maze using randomized Prim's algorithm. - * Useful for: maze layouts with different structural characteristics than DFS. + * Useful for: mazes with branching corridors differing from DFS results. */ export function generatePrimMaze({ width, @@ -99,6 +95,7 @@ export function generatePrimMaze({ if (!neighbour) { continue; } + carveCorridorBetween(grid, cell, neighbour); carveCell(grid, cell); addFrontierCells(cell, grid, frontier); @@ -110,6 +107,70 @@ export function generatePrimMaze({ return { grid, start, end }; } +/** + * Generates a maze using Kruskal's algorithm with a disjoint-set structure. + * Useful for: evenly distributed mazes with minimal bias. + */ +export function generateKruskalMaze({ + width, + height, + seed = Date.now(), +}: MazeOptions): MazeResult { + validateDimensions(width, height); + + const grid = Array.from({ length: height }, () => Array(width).fill(WALL)); + const random = createLinearCongruentialGenerator(seed); + + const cells: Cell[] = []; + const indexMap = new Map(); + let idx = 0; + for (let y = 1; y < height; y += 2) { + for (let x = 1; x < width; x += 2) { + cells.push({ x, y }); + indexMap.set(cellKey(x, y), idx); + idx += 1; + carveCell(grid, { x, y }); + } + } + + const dsu = createDisjointSet(cells.length); + const edges: Array<{ a: Cell; b: Cell }> = []; + + for (const cell of cells) { + const neighbours: Array<[number, number]> = [ + [2, 0], + [0, 2], + ]; + for (const [dx, dy] of neighbours) { + const nx = cell.x + dx; + const ny = cell.y + dy; + if (nx < width && ny < height) { + edges.push({ a: cell, b: { x: nx, y: ny } }); + } + } + } + + shuffle(edges, random); + + for (const edge of edges) { + const aIndex = indexMap.get(cellKey(edge.a.x, edge.a.y)); + const bIndex = indexMap.get(cellKey(edge.b.x, edge.b.y)); + if (aIndex === undefined || bIndex === undefined) { + continue; + } + if (findSet(dsu, aIndex) !== findSet(dsu, bIndex)) { + unionSet(dsu, aIndex, bIndex); + carveCorridorBetween(grid, edge.a, edge.b); + } + } + + const start: Cell = { x: 1, y: 1 }; + const end = findFarthestCell(start, grid); + carveCell(grid, end); + + return { grid, start, end }; +} + function validateDimensions(width: number, height: number): void { if (!Number.isInteger(width) || !Number.isInteger(height)) { throw new Error('width and height must be integers.'); @@ -204,3 +265,47 @@ function findFarthestCell(start: Cell, grid: number[][]): Cell { } return farthest; } + +function cellKey(x: number, y: number): string { + return `${x}:${y}`; +} + +function shuffle(items: T[], random: () => number): void { + for (let i = items.length - 1; i > 0; i -= 1) { + const j = Math.floor(random() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } +} + +function createDisjointSet(size: number): Int32Array { + const parent = new Int32Array(size); + for (let i = 0; i < size; i += 1) { + parent[i] = -1; + } + return parent; +} + +function findSet(parent: Int32Array, index: number): number { + if (parent[index] < 0) { + return index; + } + parent[index] = findSet(parent, parent[index]); + return parent[index]; +} + +function unionSet(parent: Int32Array, a: number, b: number): void { + const rootA = findSet(parent, a); + const rootB = findSet(parent, b); + if (rootA === rootB) { + return; + } + const sizeA = -parent[rootA]; + const sizeB = -parent[rootB]; + if (sizeA >= sizeB) { + parent[rootA] -= sizeB; + parent[rootB] = rootA; + } else { + parent[rootB] -= sizeA; + parent[rootA] = rootB; + } +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 1cacc9d..8b1972b 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -16,6 +16,7 @@ describe('package entry point', () => { expect(examples.procedural.generateBspDungeon).toBe('examples/dungeonBsp.ts'); expect(examples.procedural.generateRecursiveMaze).toBe('examples/mazeRecursive.ts'); expect(examples.procedural.generatePrimMaze).toBe('examples/mazePrim.ts'); + expect(examples.procedural.generateKruskalMaze).toBe('examples/mazeKruskal.ts'); expect(examples.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); }); @@ -62,6 +63,7 @@ describe('package entry point', () => { | 'generateBspDungeon' | 'generateRecursiveMaze' | 'generatePrimMaze' + | 'generateKruskalMaze' >(); }); }); diff --git a/tests/mazeKruskal.test.ts b/tests/mazeKruskal.test.ts new file mode 100644 index 0000000..1531e56 --- /dev/null +++ b/tests/mazeKruskal.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { generateKruskalMaze } from '../src/index.js'; + +describe('generateKruskalMaze', () => { + it('is deterministic for identical seeds', () => { + const options = { width: 21, height: 21, seed: 404 } as const; + const a = generateKruskalMaze(options); + const b = generateKruskalMaze(options); + + expect(a.grid).toEqual(b.grid); + expect(a.start).toEqual(b.start); + expect(a.end).toEqual(b.end); + }); + + it('produces a maze fully enclosed by walls', () => { + const { grid } = generateKruskalMaze({ width: 17, height: 17, seed: 99 }); + expect(grid[0].every((cell) => cell === 1)).toBe(true); + expect(grid[grid.length - 1].every((cell) => cell === 1)).toBe(true); + expect(grid.every((row) => row[0] === 1 && row[row.length - 1] === 1)).toBe(true); + const walkable = grid.flat().filter((cell) => cell === 0).length; + expect(walkable).toBeGreaterThan(0); + }); +});