Skip to content

Commit 786393a

Browse files
committed
feat: add RVO crowd steering
1 parent 535e989 commit 786393a

8 files changed

Lines changed: 323 additions & 3 deletions

File tree

PROJECT_DESCRIPTION.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ npm run build
4242
| Data transforms | Diff (LCS) / Deep clone / Group by | `data/*.ts` |
4343
| Graph traversal | BFS distances / DFS callbacks / Topological sort | `graph/traversal.ts` |
4444
| Geometry & visuals | Convex hull / Segment intersection / Point-in-poly / Bezier / Easings | `geometry/*.ts`, `visual/*.ts` |
45-
| AI behaviours | Seek / Flee / Arrive / Pursue / Wander / Boids / Behaviour trees | `ai/steering.ts`, `ai/boids.ts`, `ai/behaviorTree.ts` |
45+
| AI behaviours | Seek / Flee / Arrive / Pursue / Wander / Boids / Behaviour trees / RVO crowd steering | `ai/steering.ts`, `ai/boids.ts`, `ai/behaviorTree.ts`, `ai/rvo.ts` |
4646

4747
---
4848

@@ -95,7 +95,7 @@ Consistency between runtime code, documentation, and TypeScript declarations kee
9595
- **Data tools:** Diff operations (LCS), deep clone, groupBy.
9696
- **Graph:** BFS distance map, DFS traversal, topological sort.
9797
- **Geometry & visuals:** Convex hull, line intersection, point-in-polygon, easing presets, quadratic/cubic Bezier evaluation.
98-
- **AI behaviours:** Steering behaviours (seek, flee, arrive, pursue, wander).
98+
- **AI behaviours:** Steering behaviours (seek, flee, arrive, pursue, wander), boids, behaviour trees, RVO crowd steering.
9999

100100
---
101101

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ CDN usage:
2929
- **Web performance:** Debounce, throttle, LRU cache, memoize, request deduplication helper, virtual scrolling range calculator
3030
- **Graph:** BFS distance map, DFS traversal, topological sort
3131
- **Visual & Geometry:** Convex hull, line intersection, point-in-polygon, easing presets, Bezier helpers
32-
- **AI Behaviours:** Steering behaviours (seek, flee, arrive, pursue, wander), boids flocking update, behaviour trees
32+
- **AI Behaviours:** Steering behaviours (seek, flee, arrive, pursue, wander), boids flocking update, behaviour trees, reciprocal velocity obstacles (RVO)
3333

