Skip to content

Commit c8b594f

Browse files
committed
feat: add flow field pathfinding
1 parent 517481a commit c8b594f

8 files changed

Lines changed: 209 additions & 4 deletions

File tree

PROJECT_DESCRIPTION.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ npm run build
3434

3535
| Need | Algorithm(s) | Module | Example |
3636
| ---- | ------------ | ------ | ------- |
37-
| Grid pathfinding | `astar`, `dijkstra`, `jumpPointSearch`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts` | `examples/astar.ts` |
37+
| Grid pathfinding | `astar`, `dijkstra`, `jumpPointSearch`, `computeFlowField`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts`, `pathfinding/flowField.ts` | `examples/astar.ts` |
3838
| Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts` |
3939
| Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` |
4040
| 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
8787

8888
## ✅ Included Implementations (v0.1.0)
8989

90-
- **Pathfinding:** A*, Dijkstra, Jump Point Search, Manhattan heuristic, grid string parser.
90+
- **Pathfinding:** A*, Dijkstra, Jump Point Search, flow field integration, Manhattan heuristic, grid string parser.
9191
- **Procedural:** 2D/3D Perlin, Worley noise, Wave Function Collapse tile synthesis.
9292
- **Spatial:** Quadtree, AABB helpers, SAT convex polygon collision.
9393
- **Performance utilities:** Debounce, throttle, LRU cache, memoize, request deduplication, virtual scrolling.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ CDN usage:
2424

