@jgengine/core 0.25.0 → 0.26.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 (95) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/ai/rvo.d.ts +106 -0
  3. package/dist/ai/rvo.js +581 -0
  4. package/dist/ai/utilityBrain.d.ts +173 -0
  5. package/dist/ai/utilityBrain.js +277 -0
  6. package/dist/combat/cover.d.ts +169 -0
  7. package/dist/combat/cover.js +264 -0
  8. package/dist/combat/damageRuntime.d.ts +150 -0
  9. package/dist/combat/damageRuntime.js +208 -0
  10. package/dist/combat/damageTable.d.ts +116 -0
  11. package/dist/combat/damageTable.js +165 -0
  12. package/dist/combat/hitZones.d.ts +70 -0
  13. package/dist/combat/hitZones.js +77 -0
  14. package/dist/combat/magazine.d.ts +8 -0
  15. package/dist/combat/magazine.js +20 -2
  16. package/dist/combat/squadCombat.d.ts +167 -0
  17. package/dist/combat/squadCombat.js +557 -0
  18. package/dist/combat/squadTactics.d.ts +73 -0
  19. package/dist/combat/squadTactics.js +49 -0
  20. package/dist/combat/statusRuntime.d.ts +142 -0
  21. package/dist/combat/statusRuntime.js +236 -0
  22. package/dist/combat/weaponDefinition.d.ts +106 -0
  23. package/dist/combat/weaponDefinition.js +108 -0
  24. package/dist/combat/weaponSystem.d.ts +189 -0
  25. package/dist/combat/weaponSystem.js +469 -0
  26. package/dist/combat.d.ts +9 -0
  27. package/dist/combat.js +9 -0
  28. package/dist/devtools/devtools.d.ts +4 -2
  29. package/dist/devtools/devtools.js +2 -1
  30. package/dist/devtools/discover.d.ts +2 -1
  31. package/dist/devtools/discover.js +38 -0
  32. package/dist/devtools/frame.d.ts +0 -2
  33. package/dist/devtools/frame.js +1 -2
  34. package/dist/devtools/logBus.d.ts +58 -0
  35. package/dist/devtools/logBus.js +79 -0
  36. package/dist/devtools/logs.d.ts +6 -3
  37. package/dist/devtools/logs.js +17 -13
  38. package/dist/devtools/report.d.ts +120 -0
  39. package/dist/devtools/report.js +49 -0
  40. package/dist/devtools/reportEndpoint.d.ts +17 -0
  41. package/dist/devtools/reportEndpoint.js +7 -0
  42. package/dist/devtools/types.d.ts +48 -11
  43. package/dist/editor/document.js +210 -0
  44. package/dist/editor/environment.d.ts +48 -1
  45. package/dist/editor/environment.js +161 -0
  46. package/dist/editor/index.d.ts +6 -2
  47. package/dist/editor/index.js +1 -1
  48. package/dist/editor/types.d.ts +54 -0
  49. package/dist/gameplay.d.ts +4 -0
  50. package/dist/gameplay.js +4 -0
  51. package/dist/meta/changelog.d.ts +1 -1
  52. package/dist/meta/changelog.js +56 -1
  53. package/dist/nav/navCrowd.d.ts +138 -0
  54. package/dist/nav/navCrowd.js +412 -0
  55. package/dist/nav/navRuntime.d.ts +108 -0
  56. package/dist/nav/navRuntime.js +256 -0
  57. package/dist/render/colorPipeline.d.ts +53 -0
  58. package/dist/render/colorPipeline.js +79 -0
  59. package/dist/render/lightProbes.d.ts +97 -0
  60. package/dist/render/lightProbes.js +221 -0
  61. package/dist/render/lookPreset.js +4 -4
  62. package/dist/render/physicalSky.d.ts +39 -0
  63. package/dist/render/physicalSky.js +89 -0
  64. package/dist/render/postProcessing.d.ts +40 -8
  65. package/dist/render/postProcessing.js +1 -1
  66. package/dist/render/skyOwnedLighting.d.ts +31 -0
  67. package/dist/render/skyOwnedLighting.js +46 -0
  68. package/dist/sensor/perception.d.ts +66 -5
  69. package/dist/sensor/perception.js +167 -42
  70. package/dist/sim/nearAgents.d.ts +68 -0
  71. package/dist/sim/nearAgents.js +152 -0
  72. package/dist/sim/needs.d.ts +60 -0
  73. package/dist/sim/needs.js +108 -0
  74. package/dist/sim/offScreen.d.ts +181 -0
  75. package/dist/sim/offScreen.js +450 -0
  76. package/dist/sim/routine.d.ts +79 -0
  77. package/dist/sim/routine.js +137 -0
  78. package/dist/visibility/visibilitySystem.js +21 -0
  79. package/dist/world/features.d.ts +8 -0
  80. package/dist/world.d.ts +5 -0
  81. package/dist/world.js +5 -0
  82. package/package.json +1 -1
  83. package/skills/jgengine/api.md +59 -6
  84. package/skills/jgengine/capabilities.md +4 -0
  85. package/skills/jgengine-combat/SKILL.md +17 -0
  86. package/skills/jgengine-combat/api.md +246 -0
  87. package/skills/jgengine-combat/capabilities.md +112 -0
  88. package/skills/jgengine-combat/reference.md +32 -0
  89. package/skills/jgengine-gameplay/SKILL.md +8 -0
  90. package/skills/jgengine-gameplay/api.md +71 -1
  91. package/skills/jgengine-gameplay/capabilities.md +16 -0
  92. package/skills/jgengine-world/SKILL.md +3 -1
  93. package/skills/jgengine-world/api.md +107 -3
  94. package/skills/jgengine-world/capabilities.md +22 -2
  95. package/skills/jgengine-world/recipes/npc-senses.md +8 -2
