@zakkster/lite-steer 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -0
- package/Steer.d.ts +18 -0
- package/Steer.js +459 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# @zakkster/lite-steer
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@zakkster/lite-steer)
|
|
4
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-steer)
|
|
5
|
+
[](https://www.npmjs.com/package/@zakkster/lite-steer)
|
|
6
|
+
[](https://www.npmjs.com/package/@zakkster/lite-steer)
|
|
7
|
+

|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
|
|
10
|
+
Zero-GC steering behaviors for autonomous agents. Boids, seek, flee, wander, path following, curl noise.
|
|
11
|
+
|
|
12
|
+
**10,000 boids at 60fps. 6 scratchpad vectors. Zero garbage collection.**
|
|
13
|
+
|
|
14
|
+
## Why lite-steer?
|
|
15
|
+
|
|
16
|
+
| Feature | lite-steer | Yuka | p5.js steer | Custom code |
|
|
17
|
+
|---|---|---|---|---|
|
|
18
|
+
| **Zero-GC (scratchpad)** | **Yes** | No | No | Manual |
|
|
19
|
+
| **Float32Array (lite-vec)** | **Yes** | No | No | Rare |
|
|
20
|
+
| **Deterministic wander** | **Yes (seeded RNG)** | No | No | No |
|
|
21
|
+
| **Path following** | **Yes (with lookahead)** | Yes | No | Manual |
|
|
22
|
+
| **Curl noise** | **Yes** | No | No | Manual |
|
|
23
|
+
| **rotateAround orbit** | **Yes (1-liner)** | No | No | 5+ lines |
|
|
24
|
+
| **Bundle size** | **< 3KB** | ~40KB | ~800KB (full) | 0 |
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install @zakkster/lite-steer
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```javascript
|
|
35
|
+
import { vec2 } from '@zakkster/lite-vec';
|
|
36
|
+
import { seek, bounce } from '@zakkster/lite-steer';
|
|
37
|
+
|
|
38
|
+
const pos = vec2.create(100, 100);
|
|
39
|
+
const vel = vec2.create(0, 0);
|
|
40
|
+
const force = vec2.create();
|
|
41
|
+
const target = vec2.create(400, 300);
|
|
42
|
+
|
|
43
|
+
function update() {
|
|
44
|
+
seek(force, pos, vel, target, 200, 0.1);
|
|
45
|
+
vec2.add(vel, vel, force);
|
|
46
|
+
vec2.add(pos, pos, vel);
|
|
47
|
+
bounce(pos, vel, 800, 600);
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Recipes
|
|
52
|
+
|
|
53
|
+
### Firefly Swarm (Wander + Avoid Edges)
|
|
54
|
+
|
|
55
|
+
```javascript
|
|
56
|
+
wanderAngle = wander(force, vel, 20, 0.4, wanderAngle, rng);
|
|
57
|
+
avoidEdges(force2, pos, 40, width, height, 0.5);
|
|
58
|
+
|
|
59
|
+
vec2.add(force, force, force2);
|
|
60
|
+
vec2.add(vel, vel, force);
|
|
61
|
+
vec2.scale(vel, vel, 0.95); // air friction
|
|
62
|
+
vec2.add(pos, pos, vel);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### School of Fish (Boids)
|
|
66
|
+
|
|
67
|
+
```javascript
|
|
68
|
+
separation(fSep, pos, neighbors, 30);
|
|
69
|
+
alignment(fAli, vel, neighbors);
|
|
70
|
+
cohesion(fCoh, pos, neighbors);
|
|
71
|
+
|
|
72
|
+
vec2.scale(fSep, fSep, 1.5); // avoid crowding strongly
|
|
73
|
+
vec2.scale(fAli, fAli, 1.0);
|
|
74
|
+
vec2.scale(fCoh, fCoh, 1.2); // stay with the group
|
|
75
|
+
|
|
76
|
+
vec2.add(force, fSep, fAli);
|
|
77
|
+
vec2.add(force, force, fCoh);
|
|
78
|
+
|
|
79
|
+
vec2.add(vel, vel, force);
|
|
80
|
+
vec2.clampMag(vel, vel, 0, MAX_SPEED);
|
|
81
|
+
vec2.add(pos, pos, vel);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Vortex Particles
|
|
85
|
+
|
|
86
|
+
```javascript
|
|
87
|
+
swirlToward(force, pos, center, 40, 120);
|
|
88
|
+
|
|
89
|
+
vec2.add(vel, vel, force);
|
|
90
|
+
vec2.scale(vel, vel, 0.98); // drag for smooth spiraling
|
|
91
|
+
vec2.add(pos, pos, vel);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Smoke / Fluid (Curl Noise)
|
|
95
|
+
|
|
96
|
+
```javascript
|
|
97
|
+
curl(force, pos[0], pos[1], noiseFn);
|
|
98
|
+
vec2.scale(force, force, 40);
|
|
99
|
+
|
|
100
|
+
vec2.add(vel, vel, force);
|
|
101
|
+
vec2.scale(vel, vel, 0.92); // heavy drag = thick smoke
|
|
102
|
+
vec2.add(pos, pos, vel);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Path-Following Drones
|
|
106
|
+
|
|
107
|
+
```javascript
|
|
108
|
+
followPath(force, pos, vel, path, 120, 30);
|
|
109
|
+
|
|
110
|
+
vec2.add(vel, vel, force);
|
|
111
|
+
vec2.clampMag(vel, vel, 0, 150);
|
|
112
|
+
vec2.add(pos, pos, vel);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Butterfly Motion (Wander + Orbit)
|
|
116
|
+
|
|
117
|
+
```javascript
|
|
118
|
+
wanderAngle = wander(force, vel, 10, 0.6, wanderAngle, rng);
|
|
119
|
+
vec2.add(vel, vel, force);
|
|
120
|
+
vec2.scale(vel, vel, 0.90);
|
|
121
|
+
vec2.add(pos, pos, vel);
|
|
122
|
+
|
|
123
|
+
orbit(pos, pos, center, 0.4, dt); // gentle orbit around flower
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Flee Cursor (Interactive Art)
|
|
127
|
+
|
|
128
|
+
```javascript
|
|
129
|
+
const mouse = vec2.create(mouseX, mouseY);
|
|
130
|
+
flee(force, pos, vel, mouse, 200, 150);
|
|
131
|
+
|
|
132
|
+
vec2.add(vel, vel, force);
|
|
133
|
+
vec2.scale(vel, vel, 0.96);
|
|
134
|
+
vec2.add(pos, pos, vel);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## All 16 Functions
|
|
138
|
+
|
|
139
|
+
**Individual:** `seek`, `arrive`, `flee`, `wander`, `followFlow`
|
|
140
|
+
**Boids:** `separation`, `alignment`, `cohesion`
|
|
141
|
+
**Boundaries:** `wrap`, `bounce`, `avoidEdges`
|
|
142
|
+
**Orbital:** `orbit`, `swirlToward`
|
|
143
|
+
**Noise:** `curl`
|
|
144
|
+
**Path:** `projectToSegment`, `followPath`
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
package/Steer.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Vec2 } from '@zakkster/lite-vec';
|
|
2
|
+
|
|
3
|
+
export function seek(out: Vec2, pos: Vec2, vel: Vec2, target: Vec2, maxSpeed: number, steerStrength: number): void;
|
|
4
|
+
export function arrive(out: Vec2, pos: Vec2, vel: Vec2, target: Vec2, maxSpeed: number, slowRadius: number): void;
|
|
5
|
+
export function flee(out: Vec2, pos: Vec2, vel: Vec2, threat: Vec2, maxSpeed: number, panicDist: number): void;
|
|
6
|
+
export function wander(out: Vec2, vel: Vec2, wanderRadius: number, wanderRate: number, wanderAngle: number, rng: { next(): number }): number;
|
|
7
|
+
export function followFlow(out: Vec2, pos: Vec2, vel: Vec2, fieldFn: (out: Vec2, x: number, y: number) => void, maxSpeed: number, steerStrength: number): void;
|
|
8
|
+
export function separation(out: Vec2, pos: Vec2, neighbors: Array<{ pos: Vec2 }>, desiredDist: number): void;
|
|
9
|
+
export function alignment(out: Vec2, vel: Vec2, neighbors: Array<{ vel: Vec2 }>): void;
|
|
10
|
+
export function cohesion(out: Vec2, pos: Vec2, neighbors: Array<{ pos: Vec2 }>): void;
|
|
11
|
+
export function wrap(pos: Vec2, width: number, height: number): void;
|
|
12
|
+
export function bounce(pos: Vec2, vel: Vec2, width: number, height: number, restitution?: number): void;
|
|
13
|
+
export function avoidEdges(out: Vec2, pos: Vec2, margin: number, width: number, height: number, strength: number): void;
|
|
14
|
+
export function orbit(out: Vec2, pos: Vec2, center: Vec2, speed: number, dt: number): void;
|
|
15
|
+
export function swirlToward(out: Vec2, pos: Vec2, target: Vec2, strength: number, swirl: number): void;
|
|
16
|
+
export function curl(out: Vec2, x: number, y: number, noiseFn: (x: number, y: number) => number, eps?: number): void;
|
|
17
|
+
export function projectToSegment(out: Vec2, p: Vec2, a: Vec2, b: Vec2): void;
|
|
18
|
+
export function followPath(out: Vec2, pos: Vec2, vel: Vec2, path: Vec2[], maxSpeed: number, lookahead?: number): void;
|
package/Steer.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-steer — Zero-GC Steering Behaviors
|
|
3
|
+
*
|
|
4
|
+
* 15 production-ready autonomous agent behaviors built entirely on
|
|
5
|
+
* @zakkster/lite-vec. Every function uses the module scratchpad pattern:
|
|
6
|
+
* temporary vectors are allocated once at module load, reused forever.
|
|
7
|
+
*
|
|
8
|
+
* Zero allocations in the hot path. Deterministic when used with lite-random.
|
|
9
|
+
*
|
|
10
|
+
* Depends on: @zakkster/lite-vec
|
|
11
|
+
*
|
|
12
|
+
* USAGE PATTERN:
|
|
13
|
+
* All steering functions write into an `out` vec2 (the force/acceleration).
|
|
14
|
+
* Apply it in your game loop:
|
|
15
|
+
*
|
|
16
|
+
* seek(force, pos, vel, target, 200, 0.1);
|
|
17
|
+
* vec2.add(vel, vel, force);
|
|
18
|
+
* vec2.add(pos, pos, vel);
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { vec2 } from '@zakkster/lite-vec';
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
// ─────────────────────────────────────────────────────────
|
|
25
|
+
// MODULE SCRATCHPADS
|
|
26
|
+
// Allocated once. Reused by every function call.
|
|
27
|
+
// JS engines optimize Float32Array access into register ops.
|
|
28
|
+
// ─────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const _tmp = vec2.create();
|
|
31
|
+
const _tmp2 = vec2.create();
|
|
32
|
+
const _diff = vec2.create();
|
|
33
|
+
const _perp = vec2.create();
|
|
34
|
+
const _proj = vec2.create();
|
|
35
|
+
const _seg = vec2.create();
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
// ═══════════════════════════════════════════════════════════
|
|
39
|
+
// INDIVIDUAL BEHAVIORS
|
|
40
|
+
// ═══════════════════════════════════════════════════════════
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Seek: steer toward a target at maximum speed.
|
|
44
|
+
* Classic Craig Reynolds steering.
|
|
45
|
+
*
|
|
46
|
+
* @param {Float32Array} out Output force vector
|
|
47
|
+
* @param {Float32Array} pos Current position
|
|
48
|
+
* @param {Float32Array} vel Current velocity
|
|
49
|
+
* @param {Float32Array} target Target position
|
|
50
|
+
* @param {number} maxSpeed Desired approach speed
|
|
51
|
+
* @param {number} steerStrength How aggressively to correct course (0–1)
|
|
52
|
+
*/
|
|
53
|
+
export function seek(out, pos, vel, target, maxSpeed, steerStrength) {
|
|
54
|
+
vec2.sub(out, target, pos);
|
|
55
|
+
vec2.normalize(out, out);
|
|
56
|
+
vec2.scale(out, out, maxSpeed);
|
|
57
|
+
vec2.sub(out, out, vel);
|
|
58
|
+
vec2.scale(out, out, steerStrength);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Arrive: seek with smooth deceleration near the target.
|
|
64
|
+
* The agent slows to a stop instead of orbiting.
|
|
65
|
+
*
|
|
66
|
+
* @param {Float32Array} out
|
|
67
|
+
* @param {Float32Array} pos
|
|
68
|
+
* @param {Float32Array} vel
|
|
69
|
+
* @param {Float32Array} target
|
|
70
|
+
* @param {number} maxSpeed
|
|
71
|
+
* @param {number} slowRadius Distance at which deceleration begins
|
|
72
|
+
*/
|
|
73
|
+
export function arrive(out, pos, vel, target, maxSpeed, slowRadius) {
|
|
74
|
+
vec2.sub(out, target, pos);
|
|
75
|
+
const dist = vec2.mag(out);
|
|
76
|
+
if (dist < 0.001) { vec2.zero(out); return; }
|
|
77
|
+
|
|
78
|
+
const speed = dist < slowRadius ? maxSpeed * (dist / slowRadius) : maxSpeed;
|
|
79
|
+
vec2.scale(out, out, speed / dist); // normalize + scale in one step
|
|
80
|
+
vec2.sub(out, out, vel);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Flee: steer away from a threat. Ignores threats beyond panicDist.
|
|
86
|
+
*
|
|
87
|
+
* @param {Float32Array} out
|
|
88
|
+
* @param {Float32Array} pos
|
|
89
|
+
* @param {Float32Array} vel
|
|
90
|
+
* @param {Float32Array} threat
|
|
91
|
+
* @param {number} maxSpeed
|
|
92
|
+
* @param {number} panicDist Only flee if closer than this
|
|
93
|
+
*/
|
|
94
|
+
export function flee(out, pos, vel, threat, maxSpeed, panicDist) {
|
|
95
|
+
if (vec2.distSq(pos, threat) > panicDist * panicDist) {
|
|
96
|
+
vec2.zero(out);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
vec2.sub(out, pos, threat);
|
|
100
|
+
vec2.normalize(out, out);
|
|
101
|
+
vec2.scale(out, out, maxSpeed);
|
|
102
|
+
vec2.sub(out, out, vel);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Wander: natural random motion. Returns the updated wander angle
|
|
108
|
+
* so the caller can store it per-entity.
|
|
109
|
+
*
|
|
110
|
+
* Uses an RNG parameter instead of Math.random() for deterministic output.
|
|
111
|
+
*
|
|
112
|
+
* @param {Float32Array} out
|
|
113
|
+
* @param {Float32Array} vel Current velocity (direction is extracted)
|
|
114
|
+
* @param {number} wanderRadius Size of the wander circle
|
|
115
|
+
* @param {number} wanderRate How fast the angle drifts
|
|
116
|
+
* @param {number} wanderAngle Current wander angle (stored per entity)
|
|
117
|
+
* @param {{ next: () => number }} rng Seeded RNG (or { next: Math.random })
|
|
118
|
+
* @returns {number} Updated wander angle — store this on the entity
|
|
119
|
+
*/
|
|
120
|
+
export function wander(out, vel, wanderRadius, wanderRate, wanderAngle, rng) {
|
|
121
|
+
const newAngle = wanderAngle + (rng.next() - 0.5) * wanderRate;
|
|
122
|
+
|
|
123
|
+
vec2.normalize(_tmp, vel);
|
|
124
|
+
vec2.scale(_tmp, _tmp, wanderRadius);
|
|
125
|
+
|
|
126
|
+
vec2.fromAngle(_tmp2, newAngle);
|
|
127
|
+
vec2.scale(_tmp2, _tmp2, wanderRadius);
|
|
128
|
+
|
|
129
|
+
vec2.add(out, _tmp, _tmp2);
|
|
130
|
+
return newAngle;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Follow a flow field. Queries the field function for desired direction.
|
|
136
|
+
* The fieldFn receives a scratchpad vector to write into — zero allocations.
|
|
137
|
+
*
|
|
138
|
+
* @param {Float32Array} out
|
|
139
|
+
* @param {Float32Array} pos
|
|
140
|
+
* @param {Float32Array} vel
|
|
141
|
+
* @param {Function} fieldFn (out, x, y) => void — writes direction into out
|
|
142
|
+
* @param {number} maxSpeed
|
|
143
|
+
* @param {number} steerStrength
|
|
144
|
+
*/
|
|
145
|
+
export function followFlow(out, pos, vel, fieldFn, maxSpeed, steerStrength) {
|
|
146
|
+
fieldFn(_tmp, pos[0], pos[1]);
|
|
147
|
+
vec2.normalize(out, _tmp);
|
|
148
|
+
vec2.scale(out, out, maxSpeed);
|
|
149
|
+
vec2.sub(out, out, vel);
|
|
150
|
+
vec2.scale(out, out, steerStrength);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
// ═══════════════════════════════════════════════════════════
|
|
155
|
+
// BOIDS (Flocking)
|
|
156
|
+
// ═══════════════════════════════════════════════════════════
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Separation: steer away from nearby neighbors to avoid crowding.
|
|
160
|
+
* Boids rule #1.
|
|
161
|
+
*
|
|
162
|
+
* @param {Float32Array} out
|
|
163
|
+
* @param {Float32Array} pos
|
|
164
|
+
* @param {Array<{pos: Float32Array}>} neighbors
|
|
165
|
+
* @param {number} desiredDist Minimum comfortable distance
|
|
166
|
+
*/
|
|
167
|
+
export function separation(out, pos, neighbors, desiredDist) {
|
|
168
|
+
vec2.zero(out);
|
|
169
|
+
let count = 0;
|
|
170
|
+
const dSqThreshold = desiredDist * desiredDist;
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < neighbors.length; i++) {
|
|
173
|
+
const dSq = vec2.distSq(pos, neighbors[i].pos);
|
|
174
|
+
if (dSq > 0 && dSq < dSqThreshold) {
|
|
175
|
+
vec2.sub(_diff, pos, neighbors[i].pos);
|
|
176
|
+
vec2.normalize(_diff, _diff);
|
|
177
|
+
vec2.scale(_diff, _diff, 1 / dSq);
|
|
178
|
+
vec2.add(out, out, _diff);
|
|
179
|
+
count++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (count > 0) vec2.scale(out, out, 1 / count);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Alignment: steer toward the average heading of neighbors.
|
|
189
|
+
* Boids rule #2.
|
|
190
|
+
*
|
|
191
|
+
* @param {Float32Array} out
|
|
192
|
+
* @param {Float32Array} vel
|
|
193
|
+
* @param {Array<{vel: Float32Array}>} neighbors
|
|
194
|
+
*/
|
|
195
|
+
export function alignment(out, vel, neighbors) {
|
|
196
|
+
vec2.zero(out);
|
|
197
|
+
|
|
198
|
+
for (let i = 0; i < neighbors.length; i++) {
|
|
199
|
+
vec2.add(out, out, neighbors[i].vel);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (neighbors.length > 0) {
|
|
203
|
+
vec2.scale(out, out, 1 / neighbors.length);
|
|
204
|
+
vec2.sub(out, out, vel);
|
|
205
|
+
vec2.normalize(out, out);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Cohesion: steer toward the center of mass of neighbors.
|
|
212
|
+
* Boids rule #3.
|
|
213
|
+
*
|
|
214
|
+
* @param {Float32Array} out
|
|
215
|
+
* @param {Float32Array} pos
|
|
216
|
+
* @param {Array<{pos: Float32Array}>} neighbors
|
|
217
|
+
*/
|
|
218
|
+
export function cohesion(out, pos, neighbors) {
|
|
219
|
+
vec2.zero(out);
|
|
220
|
+
|
|
221
|
+
for (let i = 0; i < neighbors.length; i++) {
|
|
222
|
+
vec2.add(out, out, neighbors[i].pos);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (neighbors.length > 0) {
|
|
226
|
+
vec2.scale(out, out, 1 / neighbors.length);
|
|
227
|
+
vec2.sub(out, out, pos);
|
|
228
|
+
vec2.normalize(out, out);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
// ═══════════════════════════════════════════════════════════
|
|
234
|
+
// BOUNDARIES
|
|
235
|
+
// ═══════════════════════════════════════════════════════════
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Screen wrap (Asteroids-style). Teleports to opposite edge.
|
|
239
|
+
* Mutates pos in-place.
|
|
240
|
+
*
|
|
241
|
+
* @param {Float32Array} pos
|
|
242
|
+
* @param {number} width
|
|
243
|
+
* @param {number} height
|
|
244
|
+
*/
|
|
245
|
+
export function wrap(pos, width, height) {
|
|
246
|
+
if (pos[0] < 0) pos[0] += width;
|
|
247
|
+
else if (pos[0] > width) pos[0] -= width;
|
|
248
|
+
if (pos[1] < 0) pos[1] += height;
|
|
249
|
+
else if (pos[1] > height) pos[1] -= height;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Screen bounce. Reverses velocity on edge contact.
|
|
255
|
+
* Mutates pos and vel in-place. Clamps position to bounds.
|
|
256
|
+
*
|
|
257
|
+
* @param {Float32Array} pos
|
|
258
|
+
* @param {Float32Array} vel
|
|
259
|
+
* @param {number} width
|
|
260
|
+
* @param {number} height
|
|
261
|
+
* @param {number} [restitution=0.8] Bounciness (1 = perfect, 0.5 = lossy)
|
|
262
|
+
*/
|
|
263
|
+
export function bounce(pos, vel, width, height, restitution = 0.8) {
|
|
264
|
+
if (pos[0] <= 0) { pos[0] = 0; vel[0] = Math.abs(vel[0]) * restitution; }
|
|
265
|
+
if (pos[0] >= width) { pos[0] = width; vel[0] = -Math.abs(vel[0]) * restitution; }
|
|
266
|
+
if (pos[1] <= 0) { pos[1] = 0; vel[1] = Math.abs(vel[1]) * restitution; }
|
|
267
|
+
if (pos[1] >= height) { pos[1] = height; vel[1] = -Math.abs(vel[1]) * restitution; }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Soft edge avoidance. Applies a gradient steering force
|
|
273
|
+
* that increases as the agent gets closer to the edge.
|
|
274
|
+
* Much smoother than hard bounce.
|
|
275
|
+
*
|
|
276
|
+
* @param {Float32Array} out Output force
|
|
277
|
+
* @param {Float32Array} pos
|
|
278
|
+
* @param {number} margin Distance from edge where force begins
|
|
279
|
+
* @param {number} width
|
|
280
|
+
* @param {number} height
|
|
281
|
+
* @param {number} strength Maximum force magnitude
|
|
282
|
+
*/
|
|
283
|
+
export function avoidEdges(out, pos, margin, width, height, strength) {
|
|
284
|
+
out[0] = 0;
|
|
285
|
+
out[1] = 0;
|
|
286
|
+
|
|
287
|
+
if (pos[0] < margin) out[0] = strength * (1 - pos[0] / margin);
|
|
288
|
+
else if (pos[0] > width - margin) out[0] = -strength * (1 - (width - pos[0]) / margin);
|
|
289
|
+
|
|
290
|
+
if (pos[1] < margin) out[1] = strength * (1 - pos[1] / margin);
|
|
291
|
+
else if (pos[1] > height - margin) out[1] = -strength * (1 - (height - pos[1]) / margin);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
// ═══════════════════════════════════════════════════════════
|
|
296
|
+
// ORBITAL & VORTEX
|
|
297
|
+
// ═══════════════════════════════════════════════════════════
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Orbit: rotate position around a center point.
|
|
301
|
+
* One-liner powered by vec2.rotateAround.
|
|
302
|
+
*
|
|
303
|
+
* @param {Float32Array} out Output position
|
|
304
|
+
* @param {Float32Array} pos Current position
|
|
305
|
+
* @param {Float32Array} center Orbit center
|
|
306
|
+
* @param {number} speed Radians per second
|
|
307
|
+
* @param {number} dt Delta time in seconds
|
|
308
|
+
*/
|
|
309
|
+
export function orbit(out, pos, center, speed, dt) {
|
|
310
|
+
vec2.rotateAround(out, pos, center, speed * dt);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Swirl toward a target: attraction + perpendicular spin.
|
|
316
|
+
* The closer the particle, the faster it spirals.
|
|
317
|
+
* Perfect for "sucked into a vortex" VFX.
|
|
318
|
+
*
|
|
319
|
+
* @param {Float32Array} out
|
|
320
|
+
* @param {Float32Array} pos
|
|
321
|
+
* @param {Float32Array} target
|
|
322
|
+
* @param {number} strength Pull force
|
|
323
|
+
* @param {number} swirl Tangential spin force
|
|
324
|
+
*/
|
|
325
|
+
export function swirlToward(out, pos, target, strength, swirl) {
|
|
326
|
+
vec2.sub(out, target, pos);
|
|
327
|
+
const dist = vec2.mag(out);
|
|
328
|
+
|
|
329
|
+
vec2.normalize(out, out);
|
|
330
|
+
vec2.scale(out, out, strength);
|
|
331
|
+
|
|
332
|
+
vec2.perp(_perp, out);
|
|
333
|
+
vec2.scale(_perp, _perp, swirl / (dist + 1));
|
|
334
|
+
|
|
335
|
+
vec2.add(out, out, _perp);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
// ═══════════════════════════════════════════════════════════
|
|
340
|
+
// NOISE & FIELDS
|
|
341
|
+
// ═══════════════════════════════════════════════════════════
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Curl noise: compute a divergence-free 2D vector from a scalar noise field.
|
|
345
|
+
* Produces swirling, organic flow. Perfect for smoke, water, generative art.
|
|
346
|
+
*
|
|
347
|
+
* @param {Float32Array} out
|
|
348
|
+
* @param {number} x Sample X position
|
|
349
|
+
* @param {number} y Sample Y position
|
|
350
|
+
* @param {Function} noiseFn (x, y) => number (e.g. SimplexNoise.noise2D)
|
|
351
|
+
* @param {number} [eps=0.001] Finite difference step size
|
|
352
|
+
*/
|
|
353
|
+
export function curl(out, x, y, noiseFn, eps = 0.001) {
|
|
354
|
+
const n1 = noiseFn(x, y + eps);
|
|
355
|
+
const n2 = noiseFn(x, y - eps);
|
|
356
|
+
const n3 = noiseFn(x + eps, y);
|
|
357
|
+
const n4 = noiseFn(x - eps, y);
|
|
358
|
+
|
|
359
|
+
out[0] = (n3 - n4) / (2 * eps);
|
|
360
|
+
out[1] = -(n1 - n2) / (2 * eps);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
// ═══════════════════════════════════════════════════════════
|
|
365
|
+
// PATH FOLLOWING
|
|
366
|
+
// ═══════════════════════════════════════════════════════════
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Project a point onto a line segment. Returns the closest point.
|
|
370
|
+
* Zero-GC via scratchpad.
|
|
371
|
+
*
|
|
372
|
+
* @param {Float32Array} out Closest point on segment
|
|
373
|
+
* @param {Float32Array} p The point to project
|
|
374
|
+
* @param {Float32Array} a Segment start
|
|
375
|
+
* @param {Float32Array} b Segment end
|
|
376
|
+
*/
|
|
377
|
+
export function projectToSegment(out, p, a, b) {
|
|
378
|
+
vec2.sub(_seg, b, a);
|
|
379
|
+
const lenSq = vec2.magSq(_seg);
|
|
380
|
+
|
|
381
|
+
if (lenSq < 0.0001) {
|
|
382
|
+
vec2.copy(out, a);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
vec2.sub(_tmp, p, a);
|
|
387
|
+
let t = vec2.dot(_tmp, _seg) / lenSq;
|
|
388
|
+
if (t < 0) t = 0;
|
|
389
|
+
else if (t > 1) t = 1;
|
|
390
|
+
|
|
391
|
+
out[0] = a[0] + _seg[0] * t;
|
|
392
|
+
out[1] = a[1] + _seg[1] * t;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Follow a polyline path. Steers toward the closest point on the path,
|
|
398
|
+
* advanced by a lookahead distance for smooth anticipation.
|
|
399
|
+
*
|
|
400
|
+
* @param {Float32Array} out Output steering force
|
|
401
|
+
* @param {Float32Array} pos Current position
|
|
402
|
+
* @param {Float32Array} vel Current velocity
|
|
403
|
+
* @param {Array<Float32Array>} path Array of vec2 waypoints
|
|
404
|
+
* @param {number} maxSpeed
|
|
405
|
+
* @param {number} [lookahead=20] How far ahead on the path to target
|
|
406
|
+
*/
|
|
407
|
+
export function followPath(out, pos, vel, path, maxSpeed, lookahead = 20) {
|
|
408
|
+
if (path.length < 2) { vec2.zero(out); return; }
|
|
409
|
+
|
|
410
|
+
let closestDistSq = Infinity;
|
|
411
|
+
let bestSegIdx = 0;
|
|
412
|
+
let bestT = 0;
|
|
413
|
+
|
|
414
|
+
// Find nearest point on the path
|
|
415
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
416
|
+
projectToSegment(_proj, pos, path[i], path[i + 1]);
|
|
417
|
+
const dSq = vec2.distSq(pos, _proj);
|
|
418
|
+
if (dSq < closestDistSq) {
|
|
419
|
+
closestDistSq = dSq;
|
|
420
|
+
bestSegIdx = i;
|
|
421
|
+
vec2.sub(_seg, path[i + 1], path[i]);
|
|
422
|
+
const segLen = vec2.mag(_seg);
|
|
423
|
+
if (segLen > 0.001) {
|
|
424
|
+
vec2.sub(_tmp, _proj, path[i]);
|
|
425
|
+
bestT = vec2.mag(_tmp) / segLen;
|
|
426
|
+
} else { bestT = 0; }
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Advance along the path by lookahead distance
|
|
431
|
+
let remain = lookahead;
|
|
432
|
+
let segIdx = bestSegIdx;
|
|
433
|
+
let t = bestT;
|
|
434
|
+
|
|
435
|
+
while (remain > 0 && segIdx < path.length - 1) {
|
|
436
|
+
vec2.sub(_seg, path[segIdx + 1], path[segIdx]);
|
|
437
|
+
const segLen = vec2.mag(_seg);
|
|
438
|
+
const remainOnSeg = segLen * (1 - t);
|
|
439
|
+
|
|
440
|
+
if (remain <= remainOnSeg) {
|
|
441
|
+
t += remain / (segLen || 1);
|
|
442
|
+
remain = 0;
|
|
443
|
+
} else {
|
|
444
|
+
remain -= remainOnSeg;
|
|
445
|
+
segIdx++;
|
|
446
|
+
t = 0;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Compute target point
|
|
451
|
+
if (segIdx >= path.length - 1) {
|
|
452
|
+
vec2.copy(_proj, path[path.length - 1]);
|
|
453
|
+
} else {
|
|
454
|
+
vec2.lerp(_proj, path[segIdx], path[segIdx + 1], t);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Seek toward the lookahead point
|
|
458
|
+
seek(out, pos, vel, _proj, maxSpeed, 1);
|
|
459
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zakkster/lite-steer",
|
|
3
|
+
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Zero-GC steering behaviors for autonomous agents. Boids, seek, flee, wander, path following, curl noise. Built on lite-vec.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "Steer.js",
|
|
8
|
+
"types": "Steer.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./Steer.d.ts",
|
|
12
|
+
"import": "./Steer.js",
|
|
13
|
+
"default": "./Steer.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"Steer.js",
|
|
18
|
+
"Steer.d.ts",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"homepage": "https://github.com/PeshoVurtoleta/zakkster-lite-steer#readme",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/PeshoVurtoleta/zakkster-lite-steer.git"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/PeshoVurtoleta/zakkster-lite-steer/issues",
|
|
29
|
+
"email": "shinikchiev@yahoo.com"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@zakkster/lite-vec": "^1.0.0"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"steering",
|
|
36
|
+
"boids",
|
|
37
|
+
"flocking",
|
|
38
|
+
"pathfinding",
|
|
39
|
+
"seek",
|
|
40
|
+
"flee",
|
|
41
|
+
"wander",
|
|
42
|
+
"ai",
|
|
43
|
+
"autonomous-agent",
|
|
44
|
+
"zero-gc"
|
|
45
|
+
]
|
|
46
|
+
}
|