2525
| Goal | Algorithms | Import From | Example |
2626
| ---- | ---------- | ----------- | ------- |
27-
| Pathfinding & navigation | `astar`, `dijkstra`, `jumpPointSearch`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts` | `examples/astar.ts` |
27+
| Pathfinding & navigation | `astar`, `dijkstra`, `jumpPointSearch`, `computeFlowField`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts`, `pathfinding/flowField.ts` | `examples/astar.ts` |
2828
| Procedural generation | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts` |
2929
| Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` |
3030
| 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` |

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
- [x] Achieve >80% coverage across new modules
2121
- [ ] Implement reciprocal velocity obstacles (RVO) crowd steering with tests and example
2222
- [x] Add Jump Point Search optimisation for uniform grids
23-
- [ ] Implement flow-field pathfinding for multi-unit navigation
23+
- [x] Implement flow-field pathfinding for multi-unit navigation
2424
- [ ] Provide navigation mesh (navmesh) helper for irregular terrain
2525

2626
## Milestone 0.3.0 – Web Performance & Data Pipelines

docs/index.d.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,23 @@ export interface JumpPointSearchOptions {
7070
}
7171
export function jumpPointSearch(options: JumpPointSearchOptions): Point[] | null;
7272

73+
/**
74+
* Flow field builder pointing multiple agents toward a goal.
75+
* Use for: RTS flow maps, crowd steering, dynamic navigation hints.
76+
* Performance: O(width × height) with uniform costs.
77+
* Import: pathfinding/flowField.ts
78+
*/
79+
export interface FlowFieldOptions {
80+
grid: number[][];
81+
goal: Point;
82+
allowDiagonal?: boolean;
83+
}
84+
export interface FlowFieldResult {
85+
cost: number[][];
86+
flow: Vector2D[][];
87+
}
88+
export function computeFlowField(options: FlowFieldOptions): FlowFieldResult;
89+
7390
// ============================================================================
7491
// 🌍 PROCEDURAL GENERATION
7592
// ============================================================================

examples/flowField.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { computeFlowField } from '../src/index.js';
2+
3+
const grid = [
4+
[0, 0, 0, 0],
5+
[0, 1, 1, 0],
6+
[0, 0, 0, 0],
7+
[0, 0, 0, 0],
8+
];
9+
10+
const { flow, cost } = computeFlowField({ grid, goal: { x: 3, y: 3 } });
11+
console.log('Integration cost map:');
12+
console.log(cost.map((row) => row.map((value) => value.toFixed(1)).join(' ')).join('\n'));
13+
console.log('\nFlow vectors:');
14+
console.log(flow.map((row) => row.map(({ x, y }) => `(${x.toFixed(2)},${y.toFixed(2)})`).join(' ')).join('\n'));

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { astar, manhattanDistance, gridFromString } from './pathfinding/astar.js';
22
export { dijkstra } from './pathfinding/dijkstra.js';
33
export { jumpPointSearch } from './pathfinding/jumpPointSearch.js';
4+
export { computeFlowField } from './pathfinding/flowField.js';
45

56
export { perlin, perlin3D } from './procedural/perlin.js';
67
export { worley, worleySample } from './procedural/worley.js';

src/pathfinding/flowField.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import type { Point, Vector2D } from '../types.js';
2+
3+
export interface FlowFieldOptions {
4+
grid: number[][];
5+
goal: Point;
6+
allowDiagonal?: boolean;
7+
}
8+
9+
export interface FlowFieldResult {
10+
cost: number[][];
11+
flow: Vector2D[][];
12+
}
13+
14+
const ORTHOGONAL_NEIGHBORS: ReadonlyArray<Point> = [
15+
{ x: 1, y: 0 },
16+
{ x: -1, y: 0 },
17+
{ x: 0, y: 1 },
18+
{ x: 0, y: -1 },
19+
];
20+
21+
const DIAGONAL_NEIGHBORS: ReadonlyArray<Point> = [
22+
{ x: 1, y: 1 },
23+
{ x: -1, y: 1 },
24+
{ x: 1, y: -1 },
25+
{ x: -1, y: -1 },
26+
];
27+
28+
/**
29+
* Builds a flow field pointing toward the goal cell using uniform-cost integration.
30+
* Useful for: multi-unit steering, crowd navigation, RTS flow maps.
31+
*/
32+
export function computeFlowField(options: FlowFieldOptions): FlowFieldResult {
33+
const { grid, goal, allowDiagonal = true } = options;
34+
validateGrid(grid, goal);
35+
36+
const height = grid.length;
37+
const width = grid[0]?.length ?? 0;
38+
const cost: number[][] = Array.from({ length: height }, () => Array<number>(width).fill(Number.POSITIVE_INFINITY));
39+
const flow: Vector2D[][] = Array.from({ length: height }, () =>
40+
Array.from({ length: width }, () => ({ x: 0, y: 0 } as Vector2D))
41+
);
42+
43+
const queue: Array<{ point: Point; priority: number }> = [];
44+
if (isWalkable(grid, goal.x, goal.y)) {
45+
cost[goal.y][goal.x] = 0;
46+
queue.push({ point: goal, priority: 0 });
47+
}
48+
49+
while (queue.length > 0) {
50+
queue.sort((a, b) => a.priority - b.priority);
51+
const current = queue.shift()!;
52+
const currentCost = cost[current.point.y][current.point.x];
53+
54+
const neighbors = allowDiagonal
55+
? [...ORTHOGONAL_NEIGHBORS, ...DIAGONAL_NEIGHBORS]
56+
: ORTHOGONAL_NEIGHBORS;
57+
58+
for (const dir of neighbors) {
59+
const nx = current.point.x + dir.x;
60+
const ny = current.point.y + dir.y;
61+
if (!isWalkable(grid, nx, ny)) {
62+
continue;
63+
}
64+
const stepCost = dir.x !== 0 && dir.y !== 0 ? Math.SQRT2 : 1;
65+
const tentative = currentCost + stepCost;
66+
if (tentative < cost[ny][nx]) {
67+
cost[ny][nx] = tentative;
68+
queue.push({ point: { x: nx, y: ny }, priority: tentative });
69+
}
70+
}
71+
}
72+
73+
for (let y = 0; y < height; y += 1) {
74+
for (let x = 0; x < width; x += 1) {
75+
if (!isWalkable(grid, x, y) || !Number.isFinite(cost[y][x])) {
76+
flow[y][x] = { x: 0, y: 0 };
77+
continue;
78+
}
79+
if (x === goal.x && y === goal.y) {
80+
flow[y][x] = { x: 0, y: 0 };
81+
continue;
82+
}
83+
let bestNeighbor: Point | null = null;
84+
let bestCost = cost[y][x];
85+
86+
const neighbors = allowDiagonal
87+
? [...ORTHOGONAL_NEIGHBORS, ...DIAGONAL_NEIGHBORS]
88+
: ORTHOGONAL_NEIGHBORS;
89+
90+
for (const dir of neighbors) {
91+
const nx = x + dir.x;
92+
const ny = y + dir.y;
93+
if (nx < 0 || ny < 0 || ny >= height || nx >= width) {
94+
continue;
95+
}
96+
const neighborCost = cost[ny][nx];
97+
if (neighborCost < bestCost) {
98+
bestCost = neighborCost;
99+
bestNeighbor = { x: nx, y: ny };
100+
}
101+
}
102+
103+
if (!bestNeighbor) {
104+
flow[y][x] = { x: 0, y: 0 };
105+
continue;
106+
}
107+
108+
const dx = bestNeighbor.x - x;
109+
const dy = bestNeighbor.y - y;
110+
const magnitude = Math.hypot(dx, dy) || 1;
111+
flow[y][x] = { x: dx / magnitude, y: dy / magnitude };
112+
}
113+
}
114+
115+
return { cost, flow };
116+
}
117+
118+
function isWalkable(grid: number[][], x: number, y: number): boolean {
119+
return grid[y]?.[x] === 0;
120+
}
121+
122+
function validateGrid(grid: number[][], goal: Point): void {
123+
if (!Array.isArray(grid) || grid.length === 0) {
124+
throw new TypeError('grid must be a non-empty 2D array');
125+
}
126+
const width = grid[0]?.length;
127+
if (!grid.every((row) => Array.isArray(row) && row.length === width)) {
128+
throw new TypeError('grid rows must be arrays of equal length');
129+
}
130+
if (!isWithin(grid, goal.x, goal.y)) {
131+
throw new RangeError('goal must be inside the grid bounds');
132+
}
133+
}
134+
135+
function isWithin(grid: number[][], x: number, y: number): boolean {
136+
return y >= 0 && y < grid.length && x >= 0 && x < (grid[0]?.length ?? 0);
137+
}

tests/flowField.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { computeFlowField } from '../src/pathfinding/flowField.js';
3+
4+
const grid = [
5+
[0, 0, 0, 0],
6+
[0, 1, 1, 0],
7+
[0, 0, 0, 0],
8+
[0, 0, 0, 0],
9+
];
10+
11+
describe('computeFlowField', () => {
12+
it('produces lower cost near the goal', () => {
13+
const { cost } = computeFlowField({ grid, goal: { x: 3, y: 3 } });
14+
expect(cost[3][3]).toBe(0);
15+
expect(cost[0][0]).toBeGreaterThan(cost[2][2]);
16+
});
17+
18+
it('returns normalized flow vectors pointing toward the goal', () => {
19+
const { flow } = computeFlowField({ grid, goal: { x: 3, y: 3 } });
20+
const startFlow = flow[0][0];
21+
const vectorToGoal = { x: 3, y: 3 };
22+
const dot = startFlow.x * vectorToGoal.x + startFlow.y * vectorToGoal.y;
23+
expect(dot).toBeGreaterThan(0);
24+
const magnitude = Math.hypot(startFlow.x, startFlow.y);
25+
expect(Math.abs(magnitude - 1)).toBeLessThan(1e-6);
26+
});
27+
28+
it('gives zero vectors to unreachable tiles', () => {
29+
const blocked = [
30+
[0, 0],
31+
[1, 1],
32+
];
33+
const { flow } = computeFlowField({ grid: blocked, goal: { x: 0, y: 0 } });
34+
expect(flow[1][1]).toEqual({ x: 0, y: 0 });
35+
});
36+
});

0 commit comments

Comments
 (0)