@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.
Files changed (4) hide show
  1. package/README.md +148 -0
  2. package/Steer.d.ts +18 -0
  3. package/Steer.js +459 -0
  4. package/package.json +46 -0
package/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # @zakkster/lite-steer
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@zakkster/lite-steer.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-steer)
4
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-steer?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-steer)
5
+ [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-steer?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-steer)
6
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-steer?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-steer)
7
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](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
+ }