package/dist/ai/rvo.js ADDED
@@ -0,0 +1,581 @@
1
+ import { devtools } from "../devtools/devtools.js";
2
+ const DEFAULT_TUNABLES = { timeHorizon: 2, neighborDist: 3, maxNeighbors: 6, sideBias: 0.035, overlapRelief: 1 };
3
+ const EPSILON = 1e-9;
4
+ const LINE_STRIDE = 4;
5
+ function det(ax, az, bx, bz) {
6
+ return ax * bz - az * bx;
7
+ }
8
+ /** Point on line `lineNo` at parameter `t`, stored into `out`. */
9
+ function linePointAt(buffer, lineNo, t, out) {
10
+ out[0] = buffer[lineNo * LINE_STRIDE] + buffer[lineNo * LINE_STRIDE + 2] * t;
11
+ out[1] = buffer[lineNo * LINE_STRIDE + 1] + buffer[lineNo * LINE_STRIDE + 3] * t;
12
+ }
13
+ /**
14
+ * Furthest point toward `opt` on line `lineNo` that also satisfies lines `0..lineNo-1`.
15
+ * Returns `false` when the line makes the set infeasible. Result lands in `solution`.
16
+ */
17
+ function linearProgram1(buffer, lineNo, radius, optX, optZ, directionOpt, solution) {
18
+ const px = buffer[lineNo * LINE_STRIDE];
19
+ const pz = buffer[lineNo * LINE_STRIDE + 1];
20
+ const dx = buffer[lineNo * LINE_STRIDE + 2];
21
+ const dz = buffer[lineNo * LINE_STRIDE + 3];
22
+ const dotProduct = px * dx + pz * dz;
23
+ const discriminant = dotProduct * dotProduct + radius * radius - (px * px + pz * pz);
24
+ if (discriminant < 0)
25
+ return false;
26
+ const root = Math.sqrt(discriminant);
27
+ let tLeft = -dotProduct - root;
28
+ let tRight = -dotProduct + root;
29
+ for (let i = 0; i < lineNo; i += 1) {
30
+ const qx = buffer[i * LINE_STRIDE];
31
+ const qz = buffer[i * LINE_STRIDE + 1];
32
+ const ex = buffer[i * LINE_STRIDE + 2];
33
+ const ez = buffer[i * LINE_STRIDE + 3];
34
+ const denominator = det(dx, dz, ex, ez);
35
+ const numerator = det(ex, ez, px - qx, pz - qz);
36
+ if (Math.abs(denominator) <= EPSILON) {
37
+ if (numerator < 0)
38
+ return false;
39
+ continue;
40
+ }
41
+ const t = numerator / denominator;
42
+ if (denominator >= 0)
43
+ tRight = Math.min(tRight, t);
44
+ else
45
+ tLeft = Math.max(tLeft, t);
46
+ if (tLeft > tRight)
47
+ return false;
48
+ }
49
+ if (directionOpt) {
50
+ linePointAt(buffer, lineNo, dotProduct > 0 ? tRight : tLeft, solution);
51
+ }
52
+ else {
53
+ const t = dx * (optX - px) + dz * (optZ - pz);
54
+ linePointAt(buffer, lineNo, t < tLeft ? tLeft : t > tRight ? tRight : t, solution);
55
+ }
56
+ return true;
57
+ }
58
+ /** Velocity closest to `opt` inside every line in `buffer`; returns the violated line index, or `lineCount`. */
59
+ function linearProgram2(buffer, lineCount, radius, optX, optZ, directionOpt, solution) {
60
+ if (directionOpt) {
61
+ solution[0] = optX * radius;
62
+ solution[1] = optZ * radius;
63
+ }
64
+ else {
65
+ const lengthSq = optX * optX + optZ * optZ;
66
+ if (lengthSq > radius * radius) {
67
+ const scale = radius / Math.sqrt(lengthSq);
68
+ solution[0] = optX * scale;
69
+ solution[1] = optZ * scale;
70
+ }
71
+ else {
72
+ solution[0] = optX;
73
+ solution[1] = optZ;
74
+ }
75
+ }
76
+ for (let i = 0; i < lineCount; i += 1) {
77
+ const px = buffer[i * LINE_STRIDE];
78
+ const pz = buffer[i * LINE_STRIDE + 1];
79
+ const dx = buffer[i * LINE_STRIDE + 2];
80
+ const dz = buffer[i * LINE_STRIDE + 3];
81
+ if (det(dx, dz, px - solution[0], pz - solution[1]) <= 0)
82
+ continue;
83
+ const keepX = solution[0];
84
+ const keepZ = solution[1];
85
+ if (!linearProgram1(buffer, i, radius, optX, optZ, directionOpt, solution)) {
86
+ solution[0] = keepX;
87
+ solution[1] = keepZ;
88
+ return i;
89
+ }
90
+ }
91
+ return lineCount;
92
+ }
93
+ /**
94
+ * Reciprocal velocity obstacle avoidance over circular agents. Each nearby pair contributes one or
95
+ * two half-plane constraints on the agent's velocity, and a small linear program picks the velocity
96
+ * closest to the preferred one that satisfies them all. The neighbour search is a uniform grid
97
+ * capped at `maxNeighbors`, so a tick costs the same for 50 agents and 5,000.
98
+ *
99
+ * @capability rvo-avoidance reciprocal collision avoidance between many steering agents
100
+ */
101
+ export function createRvoCrowd(options = {}) {
102
+ let tunables = {
103
+ timeHorizon: Math.max(EPSILON, options.timeHorizon ?? DEFAULT_TUNABLES.timeHorizon),
104
+ neighborDist: Math.max(EPSILON, options.neighborDist ?? DEFAULT_TUNABLES.neighborDist),
105
+ maxNeighbors: Math.max(1, Math.floor(options.maxNeighbors ?? DEFAULT_TUNABLES.maxNeighbors)),
106
+ sideBias: Number.isFinite(options.sideBias) ? options.sideBias : DEFAULT_TUNABLES.sideBias,
107
+ overlapRelief: Number.isFinite(options.overlapRelief) ? options.overlapRelief : DEFAULT_TUNABLES.overlapRelief,
108
+ };
109
+ const slots = [];
110
+ const slotOf = new Map();
111
+ let biasTurn = 0;
112
+ let biasKeep = 1;
113
+ function refreshBias() {
114
+ biasTurn = tunables.sideBias === 0 ? 0 : Math.sin(tunables.sideBias);
115
+ biasKeep = tunables.sideBias === 0 ? 1 : Math.cos(tunables.sideBias);
116
+ }
117
+ refreshBias();
118
+ let tickCount = 0;
119
+ let solveCount = 0;
120
+ let slowestStepMs = 0;
121
+ let candidates = new Int32Array(tunables.maxNeighbors);
122
+ let candidateSq = new Float64Array(tunables.maxNeighbors);
123
+ let nextVX = new Float64Array(64);
124
+ let nextVZ = new Float64Array(64);
125
+ let lines = new Float64Array(tunables.maxNeighbors * 2 * LINE_STRIDE);
126
+ let projection = new Float64Array(tunables.maxNeighbors * 2 * LINE_STRIDE);
127
+ let nextInCell = new Int32Array(64);
128
+ let cells = new Int32Array(64);
129
+ const solution = [0, 0];
130
+ function resizeBuffers() {
131
+ const width = tunables.maxNeighbors;
132
+ if (candidates.length < width)
133
+ candidates = new Int32Array(width);
134
+ if (candidateSq.length < width)
135
+ candidateSq = new Float64Array(width);
136
+ if (lines.length < width * 2 * LINE_STRIDE)
137
+ lines = new Float64Array(width * 2 * LINE_STRIDE);
138
+ if (projection.length < width * 2 * LINE_STRIDE)
139
+ projection = new Float64Array(width * 2 * LINE_STRIDE);
140
+ }
141
+ function growInt32(buffer, needed) {
142
+ if (buffer.length >= needed)
143
+ return buffer;
144
+ let size = buffer.length;
145
+ while (size < needed)
146
+ size *= 2;
147
+ return new Int32Array(size);
148
+ }
149
+ function slotIndexOf(id) {
150
+ return slotOf.get(id);
151
+ }
152
+ function probeLine() {
153
+ return `${slots.length} agents ${tickCount} ticks ${solveCount} solves ${slowestStepMs.toFixed(2)} ms slowest`;
154
+ }
155
+ /** Fill `lines` with this agent's constraints; returns the line count. */
156
+ function buildLines(agentIndex, dt, candidateCount) {
157
+ const agent = slots[agentIndex];
158
+ const invHorizon = 1 / tunables.timeHorizon;
159
+ const invStep = dt > EPSILON ? 1 / dt : 0;
160
+ let count = 0;
161
+ for (let c = 0; c < candidateCount; c += 1) {
162
+ const otherIndex = candidates[c];
163
+ const other = slots[otherIndex];
164
+ const relX = other.x - agent.x;
165
+ const relZ = other.z - agent.z;
166
+ const relVX = agent.vx - other.vx;
167
+ const relVZ = agent.vz - other.vz;
168
+ const distanceSq = relX * relX + relZ * relZ;
169
+ const combined = agent.radius + other.radius;
170
+ const combinedSq = combined * combined;
171
+ let pointX;
172
+ let pointZ;
173
+ let dirX;
174
+ let dirZ;
175
+ if (distanceSq > combinedSq) {
176
+ const wx = relVX - invHorizon * relX;
177
+ const wz = relVZ - invHorizon * relZ;
178
+ const wSq = wx * wx + wz * wz;
179
+ const dot1 = wx * relX + wz * relZ;
180
+ if (dot1 < 0 && dot1 * dot1 > combinedSq * wSq) {
181
+ const wLength = Math.sqrt(wSq);
182
+ const unitX = wLength > EPSILON ? wx / wLength : 1;
183
+ const unitZ = wLength > EPSILON ? wz / wLength : 0;
184
+ dirX = unitZ;
185
+ dirZ = -unitX;
186
+ const scale = combined * invHorizon - wLength;
187
+ pointX = agent.vx + 0.5 * scale * unitX;
188
+ pointZ = agent.vz + 0.5 * scale * unitZ;
189
+ }
190
+ else {
191
+ const leg = Math.sqrt(Math.max(0, distanceSq - combinedSq));
192
+ // A perfectly mirrored pair has no geometric side to pick; taking the canonical branch for
193
+ // both is what makes each agent yield to its own right, so the pair passes instead of stalling.
194
+ const cross = det(relX, relZ, wx, wz);
195
+ if (cross > 0 || Math.abs(cross) <= EPSILON) {
196
+ dirX = (relX * leg - relZ * combined) / distanceSq;
197
+ dirZ = (relX * combined + relZ * leg) / distanceSq;
198
+ }
199
+ else {
200
+ dirX = -(relX * leg + relZ * combined) / distanceSq;
201
+ dirZ = -(-relX * combined + relZ * leg) / distanceSq;
202
+ }
203
+ const dot2 = relVX * dirX + relVZ * dirZ;
204
+ pointX = agent.vx + (dot2 * dirX - relVX) * 0.5;
205
+ pointZ = agent.vz + (dot2 * dirZ - relVZ) * 0.5;
206
+ }
207
+ }
208
+ else {
209
+ const wx = relVX - invStep * relX;
210
+ const wz = relVZ - invStep * relZ;
211
+ const wLength = Math.hypot(wx, wz);
212
+ let unitX = 1;
213
+ let unitZ = 0;
214
+ if (wLength > EPSILON) {
215
+ unitX = wx / wLength;
216
+ unitZ = wz / wLength;
217
+ }
218
+ else if (Math.abs(relX) > EPSILON || Math.abs(relZ) > EPSILON) {
219
+ const length = Math.hypot(relX, relZ);
220
+ unitX = relZ / length;
221
+ unitZ = -relX / length;
222
+ }
223
+ dirX = unitZ;
224
+ dirZ = -unitX;
225
+ const scale = combined * invStep - wLength;
226
+ pointX = agent.vx + 0.5 * scale * unitX;
227
+ pointZ = agent.vz + 0.5 * scale * unitZ;
228
+ }
229
+ lines[count * LINE_STRIDE] = pointX;
230
+ lines[count * LINE_STRIDE + 1] = pointZ;
231
+ lines[count * LINE_STRIDE + 2] = dirX;
232
+ lines[count * LINE_STRIDE + 3] = dirZ;
233
+ count += 1;
234
+ }
235
+ return count;
236
+ }
237
+ /**
238
+ * Preferred direction unreachable: relax the constraints by the smallest common margin, as RVO2's
239
+ * third program does. `lines` stays intact — the projected set lives in `projection`.
240
+ */
241
+ function relaxConstraints(lineCount, radius, optX, optZ) {
242
+ let distance = 0;
243
+ for (let i = 0; i < lineCount; i += 1) {
244
+ const px = lines[i * LINE_STRIDE];
245
+ const pz = lines[i * LINE_STRIDE + 1];
246
+ const dx = lines[i * LINE_STRIDE + 2];
247
+ const dz = lines[i * LINE_STRIDE + 3];
248
+ if (det(dx, dz, px - solution[0], pz - solution[1]) <= distance)
249
+ continue;
250
+ let projected = 0;
251
+ for (let j = 0; j < i; j += 1) {
252
+ const qx = lines[j * LINE_STRIDE];
253
+ const qz = lines[j * LINE_STRIDE + 1];
254
+ const ex = lines[j * LINE_STRIDE + 2];
255
+ const ez = lines[j * LINE_STRIDE + 3];
256
+ const determinant = det(dx, dz, ex, ez);
257
+ let pointX;
258
+ let pointZ;
259
+ if (Math.abs(determinant) <= EPSILON) {
260
+ if (dx * ex + dz * ez > 0)
261
+ continue;
262
+ pointX = 0.5 * (px + qx);
263
+ pointZ = 0.5 * (pz + qz);
264
+ }
265
+ else {
266
+ const along = det(ex, ez, px - qx, pz - qz) / determinant;
267
+ pointX = px + dx * along;
268
+ pointZ = pz + dz * along;
269
+ }
270
+ const rawX = ex - dx;
271
+ const rawZ = ez - dz;
272
+ const length = Math.hypot(rawX, rawZ);
273
+ if (length <= EPSILON)
274
+ continue;
275
+ projection[projected * LINE_STRIDE] = pointX;
276
+ projection[projected * LINE_STRIDE + 1] = pointZ;
277
+ projection[projected * LINE_STRIDE + 2] = rawX / length;
278
+ projection[projected * LINE_STRIDE + 3] = rawZ / length;
279
+ projected += 1;
280
+ }
281
+ const keepX = solution[0];
282
+ const keepZ = solution[1];
283
+ if (linearProgram2(projection, projected, radius, optX, optZ, false, solution) < 0) {
284
+ solution[0] = keepX;
285
+ solution[1] = keepZ;
286
+ }
287
+ distance = det(dx, dz, px - solution[0], pz - solution[1]);
288
+ }
289
+ }
290
+ const unregister = options.devtoolsName === undefined ? null : devtools.probes.register(options.devtoolsName, () => probeLine());
291
+ /**
292
+ * Push apart every pair still overlapping after the velocity solve. The grid built for this tick
293
+ * is reused: one tick moves an agent a small fraction of a cell. Pairs are visited in slot order
294
+ * so the result is reproducible.
295
+ */
296
+ function relieveOverlap(cols, rows, minX, minZ, reach) {
297
+ const reachSq = reach * reach;
298
+ for (let i = 0; i < slots.length; i += 1) {
299
+ const agent = slots[i];
300
+ const col = Math.min(cols - 1, Math.max(0, Math.floor((agent.x - minX) / reach)));
301
+ const row = Math.min(rows - 1, Math.max(0, Math.floor((agent.z - minZ) / reach)));
302
+ for (let dz = -1; dz <= 1; dz += 1) {
303
+ const r = row + dz;
304
+ if (r < 0 || r >= rows)
305
+ continue;
306
+ for (let dx = -1; dx <= 1; dx += 1) {
307
+ const c = col + dx;
308
+ if (c < 0 || c >= cols)
309
+ continue;
310
+ for (let j = cells[r * cols + c]; j >= 0; j = nextInCell[j]) {
311
+ if (j <= i)
312
+ continue;
313
+ const other = slots[j];
314
+ const deltaX = other.x - agent.x;
315
+ const deltaZ = other.z - agent.z;
316
+ const distanceSq = deltaX * deltaX + deltaZ * deltaZ;
317
+ if (distanceSq > reachSq)
318
+ continue;
319
+ const combined = agent.radius + other.radius;
320
+ if (distanceSq >= combined * combined)
321
+ continue;
322
+ const distance = Math.sqrt(distanceSq);
323
+ const normalX = distance > EPSILON ? deltaX / distance : i % 2 === 0 ? 1 : 0;
324
+ const normalZ = distance > EPSILON ? deltaZ / distance : i % 2 === 0 ? 0 : 1;
325
+ const push = 0.5 * (combined - distance) * tunables.overlapRelief;
326
+ agent.x -= normalX * push;
327
+ agent.z -= normalZ * push;
328
+ other.x += normalX * push;
329
+ other.z += normalZ * push;
330
+ }
331
+ }
332
+ }
333
+ }
334
+ }
335
+ return {
336
+ add(id, spec) {
337
+ if (slotOf.has(id))
338
+ return false;
339
+ slots.push({
340
+ id,
341
+ x: spec.x,
342
+ z: spec.z,
343
+ vx: spec.vx ?? 0,
344
+ vz: spec.vz ?? 0,
345
+ radius: Math.max(0, spec.radius),
346
+ maxSpeed: Math.max(0, spec.maxSpeed),
347
+ prefX: 0,
348
+ prefZ: 0,
349
+ });
350
+ slotOf.set(id, slots.length - 1);
351
+ return true;
352
+ },
353
+ remove(id) {
354
+ const at = slotIndexOf(id);
355
+ if (at === undefined)
356
+ return false;
357
+ const last = slots.pop();
358
+ slotOf.delete(id);
359
+ if (at < slots.length) {
360
+ slots[at] = last;
361
+ slotOf.set(last.id, at);
362
+ }
363
+ return true;
364
+ },
365
+ has: (id) => slotOf.has(id),
366
+ count: () => slots.length,
367
+ ids: () => slots.map((agent) => agent.id),
368
+ agent(id) {
369
+ const at = slotIndexOf(id);
370
+ if (at === undefined)
371
+ return null;
372
+ const agent = slots[at];
373
+ return { x: agent.x, z: agent.z, vx: agent.vx, vz: agent.vz, radius: agent.radius, maxSpeed: agent.maxSpeed };
374
+ },
375
+ readInto(id, out) {
376
+ const at = slotIndexOf(id);
377
+ if (at === undefined)
378
+ return false;
379
+ const agent = slots[at];
380
+ out.x = agent.x;
381
+ out.z = agent.z;
382
+ out.vx = agent.vx;
383
+ out.vz = agent.vz;
384
+ return true;
385
+ },
386
+ setPreferredVelocity(id, vx, vz) {
387
+ const at = slotIndexOf(id);
388
+ if (at === undefined)
389
+ return false;
390
+ const agent = slots[at];
391
+ const speed = Math.hypot(vx, vz);
392
+ const scale = speed > agent.maxSpeed && speed > EPSILON ? agent.maxSpeed / speed : 1;
393
+ agent.prefX = (vx * biasKeep + vz * biasTurn) * scale;
394
+ agent.prefZ = (-vx * biasTurn + vz * biasKeep) * scale;
395
+ return true;
396
+ },
397
+ setKinematics(id, position) {
398
+ const at = slotIndexOf(id);
399
+ if (at === undefined)
400
+ return false;
401
+ const agent = slots[at];
402
+ agent.x = position.x;
403
+ agent.z = position.z;
404
+ if (position.vx !== undefined)
405
+ agent.vx = position.vx;
406
+ if (position.vz !== undefined)
407
+ agent.vz = position.vz;
408
+ return true;
409
+ },
410
+ step(dt) {
411
+ if (!(dt > 0) || slots.length === 0)
412
+ return;
413
+ const started = performance.now();
414
+ const reach = tunables.neighborDist;
415
+ let minX = Infinity;
416
+ let minZ = Infinity;
417
+ let maxX = -Infinity;
418
+ let maxZ = -Infinity;
419
+ for (let i = 0; i < slots.length; i += 1) {
420
+ const agent = slots[i];
421
+ if (agent.x < minX)
422
+ minX = agent.x;
423
+ if (agent.x > maxX)
424
+ maxX = agent.x;
425
+ if (agent.z < minZ)
426
+ minZ = agent.z;
427
+ if (agent.z > maxZ)
428
+ maxZ = agent.z;
429
+ }
430
+ const cols = Math.max(1, Math.floor((maxX - minX) / reach) + 1);
431
+ const rows = Math.max(1, Math.floor((maxZ - minZ) / reach) + 1);
432
+ const cellCount = cols * rows;
433
+ cells = growInt32(cells, cellCount);
434
+ nextInCell = growInt32(nextInCell, slots.length);
435
+ if (nextVX.length < slots.length) {
436
+ nextVX = new Float64Array(slots.length);
437
+ nextVZ = new Float64Array(slots.length);
438
+ }
439
+ cells.fill(-1, 0, cellCount);
440
+ const reachSq = reach * reach;
441
+ for (let i = 0; i < slots.length; i += 1) {
442
+ const agent = slots[i];
443
+ const col = Math.min(cols - 1, Math.max(0, Math.floor((agent.x - minX) / reach)));
444
+ const row = Math.min(rows - 1, Math.max(0, Math.floor((agent.z - minZ) / reach)));
445
+ const cell = row * cols + col;
446
+ nextInCell[i] = cells[cell];
447
+ cells[cell] = i;
448
+ }
449
+ for (let i = 0; i < slots.length; i += 1) {
450
+ const agent = slots[i];
451
+ const col = Math.min(cols - 1, Math.max(0, Math.floor((agent.x - minX) / reach)));
452
+ const row = Math.min(rows - 1, Math.max(0, Math.floor((agent.z - minZ) / reach)));
453
+ let found = 0;
454
+ for (let dz = -1; dz <= 1; dz += 1) {
455
+ const r = row + dz;
456
+ if (r < 0 || r >= rows)
457
+ continue;
458
+ for (let dx = -1; dx <= 1; dx += 1) {
459
+ const c = col + dx;
460
+ if (c < 0 || c >= cols)
461
+ continue;
462
+ for (let j = cells[r * cols + c]; j >= 0; j = nextInCell[j]) {
463
+ if (j === i)
464
+ continue;
465
+ const other = slots[j];
466
+ const ddx = other.x - agent.x;
467
+ const ddz = other.z - agent.z;
468
+ const distanceSq = ddx * ddx + ddz * ddz;
469
+ if (distanceSq > reachSq)
470
+ continue;
471
+ // Keep the nearest `maxNeighbors`: skipping a close agent because far ones filled
472
+ // the slots is what lets a dense crossing stream walk through itself.
473
+ let at = found < tunables.maxNeighbors ? found : tunables.maxNeighbors - 1;
474
+ if (found === tunables.maxNeighbors && distanceSq >= candidateSq[at])
475
+ continue;
476
+ while (at > 0 && candidateSq[at - 1] > distanceSq) {
477
+ candidates[at] = candidates[at - 1];
478
+ candidateSq[at] = candidateSq[at - 1];
479
+ at -= 1;
480
+ }
481
+ candidates[at] = j;
482
+ candidateSq[at] = distanceSq;
483
+ if (found < tunables.maxNeighbors)
484
+ found += 1;
485
+ }
486
+ }
487
+ }
488
+ const lineCount = buildLines(i, dt, found);
489
+ const solved = linearProgram2(lines, lineCount, agent.maxSpeed, agent.prefX, agent.prefZ, false, solution);
490
+ if (solved < lineCount) {
491
+ const keepX = solution[0];
492
+ const keepZ = solution[1];
493
+ relaxConstraints(lineCount, agent.maxSpeed, agent.prefX, agent.prefZ);
494
+ if (!Number.isFinite(solution[0]) || !Number.isFinite(solution[1])) {
495
+ solution[0] = keepX;
496
+ solution[1] = keepZ;
497
+ }
498
+ }
499
+ if (!Number.isFinite(solution[0]) || !Number.isFinite(solution[1])) {
500
+ solution[0] = agent.prefX;
501
+ solution[1] = agent.prefZ;
502
+ }
503
+ const solvedSpeed = Math.hypot(solution[0], solution[1]);
504
+ const preferredSpeed = Math.hypot(agent.prefX, agent.prefZ);
505
+ if (preferredSpeed > EPSILON && solvedSpeed < 0.25 * preferredSpeed) {
506
+ // A tight cluster can leave the constraints with no way forward. Creeping at a fraction of
507
+ // the asked speed keeps a jam resolving instead of freezing every agent in it.
508
+ solution[0] = agent.prefX * 0.35;
509
+ solution[1] = agent.prefZ * 0.35;
510
+ }
511
+ nextVX[i] = solution[0];
512
+ nextVZ[i] = solution[1];
513
+ }
514
+ for (let i = 0; i < slots.length; i += 1) {
515
+ const agent = slots[i];
516
+ agent.vx = nextVX[i];
517
+ agent.vz = nextVZ[i];
518
+ agent.x += agent.vx * dt;
519
+ agent.z += agent.vz * dt;
520
+ agent.prefX = 0;
521
+ agent.prefZ = 0;
522
+ }
523
+ if (tunables.overlapRelief > 0)
524
+ relieveOverlap(cols, rows, minX, minZ, reach);
525
+ const elapsed = performance.now() - started;
526
+ slowestStepMs = Math.max(slowestStepMs, elapsed);
527
+ tickCount += 1;
528
+ solveCount += slots.length;
529
+ },
530
+ retune(patch) {
531
+ if (patch.timeHorizon !== undefined)
532
+ tunables.timeHorizon = Math.max(EPSILON, patch.timeHorizon);
533
+ if (patch.neighborDist !== undefined)
534
+ tunables.neighborDist = Math.max(EPSILON, patch.neighborDist);
535
+ if (patch.maxNeighbors !== undefined)
536
+ tunables.maxNeighbors = Math.max(1, Math.floor(patch.maxNeighbors));
537
+ if (patch.sideBias !== undefined && Number.isFinite(patch.sideBias)) {
538
+ tunables.sideBias = patch.sideBias;
539
+ refreshBias();
540
+ }
541
+ if (patch.overlapRelief !== undefined && Number.isFinite(patch.overlapRelief)) {
542
+ tunables.overlapRelief = Math.max(0, Math.min(1, patch.overlapRelief));
543
+ }
544
+ resizeBuffers();
545
+ },
546
+ snapshot() {
547
+ return {
548
+ tunables: { ...tunables },
549
+ agents: slots.map((agent) => ({
550
+ id: agent.id,
551
+ x: agent.x,
552
+ z: agent.z,
553
+ vx: agent.vx,
554
+ vz: agent.vz,
555
+ radius: agent.radius,
556
+ maxSpeed: agent.maxSpeed,
557
+ })),
558
+ };
559
+ },
560
+ restore(next) {
561
+ slots.length = 0;
562
+ slotOf.clear();
563
+ tunables = {
564
+ timeHorizon: Math.max(EPSILON, next.tunables.timeHorizon),
565
+ neighborDist: Math.max(EPSILON, next.tunables.neighborDist),
566
+ maxNeighbors: Math.max(1, Math.floor(next.tunables.maxNeighbors)),
567
+ sideBias: Number.isFinite(next.tunables.sideBias) ? next.tunables.sideBias : DEFAULT_TUNABLES.sideBias,
568
+ overlapRelief: Number.isFinite(next.tunables.overlapRelief) ? next.tunables.overlapRelief : DEFAULT_TUNABLES.overlapRelief,
569
+ };
570
+ resizeBuffers();
571
+ refreshBias();
572
+ for (const saved of next.agents) {
573
+ slots.push({ ...saved, prefX: 0, prefZ: 0 });
574
+ slotOf.set(saved.id, slots.length - 1);
575
+ }
576
+ },
577
+ dispose() {
578
+ unregister?.();
579
+ },
580
+ };
581
+ }