3434
## Scripts
3535
```bash

docs/index.d.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,28 @@ export interface BoidOptions {
526526
}
527527
export function updateBoids(boids: Boid[], options: BoidOptions): void;
528528

529+
/**
530+
* Reciprocal velocity obstacles (RVO) step for multi-agent avoidance.
531+
* Use for: crowd steering, swarms, dense navigation.
532+
* Performance: O(n × log n) with neighbor filtering.
533+
* Import: ai/rvo.ts
534+
*/
535+
export interface RvoAgent extends Agent {
536+
id?: string;
537+
radius: number;
538+
preferredVelocity: Vector2D;
539+
}
540+
export interface RvoOptions {
541+
timeHorizon?: number;
542+
maxNeighbors?: number;
543+
avoidanceStrength?: number;
544+
}
545+
export interface RvoResult {
546+
id?: string;
547+
velocity: Vector2D;
548+
}
549+
export function rvoStep(agents: ReadonlyArray<RvoAgent>, options?: RvoOptions): RvoResult[];
550+
529551
/**
530552
* Behavior tree orchestrator for AI decision making.
531553
* Use for: hierarchical NPC logic, modular behaviour scripting, goal selection.

examples/rvo.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { rvoStep } from '../src/index.js';
2+
import type { RvoAgent } from '../src/types.js';
3+
4+
const agents: RvoAgent[] = [
5+
{
6+
id: 'alpha',
7+
position: { x: -2, y: 0 },
8+
velocity: { x: 1, y: 0 },
9+
preferredVelocity: { x: 1, y: 0 },
10+
radius: 0.4,
11+
maxSpeed: 1.5,
12+
},
13+
{
14+
id: 'beta',
15+
position: { x: 2, y: 0.2 },
16+
velocity: { x: -1, y: 0 },
17+
preferredVelocity: { x: -1, y: 0 },
18+
radius: 0.4,
19+
maxSpeed: 1.5,
20+
},
21+
{
22+
id: 'gamma',
23+
position: { x: 0, y: 1.5 },
24+
velocity: { x: 0, y: -0.8 },
25+
preferredVelocity: { x: 0, y: -0.8 },
26+
radius: 0.4,
27+
maxSpeed: 1.2,
28+
},
29+
];
30+
31+
const results = rvoStep(agents, { timeHorizon: 3 });
32+
console.log(results);

src/ai/rvo.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import type { RvoAgent, Vector2D } from '../types.js';
2+
3+
const EPSILON = 1e-6;
4+
5+
export interface RvoOptions {
6+
timeHorizon?: number;
7+
maxNeighbors?: number;
8+
avoidanceStrength?: number;
9+
}
10+
11+
export interface RvoResult {
12+
id?: string;
13+
velocity: Vector2D;
14+
}
15+
16+
/**
17+
* Computes collision-avoiding agent velocities using reciprocal velocity obstacles (RVO).
18+
* Useful for: crowd steering, swarm navigation, multi-agent avoidance.
19+
*/
20+
export function rvoStep(
21+
agents: ReadonlyArray<RvoAgent>,
22+
options: RvoOptions = {}
23+
): RvoResult[] {
24+
if (!Array.isArray(agents)) {
25+
throw new TypeError('agents must be an array');
26+
}
27+
28+
const timeHorizon = options.timeHorizon ?? 2;
29+
const maxNeighbors = options.maxNeighbors ?? agents.length;
30+
const avoidanceStrength = options.avoidanceStrength ?? 0.6;
31+
32+
if (timeHorizon <= 0) {
33+
throw new RangeError('timeHorizon must be greater than zero');
34+
}
35+
if (maxNeighbors <= 0) {
36+
throw new RangeError('maxNeighbors must be greater than zero');
37+
}
38+
if (avoidanceStrength < 0) {
39+
throw new RangeError('avoidanceStrength must be non-negative');
40+
}
41+
42+
return agents.map<RvoResult>((agent: RvoAgent, index: number) => {
43+
validateAgent(agent, index);
44+
45+
const neighborEntries: Array<{ other: RvoAgent; distanceSq: number }> = agents
46+
.map((other: RvoAgent, otherIndex: number) => ({
47+
other,
48+
distanceSq: squaredDistance(agent.position, other.position),
49+
otherIndex,
50+
}))
51+
.filter((entry) => entry.otherIndex !== index)
52+
.sort((a, b) => a.distanceSq - b.distanceSq)
53+
.slice(0, maxNeighbors)
54+
.map(({ other, distanceSq }) => ({ other, distanceSq }));
55+
56+
let adjusted = { ...agent.preferredVelocity };
57+
58+
for (const { other } of neighborEntries) {
59+
const avoidance = computeAvoidance(agent, other, adjusted, timeHorizon, avoidanceStrength);
60+
adjusted = {
61+
x: adjusted.x + avoidance.x,
62+
y: adjusted.y + avoidance.y,
63+
};
64+
}
65+
66+
const speed = length(adjusted);
67+
if (speed > agent.maxSpeed) {
68+
adjusted = scale(adjusted, agent.maxSpeed / (speed || 1));
69+
}
70+
71+
return { id: agent.id, velocity: adjusted };
72+
});
73+
}
74+
75+
function validateAgent(agent: RvoAgent, index: number): void {
76+
if (!agent) {
77+
throw new TypeError(`agents[${index}] is undefined`);
78+
}
79+
if (!isFinite(agent.position.x) || !isFinite(agent.position.y)) {
80+
throw new TypeError(`agents[${index}].position must contain finite numbers`);
81+
}
82+
if (!isFinite(agent.velocity.x) || !isFinite(agent.velocity.y)) {
83+
throw new TypeError(`agents[${index}].velocity must contain finite numbers`);
84+
}
85+
if (!isFinite(agent.preferredVelocity.x) || !isFinite(agent.preferredVelocity.y)) {
86+
throw new TypeError(`agents[${index}].preferredVelocity must contain finite numbers`);
87+
}
88+
if (!isFinite(agent.radius) || agent.radius < 0) {
89+
throw new RangeError(`agents[${index}].radius must be a non-negative number`);
90+
}
91+
if (!isFinite(agent.maxSpeed) || agent.maxSpeed <= 0) {
92+
throw new RangeError(`agents[${index}].maxSpeed must be a positive number`);
93+
}
94+
}
95+
96+
function computeAvoidance(
97+
agent: RvoAgent,
98+
other: RvoAgent,
99+
candidateVelocity: Vector2D,
100+
timeHorizon: number,
101+
avoidanceStrength: number
102+
): Vector2D {
103+
const relPos = subtract(other.position, agent.position);
104+
const relVel = subtract(other.velocity, candidateVelocity);
105+
const combinedRadius = agent.radius + other.radius;
106+
const distSq = dot(relPos, relPos);
107+
108+
if (distSq < EPSILON) {
109+
const direction = normalize(subtract(agent.position, other.position));
110+
return scale(direction, avoidanceStrength * agent.maxSpeed);
111+
}
112+
113+
const timeToCollision = computeTimeToCollision(relPos, relVel, combinedRadius);
114+
115+
if (!Number.isFinite(timeToCollision) || timeToCollision > timeHorizon) {
116+
return { x: 0, y: 0 };
117+
}
118+
119+
const weight = Math.max(0, (timeHorizon - timeToCollision) / timeHorizon);
120+
const separation = Math.sqrt(distSq);
121+
122+
if (separation <= combinedRadius) {
123+
const away = normalize(subtract(agent.position, other.position));
124+
return scale(away, weight * avoidanceStrength * agent.maxSpeed);
125+
}
126+
127+
const normal = normalize(relPos);
128+
const tangent = { x: -normal.y, y: normal.x };
129+
const directionSign = dot(relVel, tangent) >= 0 ? 1 : -1;
130+
const slide = scale(tangent, directionSign * weight * avoidanceStrength * agent.maxSpeed);
131+
return slide;
132+
}
133+
134+
function computeTimeToCollision(relPos: Vector2D, relVel: Vector2D, combinedRadius: number): number {
135+
const a = dot(relVel, relVel);
136+
const b = 2 * dot(relPos, relVel);
137+
const c = dot(relPos, relPos) - combinedRadius * combinedRadius;
138+
139+
if (a < EPSILON) {
140+
return Number.POSITIVE_INFINITY;
141+
}
142+
143+
const discriminant = b * b - 4 * a * c;
144+
if (discriminant < 0) {
145+
return Number.POSITIVE_INFINITY;
146+
}
147+
148+
const sqrtDisc = Math.sqrt(discriminant);
149+
const t1 = (-b - sqrtDisc) / (2 * a);
150+
const t2 = (-b + sqrtDisc) / (2 * a);
151+
152+
if (t1 > EPSILON) {
153+
return t1;
154+
}
155+
if (t2 > EPSILON) {
156+
return t2;
157+
}
158+
return Number.POSITIVE_INFINITY;
159+
}
160+
161+
function subtract(a: Vector2D, b: Vector2D): Vector2D {
162+
return { x: a.x - b.x, y: a.y - b.y };
163+
}
164+
165+
function dot(a: Vector2D, b: Vector2D): number {
166+
return a.x * b.x + a.y * b.y;
167+
}
168+
169+
function length(vec: Vector2D): number {
170+
return Math.hypot(vec.x, vec.y);
171+
}
172+
173+
function squaredDistance(a: Vector2D, b: Vector2D): number {
174+
return (a.x - b.x) ** 2 + (a.y - b.y) ** 2;
175+
}
176+
177+
function normalize(vec: Vector2D): Vector2D {
178+
const len = length(vec);
179+
if (len < EPSILON) {
180+
return { x: 0, y: 0 };
181+
}
182+
return { x: vec.x / len, y: vec.y / len };
183+
}
184+
185+
function scale(vec: Vector2D, scalar: number): Vector2D {
186+
return { x: vec.x * scalar, y: vec.y * scalar };
187+
}
188+
189+
export const __internals = { computeTimeToCollision, subtract, dot, length, normalize, scale };

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export { quadraticBezier, cubicBezier } from './visual/bezier.js';
5050

5151
export { seek, flee, pursue, wander, arrive } from './ai/steering.js';
5252
export { updateBoids } from './ai/boids.js';
53+
export { rvoStep } from './ai/rvo.js';
5354
export {
5455
BehaviorTree,
5556
type BehaviorStatus,

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,9 @@ export interface SteeringAgent extends Agent {
5050
export interface Boid extends SteeringAgent {
5151
acceleration: Vector2D;
5252
}
53+
54+
export interface RvoAgent extends Agent {
55+
id?: string;
56+
radius: number;
57+
preferredVelocity: Vector2D;
58+
}

tests/rvo.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { rvoStep } from '../src/ai/rvo.js';
3+
import type { RvoAgent } from '../src/types.js';
4+
5+
describe('rvoStep', () => {
6+
const baseAgents: RvoAgent[] = [
7+
{
8+
id: 'left',
9+
position: { x: -1, y: 0 },
10+
velocity: { x: 1, y: 0 },
11+
preferredVelocity: { x: 1, y: 0 },
12+
radius: 0.3,
13+
maxSpeed: 2,
14+
},
15+
{
16+
id: 'right',
17+
position: { x: 1, y: 0 },
18+
velocity: { x: -1, y: 0 },
19+
preferredVelocity: { x: -1, y: 0 },
20+
radius: 0.3,
21+
maxSpeed: 2,
22+
},
23+
];
24+
25+
it('adjusts velocities to avoid impending collisions', () => {
26+
const results = rvoStep(baseAgents, { timeHorizon: 3 });
27+
const left = results.find((entry) => entry.id === 'left');
28+
const right = results.find((entry) => entry.id === 'right');
29+
30+
expect(left).toBeDefined();
31+
expect(right).toBeDefined();
32+
33+
expect(Math.abs(left!.velocity.y)).toBeGreaterThan(0);
34+
expect(Math.abs(right!.velocity.y)).toBeGreaterThan(0);
35+
expect(left!.velocity.y * right!.velocity.y).toBeLessThan(0);
36+
37+
expect(left!.velocity.x).toBeGreaterThan(0);
38+
expect(right!.velocity.x).toBeLessThan(0);
39+
});
40+
41+
it('respects max speed and leaves non-threatening agents unchanged', () => {
42+
const agents: RvoAgent[] = [
43+
{
44+
id: 'a',
45+
position: { x: 0, y: 0 },
46+
velocity: { x: 0.2, y: 0 },
47+
preferredVelocity: { x: 1, y: 0 },
48+
radius: 0.2,
49+
maxSpeed: 1,
50+
},
51+
{
52+
id: 'b',
53+
position: { x: 0, y: 5 },
54+
velocity: { x: 0, y: -0.2 },
55+
preferredVelocity: { x: 0, y: -0.5 },
56+
radius: 0.2,
57+
maxSpeed: 1,
58+
},
59+
];
60+
61+
const [first, second] = rvoStep(agents, { timeHorizon: 2 });
62+
expect(Math.hypot(first.velocity.x, first.velocity.y)).toBeLessThanOrEqual(1 + 1e-6);
63+
expect(second.velocity).toEqual(agents[1].preferredVelocity);
64+
});
65+
66+
it('validates inputs', () => {
67+
expect(() => rvoStep(null as unknown as RvoAgent[])).toThrow(TypeError);
68+
expect(() => rvoStep(baseAgents, { timeHorizon: 0 })).toThrow(RangeError);
69+
});
70+
});

0 commit comments

Comments
 (0)