From c8b594f2a625fcab3e5fb8ed906afcc1b698e57d Mon Sep 17 00:00:00 2001 From: Francois Date: Tue, 7 Oct 2025 16:05:09 +0900 Subject: [PATCH] feat: add flow field pathfinding --- PROJECT_DESCRIPTION.md | 4 +- README.md | 2 +- ROADMAP.md | 2 +- docs/index.d.ts | 17 +++++ examples/flowField.ts | 14 ++++ src/index.ts | 1 + src/pathfinding/flowField.ts | 137 +++++++++++++++++++++++++++++++++++ tests/flowField.test.ts | 36 +++++++++ 8 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 examples/flowField.ts create mode 100644 src/pathfinding/flowField.ts create mode 100644 tests/flowField.test.ts diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index dff8052..b39e108 100644 --- a/PROJECT_DESCRIPTION.md +++ b/PROJECT_DESCRIPTION.md @@ -34,7 +34,7 @@ npm run build | Need | Algorithm(s) | Module | Example | | ---- | ------------ | ------ | ------- | -| Grid pathfinding | `astar`, `dijkstra`, `jumpPointSearch`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts` | `examples/astar.ts` | +| Grid pathfinding | `astar`, `dijkstra`, `jumpPointSearch`, `computeFlowField`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts`, `pathfinding/flowField.ts` | `examples/astar.ts` | | Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.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` | @@ -87,7 +87,7 @@ Consistency between runtime code, documentation, and TypeScript declarations kee ## ✅ Included Implementations (v0.1.0) -- **Pathfinding:** A*, Dijkstra, Jump Point Search, Manhattan heuristic, grid string parser. +- **Pathfinding:** A*, Dijkstra, Jump Point Search, flow field integration, Manhattan heuristic, grid string parser. - **Procedural:** 2D/3D Perlin, Worley noise, Wave Function Collapse tile synthesis. - **Spatial:** Quadtree, AABB helpers, SAT convex polygon collision. - **Performance utilities:** Debounce, throttle, LRU cache, memoize, request deduplication, virtual scrolling. diff --git a/README.md b/README.md index b36d122..8bdef3b 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ CDN usage: | Goal | Algorithms | Import From | Example | | ---- | ---------- | ----------- | ------- | -| Pathfinding & navigation | `astar`, `dijkstra`, `jumpPointSearch`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts` | `examples/astar.ts` | +| Pathfinding & navigation | `astar`, `dijkstra`, `jumpPointSearch`, `computeFlowField`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts`, `pathfinding/flowField.ts` | `examples/astar.ts` | | Procedural generation | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.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` | diff --git a/ROADMAP.md b/ROADMAP.md index fa78f20..cecc110 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,7 @@ - [x] Achieve >80% coverage across new modules - [ ] Implement reciprocal velocity obstacles (RVO) crowd steering with tests and example - [x] Add Jump Point Search optimisation for uniform grids -- [ ] Implement flow-field pathfinding for multi-unit navigation +- [x] Implement flow-field pathfinding for multi-unit navigation - [ ] Provide navigation mesh (navmesh) helper for irregular terrain ## Milestone 0.3.0 – Web Performance & Data Pipelines diff --git a/docs/index.d.ts b/docs/index.d.ts index cddd92c..0e8dbe0 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -70,6 +70,23 @@ export interface JumpPointSearchOptions { } export function jumpPointSearch(options: JumpPointSearchOptions): Point[] | null; +/** + * Flow field builder pointing multiple agents toward a goal. + * Use for: RTS flow maps, crowd steering, dynamic navigation hints. + * Performance: O(width × height) with uniform costs. + * Import: pathfinding/flowField.ts + */ +export interface FlowFieldOptions { + grid: number[][]; + goal: Point; + allowDiagonal?: boolean; +} +export interface FlowFieldResult { + cost: number[][]; + flow: Vector2D[][]; +} +export function computeFlowField(options: FlowFieldOptions): FlowFieldResult; + // ============================================================================ // 🌍 PROCEDURAL GENERATION // ============================================================================ diff --git a/examples/flowField.ts b/examples/flowField.ts new file mode 100644 index 0000000..5862b47 --- /dev/null +++ b/examples/flowField.ts @@ -0,0 +1,14 @@ +import { computeFlowField } from '../src/index.js'; + +const grid = [ + [0, 0, 0, 0], + [0, 1, 1, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], +]; + +const { flow, cost } = computeFlowField({ grid, goal: { x: 3, y: 3 } }); +console.log('Integration cost map:'); +console.log(cost.map((row) => row.map((value) => value.toFixed(1)).join(' ')).join('\n')); +console.log('\nFlow vectors:'); +console.log(flow.map((row) => row.map(({ x, y }) => `(${x.toFixed(2)},${y.toFixed(2)})`).join(' ')).join('\n')); diff --git a/src/index.ts b/src/index.ts index 4ff4a9f..41e0f9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export { astar, manhattanDistance, gridFromString } from './pathfinding/astar.js'; export { dijkstra } from './pathfinding/dijkstra.js'; export { jumpPointSearch } from './pathfinding/jumpPointSearch.js'; +export { computeFlowField } from './pathfinding/flowField.js'; export { perlin, perlin3D } from './procedural/perlin.js'; export { worley, worleySample } from './procedural/worley.js'; diff --git a/src/pathfinding/flowField.ts b/src/pathfinding/flowField.ts new file mode 100644 index 0000000..40cc9e9 --- /dev/null +++ b/src/pathfinding/flowField.ts @@ -0,0 +1,137 @@ +import type { Point, Vector2D } from '../types.js'; + +export interface FlowFieldOptions { + grid: number[][]; + goal: Point; + allowDiagonal?: boolean; +} + +export interface FlowFieldResult { + cost: number[][]; + flow: Vector2D[][]; +} + +const ORTHOGONAL_NEIGHBORS: ReadonlyArray = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 }, +]; + +const DIAGONAL_NEIGHBORS: ReadonlyArray = [ + { x: 1, y: 1 }, + { x: -1, y: 1 }, + { x: 1, y: -1 }, + { x: -1, y: -1 }, +]; + +/** + * Builds a flow field pointing toward the goal cell using uniform-cost integration. + * Useful for: multi-unit steering, crowd navigation, RTS flow maps. + */ +export function computeFlowField(options: FlowFieldOptions): FlowFieldResult { + const { grid, goal, allowDiagonal = true } = options; + validateGrid(grid, goal); + + const height = grid.length; + const width = grid[0]?.length ?? 0; + const cost: number[][] = Array.from({ length: height }, () => Array(width).fill(Number.POSITIVE_INFINITY)); + const flow: Vector2D[][] = Array.from({ length: height }, () => + Array.from({ length: width }, () => ({ x: 0, y: 0 } as Vector2D)) + ); + + const queue: Array<{ point: Point; priority: number }> = []; + if (isWalkable(grid, goal.x, goal.y)) { + cost[goal.y][goal.x] = 0; + queue.push({ point: goal, priority: 0 }); + } + + while (queue.length > 0) { + queue.sort((a, b) => a.priority - b.priority); + const current = queue.shift()!; + const currentCost = cost[current.point.y][current.point.x]; + + const neighbors = allowDiagonal + ? [...ORTHOGONAL_NEIGHBORS, ...DIAGONAL_NEIGHBORS] + : ORTHOGONAL_NEIGHBORS; + + for (const dir of neighbors) { + const nx = current.point.x + dir.x; + const ny = current.point.y + dir.y; + if (!isWalkable(grid, nx, ny)) { + continue; + } + const stepCost = dir.x !== 0 && dir.y !== 0 ? Math.SQRT2 : 1; + const tentative = currentCost + stepCost; + if (tentative < cost[ny][nx]) { + cost[ny][nx] = tentative; + queue.push({ point: { x: nx, y: ny }, priority: tentative }); + } + } + } + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if (!isWalkable(grid, x, y) || !Number.isFinite(cost[y][x])) { + flow[y][x] = { x: 0, y: 0 }; + continue; + } + if (x === goal.x && y === goal.y) { + flow[y][x] = { x: 0, y: 0 }; + continue; + } + let bestNeighbor: Point | null = null; + let bestCost = cost[y][x]; + + const neighbors = allowDiagonal + ? [...ORTHOGONAL_NEIGHBORS, ...DIAGONAL_NEIGHBORS] + : ORTHOGONAL_NEIGHBORS; + + for (const dir of neighbors) { + const nx = x + dir.x; + const ny = y + dir.y; + if (nx < 0 || ny < 0 || ny >= height || nx >= width) { + continue; + } + const neighborCost = cost[ny][nx]; + if (neighborCost < bestCost) { + bestCost = neighborCost; + bestNeighbor = { x: nx, y: ny }; + } + } + + if (!bestNeighbor) { + flow[y][x] = { x: 0, y: 0 }; + continue; + } + + const dx = bestNeighbor.x - x; + const dy = bestNeighbor.y - y; + const magnitude = Math.hypot(dx, dy) || 1; + flow[y][x] = { x: dx / magnitude, y: dy / magnitude }; + } + } + + return { cost, flow }; +} + +function isWalkable(grid: number[][], x: number, y: number): boolean { + return grid[y]?.[x] === 0; +} + +function validateGrid(grid: number[][], goal: Point): void { + if (!Array.isArray(grid) || grid.length === 0) { + throw new TypeError('grid must be a non-empty 2D array'); + } + const width = grid[0]?.length; + if (!grid.every((row) => Array.isArray(row) && row.length === width)) { + throw new TypeError('grid rows must be arrays of equal length'); + } + if (!isWithin(grid, goal.x, goal.y)) { + throw new RangeError('goal must be inside the grid bounds'); + } +} + +function isWithin(grid: number[][], x: number, y: number): boolean { + return y >= 0 && y < grid.length && x >= 0 && x < (grid[0]?.length ?? 0); +} diff --git a/tests/flowField.test.ts b/tests/flowField.test.ts new file mode 100644 index 0000000..f54d24d --- /dev/null +++ b/tests/flowField.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { computeFlowField } from '../src/pathfinding/flowField.js'; + +const grid = [ + [0, 0, 0, 0], + [0, 1, 1, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], +]; + +describe('computeFlowField', () => { + it('produces lower cost near the goal', () => { + const { cost } = computeFlowField({ grid, goal: { x: 3, y: 3 } }); + expect(cost[3][3]).toBe(0); + expect(cost[0][0]).toBeGreaterThan(cost[2][2]); + }); + + it('returns normalized flow vectors pointing toward the goal', () => { + const { flow } = computeFlowField({ grid, goal: { x: 3, y: 3 } }); + const startFlow = flow[0][0]; + const vectorToGoal = { x: 3, y: 3 }; + const dot = startFlow.x * vectorToGoal.x + startFlow.y * vectorToGoal.y; + expect(dot).toBeGreaterThan(0); + const magnitude = Math.hypot(startFlow.x, startFlow.y); + expect(Math.abs(magnitude - 1)).toBeLessThan(1e-6); + }); + + it('gives zero vectors to unreachable tiles', () => { + const blocked = [ + [0, 0], + [1, 1], + ]; + const { flow } = computeFlowField({ grid: blocked, goal: { x: 0, y: 0 } }); + expect(flow[1][1]).toEqual({ x: 0, y: 0 }); + }); +});