Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions PROJECT_DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions docs/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down
14 changes: 14 additions & 0 deletions examples/flowField.ts
Original file line number Diff line number Diff line change
@@ -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'));
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
137 changes: 137 additions & 0 deletions src/pathfinding/flowField.ts
Original file line number Diff line number Diff line change
@@ -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<Point> = [
{ x: 1, y: 0 },
{ x: -1, y: 0 },
{ x: 0, y: 1 },
{ x: 0, y: -1 },
];

const DIAGONAL_NEIGHBORS: ReadonlyArray<Point> = [
{ 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<number>(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);
}
36 changes: 36 additions & 0 deletions tests/flowField.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});