From 1ae751ead95da609965f89c7b1cb5695c65e54c2 Mon Sep 17 00:00:00 2001 From: Francois Date: Tue, 7 Oct 2025 19:26:05 +0900 Subject: [PATCH] feat: add recursive maze generator --- PROJECT_DESCRIPTION.md | 2 +- README.md | 4 +- docs/index.d.ts | 31 +++++++++ examples/mazeRecursive.ts | 11 ++++ src/index.ts | 8 +++ src/procedural/maze.ts | 126 ++++++++++++++++++++++++++++++++++++ tests/mazeRecursive.test.ts | 28 ++++++++ 7 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 examples/mazeRecursive.ts create mode 100644 src/procedural/maze.ts create mode 100644 tests/mazeRecursive.test.ts diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index 803d06e..63a79e3 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` | `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` | +| Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze` | `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` | | 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 58fbf44..affaafc 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` | `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` | +| Procedural generation | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze` | `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` | | 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/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/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 9400e55..e3532c2 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -52,6 +52,7 @@ export const examples: { readonly diamondSquare: 'examples/diamondSquare.ts'; readonly generateLSystem: 'examples/lSystem.ts'; readonly generateBspDungeon: 'examples/dungeonBsp.ts'; + readonly generateRecursiveMaze: 'examples/mazeRecursive.ts'; }; readonly spatial: { readonly Quadtree: 'examples/sat.ts'; @@ -579,6 +580,36 @@ export interface DungeonBspResult { */ export function generateBspDungeon(options: DungeonGeneratorOptions): DungeonBspResult; +/** + * Maze generation options for recursive backtracking. + * Use for: controlling grid dimensions and deterministic seeding. + * Import: procedural/maze.ts + */ +export interface MazeOptions { + width: number; + height: number; + seed?: number; +} + +/** + * Maze generation result describing walkable grid and terminals. + * Use for: pathfinding tests, gameplay layout, puzzle generation. + * Import: procedural/maze.ts + */ +export interface MazeResult { + grid: number[][]; + start: Point; + end: Point; +} + +/** + * Generates a maze using recursive backtracking depth-first search. + * Use for: grid-based dungeon layouts, puzzles, procedural environments. + * Performance: O(width × height). + * Import: procedural/maze.ts + */ +export function generateRecursiveMaze(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/mazeRecursive.ts b/examples/mazeRecursive.ts new file mode 100644 index 0000000..d8f9228 --- /dev/null +++ b/examples/mazeRecursive.ts @@ -0,0 +1,11 @@ +import { generateRecursiveMaze } from '../src/index.js'; + +const { grid, start, end } = generateRecursiveMaze({ + width: 21, + height: 21, + seed: 314, +}); + +console.log('Start:', start); +console.log('End:', end); +console.log('Row preview:', grid[10]?.join('')); diff --git a/src/index.ts b/src/index.ts index 7a7519f..282eb7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,7 @@ export const examples = { diamondSquare: 'examples/diamondSquare.ts', generateLSystem: 'examples/lSystem.ts', generateBspDungeon: 'examples/dungeonBsp.ts', + generateRecursiveMaze: 'examples/mazeRecursive.ts', }, spatial: { Quadtree: 'examples/sat.ts', @@ -274,6 +275,13 @@ export { generateLSystem } from './procedural/lSystem.js'; */ export { generateBspDungeon } from './procedural/dungeonBsp.js'; +/** + * Recursive backtracking maze generator for grid layouts. + * + * Example file: examples/mazeRecursive.ts + */ +export { generateRecursiveMaze } from './procedural/maze.js'; + // ============================================================================ // 🎯 SPATIAL & COLLISION // ============================================================================ diff --git a/src/procedural/maze.ts b/src/procedural/maze.ts new file mode 100644 index 0000000..5ccc07f --- /dev/null +++ b/src/procedural/maze.ts @@ -0,0 +1,126 @@ +import { createLinearCongruentialGenerator } from '../util/prng.js'; +import type { Point } from '../types.js'; + +export interface MazeOptions { + width: number; + height: number; + seed?: number; +} + +export interface MazeResult { + grid: number[][]; + start: Point; + end: Point; +} + +interface Cell extends Point {} + +const WALL = 1; +const PATH = 0; + +/** + * Generates a maze using recursive backtracking (depth-first search). + * Useful for: grid-based level layouts, puzzle generation, pathfinding tests. + * Performance: O(width × height). + */ +export function generateRecursiveMaze({ + 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 start: Cell = { x: 1, y: 1 }; + carveCell(grid, start); + + const stack: Cell[] = [start]; + + while (stack.length > 0) { + const current = stack[stack.length - 1]; + const neighbours = shuffledNeighbours(current, grid, random); + + 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); + carveCell(grid, next); + stack.push(next); + } else { + stack.pop(); + } + } + + const end = findFarthestCell(start, grid); + carveCell(grid, start); + 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.'); + } + if (width < 5 || height < 5) { + throw new Error('width and height must be at least 5.'); + } + if (width % 2 === 0 || height % 2 === 0) { + throw new Error('width and height should be odd to surround cells with walls.'); + } +} + +function shuffledNeighbours(cell: Cell, grid: number[][], random: () => number): Cell[] { + const offsets: Array<[number, number]> = [ + [0, -2], + [2, 0], + [0, 2], + [-2, 0], + ]; + for (let i = offsets.length - 1; i > 0; i -= 1) { + const j = Math.floor(random() * (i + 1)); + [offsets[i], offsets[j]] = [offsets[j], offsets[i]]; + } + + const neighbours: Cell[] = []; + for (const [dx, dy] of offsets) { + const nx = cell.x + dx; + const ny = cell.y + dy; + if (ny > 0 && ny < grid.length && nx > 0 && nx < grid[0]!.length) { + neighbours.push({ x: nx, y: ny }); + } + } + return neighbours; +} + +function isWall(cell: Cell, grid: number[][]): boolean { + return grid[cell.y]?.[cell.x] === WALL; +} + +function carveCell(grid: number[][], cell: Cell): void { + if (grid[cell.y] && grid[cell.y][cell.x] !== undefined) { + grid[cell.y][cell.x] = PATH; + } +} + +function findFarthestCell(start: Cell, grid: number[][]): Cell { + let farthest = start; + let maxDistance = -1; + for (let y = 1; y < grid.length; y += 2) { + for (let x = 1; x < grid[0]!.length; x += 2) { + if (grid[y][x] === PATH) { + const distance = Math.abs(start.x - x) + Math.abs(start.y - y); + if (distance > maxDistance) { + maxDistance = distance; + farthest = { x, y }; + } + } + } + } + return farthest; +} diff --git a/tests/mazeRecursive.test.ts b/tests/mazeRecursive.test.ts new file mode 100644 index 0000000..39ace85 --- /dev/null +++ b/tests/mazeRecursive.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { generateRecursiveMaze } from '../src/index.js'; + +describe('generateRecursiveMaze', () => { + it('produces deterministic mazes for equal seeds', () => { + const options = { width: 21, height: 21, seed: 123 } as const; + const a = generateRecursiveMaze(options); + const b = generateRecursiveMaze(options); + expect(a.grid).toEqual(b.grid); + expect(a.start).toEqual(b.start); + expect(a.end).toEqual(b.end); + }); + + it('creates a traversable grid bounded by walls', () => { + const { grid } = generateRecursiveMaze({ width: 15, height: 15, seed: 7 }); + expect(grid.length).toBe(15); + expect(grid.every((row) => row.length === 15)).toBe(true); + + // Outer border should remain walls + 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 walkableTiles = grid.flat().filter((cell) => cell === 0).length; + expect(walkableTiles).toBeGreaterThan(0); + }); +});