@jgengine/core 0.7.0 → 0.8.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 (159) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +2 -0
  3. package/dist/ai/spawnDirector.d.ts +5 -0
  4. package/dist/ai/spawnDirector.js +13 -1
  5. package/dist/anim/easing.d.ts +14 -0
  6. package/dist/anim/easing.js +55 -0
  7. package/dist/behaviour/behaviour.d.ts +136 -0
  8. package/dist/behaviour/behaviour.js +388 -0
  9. package/dist/cards/cardPile.d.ts +1 -0
  10. package/dist/cards/cardPile.js +12 -3
  11. package/dist/combat/abilityKit.d.ts +5 -0
  12. package/dist/combat/abilityKit.js +10 -0
  13. package/dist/combat/effects.d.ts +1 -1
  14. package/dist/combat/effects.js +9 -6
  15. package/dist/combat/projectiles.d.ts +42 -5
  16. package/dist/combat/projectiles.js +98 -12
  17. package/dist/combat/telegraph.d.ts +5 -1
  18. package/dist/combat/telegraph.js +14 -0
  19. package/dist/commands/commandRegistry.d.ts +1 -1
  20. package/dist/commands/commandRegistry.js +2 -1
  21. package/dist/data/dataSource.d.ts +26 -0
  22. package/dist/data/dataSource.js +93 -0
  23. package/dist/data/devProxy.d.ts +11 -0
  24. package/dist/data/devProxy.js +38 -0
  25. package/dist/data/fetchJson.d.ts +20 -0
  26. package/dist/data/fetchJson.js +43 -0
  27. package/dist/data/jsonDataSource.d.ts +4 -0
  28. package/dist/data/jsonDataSource.js +6 -0
  29. package/dist/devtools/devtools.d.ts +157 -0
  30. package/dist/devtools/devtools.js +484 -0
  31. package/dist/devtools/transformTunables.d.ts +14 -0
  32. package/dist/devtools/transformTunables.js +38 -0
  33. package/dist/game/chat.d.ts +66 -0
  34. package/dist/game/chat.js +165 -0
  35. package/dist/game/defineGame.d.ts +8 -2
  36. package/dist/game/defineGame.js +2 -1
  37. package/dist/game/events.d.ts +25 -0
  38. package/dist/game/levelSequence.d.ts +44 -0
  39. package/dist/game/levelSequence.js +78 -0
  40. package/dist/game/playableGame.d.ts +237 -6
  41. package/dist/game/playableGame.js +6 -1
  42. package/dist/game/progression.d.ts +1 -0
  43. package/dist/game/progression.js +23 -2
  44. package/dist/game/race.d.ts +5 -0
  45. package/dist/game/race.js +20 -0
  46. package/dist/game/social.d.ts +42 -0
  47. package/dist/game/social.js +122 -0
  48. package/dist/game/spawnPoints.d.ts +19 -0
  49. package/dist/game/spawnPoints.js +23 -0
  50. package/dist/game/worldItem.d.ts +3 -0
  51. package/dist/game/worldItem.js +12 -6
  52. package/dist/input/actionBindings.d.ts +10 -0
  53. package/dist/input/actionBindings.js +14 -0
  54. package/dist/input/gestureSurface.d.ts +31 -0
  55. package/dist/input/gestureSurface.js +120 -0
  56. package/dist/input/pointer.d.ts +45 -0
  57. package/dist/input/pointer.js +48 -0
  58. package/dist/input/touchScheme.d.ts +83 -0
  59. package/dist/input/touchScheme.js +115 -0
  60. package/dist/item/affix.d.ts +1 -1
  61. package/dist/item/affix.js +1 -19
  62. package/dist/meta/changelog.d.ts +1 -1
  63. package/dist/meta/changelog.js +37 -1
  64. package/dist/movement/movementModel.d.ts +36 -3
  65. package/dist/movement/movementModel.js +81 -5
  66. package/dist/movement/voxelController.d.ts +61 -0
  67. package/dist/movement/voxelController.js +229 -0
  68. package/dist/multiplayer/chatContract.d.ts +42 -0
  69. package/dist/multiplayer/chatContract.js +57 -0
  70. package/dist/multiplayer/identity.d.ts +13 -0
  71. package/dist/multiplayer/identity.js +20 -0
  72. package/dist/multiplayer/poseSyncGate.d.ts +1 -0
  73. package/dist/multiplayer/poseSyncGate.js +13 -1
  74. package/dist/multiplayer/presenceModel.d.ts +3 -0
  75. package/dist/multiplayer/presenceModel.js +15 -1
  76. package/dist/multiplayer/voiceContract.d.ts +44 -0
  77. package/dist/multiplayer/voiceContract.js +108 -0
  78. package/dist/nav/navConstrain.d.ts +14 -0
  79. package/dist/nav/navConstrain.js +26 -0
  80. package/dist/nav/navFromEnvironment.d.ts +11 -0
  81. package/dist/nav/navFromEnvironment.js +16 -0
  82. package/dist/nav/navGrid.d.ts +10 -0
  83. package/dist/nav/navGrid.js +18 -1
  84. package/dist/nav/pathFollow.d.ts +4 -2
  85. package/dist/nav/pathFollow.js +5 -3
  86. package/dist/physics/ballisticSweep.d.ts +18 -0
  87. package/dist/physics/ballisticSweep.js +49 -0
  88. package/dist/physics/forceVolume.js +5 -5
  89. package/dist/physics/physicsWorld.d.ts +41 -2
  90. package/dist/physics/physicsWorld.js +232 -40
  91. package/dist/physics/spatialGrid.d.ts +1 -1
  92. package/dist/physics/spatialGrid.js +5 -0
  93. package/dist/physics/vehicleBody.d.ts +6 -0
  94. package/dist/physics/vehicleBody.js +16 -17
  95. package/dist/puzzle/cellGrid.d.ts +26 -0
  96. package/dist/puzzle/cellGrid.js +110 -0
  97. package/dist/puzzle/fallingPiece.d.ts +24 -0
  98. package/dist/puzzle/fallingPiece.js +51 -0
  99. package/dist/random/nameGen.d.ts +18 -0
  100. package/dist/random/nameGen.js +35 -0
  101. package/dist/random/rng.d.ts +2 -0
  102. package/dist/random/rng.js +23 -0
  103. package/dist/runtime/adapter.d.ts +35 -0
  104. package/dist/runtime/adapter.js +37 -1
  105. package/dist/runtime/cameraDirector.d.ts +10 -0
  106. package/dist/runtime/cameraDirector.js +25 -0
  107. package/dist/runtime/gameContext.d.ts +44 -2
  108. package/dist/runtime/gameContext.js +100 -4
  109. package/dist/runtime/hostPersistence.d.ts +21 -0
  110. package/dist/runtime/hostPersistence.js +50 -0
  111. package/dist/runtime/inputSnapshot.d.ts +7 -0
  112. package/dist/runtime/inputSnapshot.js +12 -0
  113. package/dist/runtime/motionIntents.d.ts +25 -0
  114. package/dist/runtime/motionIntents.js +25 -0
  115. package/dist/runtime/transport.d.ts +32 -0
  116. package/dist/scene/entityStore.d.ts +8 -0
  117. package/dist/scene/entityStore.js +25 -2
  118. package/dist/scene/movementSpeed.d.ts +35 -0
  119. package/dist/scene/movementSpeed.js +20 -0
  120. package/dist/scene/objectQuery.d.ts +18 -0
  121. package/dist/scene/objectQuery.js +108 -0
  122. package/dist/scene/objectStore.d.ts +11 -0
  123. package/dist/scene/objectStore.js +109 -4
  124. package/dist/scene/paintLayer.d.ts +15 -0
  125. package/dist/scene/paintLayer.js +53 -0
  126. package/dist/scene/spatial.d.ts +21 -0
  127. package/dist/scene/spatial.js +63 -1
  128. package/dist/sensor/concealment.d.ts +21 -0
  129. package/dist/sensor/concealment.js +63 -0
  130. package/dist/sensor/freezeMonitor.d.ts +17 -0
  131. package/dist/sensor/freezeMonitor.js +35 -0
  132. package/dist/session/roles.d.ts +6 -0
  133. package/dist/session/roles.js +32 -0
  134. package/dist/session/roundState.d.ts +41 -17
  135. package/dist/session/roundState.js +67 -38
  136. package/dist/tactics/fallingGrid.d.ts +62 -0
  137. package/dist/tactics/fallingGrid.js +158 -0
  138. package/dist/tactics/tacticalGrid.d.ts +11 -0
  139. package/dist/tactics/tacticalGrid.js +17 -0
  140. package/dist/time/simClock.d.ts +25 -1
  141. package/dist/time/simClock.js +28 -3
  142. package/dist/turn/intent.d.ts +14 -0
  143. package/dist/turn/intent.js +29 -0
  144. package/dist/turn/turnLoop.d.ts +2 -0
  145. package/dist/turn/turnLoop.js +8 -1
  146. package/dist/world/environmentSummary.d.ts +53 -0
  147. package/dist/world/environmentSummary.js +129 -0
  148. package/dist/world/features.d.ts +87 -6
  149. package/dist/world/features.js +52 -2
  150. package/dist/world/gridInstances.d.ts +13 -0
  151. package/dist/world/gridInstances.js +22 -0
  152. package/dist/world/placementController.d.ts +22 -1
  153. package/dist/world/placementController.js +56 -0
  154. package/dist/world/terrain.d.ts +23 -1
  155. package/dist/world/terrain.js +64 -1
  156. package/dist/world/voxelField.d.ts +54 -0
  157. package/dist/world/voxelField.js +241 -0
  158. package/llms.txt +4102 -0
  159. package/package.json +3 -2
@@ -1,5 +1,10 @@
1
+ export const SHAPE_BOX = 0;
2
+ export const SHAPE_SPHERE = 1;
1
3
  const FLAG_SLEEPING = 1;
2
4
  const FLAG_STATIC = 2;
5
+ const FLAG_DEAD = 4;
6
+ /** Cap on `nx*ny*nz` broadphase cells — guards a huge-bounds/tiny-cellSize config from hanging `step()`. */
7
+ export const MAX_BROADPHASE_CELLS = 1_000_000;
3
8
  /** Grid cell containing a point, clamped into the grid. Pure — exported for tests. */
4
9
  export function cellCoord(value, min, cellSize, cells) {
5
10
  const raw = Math.floor((value - min) / cellSize);
@@ -39,6 +44,8 @@ export class PhysicsWorld {
39
44
  halfZ;
40
45
  invMass;
41
46
  flags;
47
+ /** `SHAPE_BOX` (0) or `SHAPE_SPHERE` (1) per body; spheres keep their radius in halfX/halfY/halfZ. */
48
+ shape;
42
49
  /** 1 for a body that took part in a contact this frame; reset each `step()`. Render tint reads this. */
43
50
  contact;
44
51
  sleepTimer;
@@ -76,6 +83,12 @@ export class PhysicsWorld {
76
83
  jFreeCount = 0;
77
84
  jointHigh = 0;
78
85
  jointActiveCount = 0;
86
+ // Removed bodies tombstone (FLAG_DEAD) rather than compact: `id` is the SoA slot itself, handed
87
+ // out to callers and stored raw in joints/game state, so a slot can never move once allocated.
88
+ // `bodyHigh` is the high-water mark of ever-used slots; `bodyFree` recycles dead ones for addBody.
89
+ bodyFree;
90
+ bodyFreeCount = 0;
91
+ bodyHigh = 0;
79
92
  collisionListener = null;
80
93
  collisionMinSpeed = 0;
81
94
  collisionEvent = {
@@ -131,6 +144,7 @@ export class PhysicsWorld {
131
144
  this.halfZ = new Float32Array(cap);
132
145
  this.invMass = new Float32Array(cap);
133
146
  this.flags = new Uint8Array(cap);
147
+ this.shape = new Uint8Array(cap);
134
148
  this.contact = new Uint8Array(cap);
135
149
  this.sleepTimer = new Uint16Array(cap);
136
150
  this.prevX = new Float32Array(cap);
@@ -141,12 +155,18 @@ export class PhysicsWorld {
141
155
  this.ny = Math.max(1, Math.ceil(span(1) / this.cellSize));
142
156
  this.nz = Math.max(1, Math.ceil(span(2) / this.cellSize));
143
157
  this.numCells = this.nx * this.ny * this.nz;
158
+ if (this.numCells > MAX_BROADPHASE_CELLS) {
159
+ throw new Error(`PhysicsWorld: broadphase grid too large (${this.nx}x${this.ny}x${this.nz} = ${this.numCells} cells, ` +
160
+ `cap ${MAX_BROADPHASE_CELLS}). bounds=[${config.bounds.min}]..[${config.bounds.max}] cellSize=${this.cellSize}; ` +
161
+ `raise cellSize or shrink bounds.`);
162
+ }
144
163
  this.cellStart = new Int32Array(this.numCells + 1);
145
164
  this.cursor = new Int32Array(this.numCells);
146
165
  this.sorted = new Int32Array(cap);
147
166
  this.bodyCell = new Int32Array(cap);
148
167
  this.awakeList = new Int32Array(cap);
149
168
  this.awakeSlot = new Int32Array(cap).fill(-1);
169
+ this.bodyFree = new Int32Array(cap);
150
170
  const jc = this.jointCapacity;
151
171
  this.jSpring = new Uint8Array(jc);
152
172
  this.jActive = new Uint8Array(jc);
@@ -184,6 +204,10 @@ export class PhysicsWorld {
184
204
  get count() {
185
205
  return this.bodyCount;
186
206
  }
207
+ /** One past the highest slot `addBody` has ever handed out — the range `removeBody` leaves holes in. */
208
+ get highWater() {
209
+ return this.bodyHigh;
210
+ }
187
211
  get cells() {
188
212
  return { nx: this.nx, ny: this.ny, nz: this.nz, total: this.numCells };
189
213
  }
@@ -254,6 +278,12 @@ export class PhysicsWorld {
254
278
  this.jAnchorBY[id] = y;
255
279
  this.jAnchorBZ[id] = z;
256
280
  }
281
+ /** Move a joint's local anchor offset on A (world axes, no rotation applied by the solver). */
282
+ setJointAnchorA(id, x, y, z) {
283
+ this.jAnchorAX[id] = x;
284
+ this.jAnchorAY[id] = y;
285
+ this.jAnchorAZ[id] = z;
286
+ }
257
287
  setJointRest(id, restLength) {
258
288
  this.jRest[id] = restLength;
259
289
  }
@@ -291,9 +321,16 @@ export class PhysicsWorld {
291
321
  return n;
292
322
  }
293
323
  addBody(options) {
294
- if (this.bodyCount >= this.capacity)
295
- throw new Error("PhysicsWorld: capacity exceeded");
296
- const i = this.bodyCount++;
324
+ let i;
325
+ if (this.bodyFreeCount > 0) {
326
+ i = this.bodyFree[--this.bodyFreeCount];
327
+ }
328
+ else {
329
+ if (this.bodyHigh >= this.capacity)
330
+ throw new Error("PhysicsWorld: capacity exceeded");
331
+ i = this.bodyHigh++;
332
+ }
333
+ this.bodyCount += 1;
297
334
  this.posX[i] = options.position[0];
298
335
  this.posY[i] = options.position[1];
299
336
  this.posZ[i] = options.position[2];
@@ -301,9 +338,18 @@ export class PhysicsWorld {
301
338
  this.velX[i] = v?.[0] ?? 0;
302
339
  this.velY[i] = v?.[1] ?? 0;
303
340
  this.velZ[i] = v?.[2] ?? 0;
304
- this.halfX[i] = options.halfExtents[0];
305
- this.halfY[i] = options.halfExtents[1];
306
- this.halfZ[i] = options.halfExtents[2];
341
+ if (options.shape === "sphere") {
342
+ this.halfX[i] = options.radius;
343
+ this.halfY[i] = options.radius;
344
+ this.halfZ[i] = options.radius;
345
+ this.shape[i] = SHAPE_SPHERE;
346
+ }
347
+ else {
348
+ this.halfX[i] = options.halfExtents[0];
349
+ this.halfY[i] = options.halfExtents[1];
350
+ this.halfZ[i] = options.halfExtents[2];
351
+ this.shape[i] = SHAPE_BOX;
352
+ }
307
353
  const isStatic = options.static === true;
308
354
  const mass = options.mass ?? 1;
309
355
  this.invMass[i] = isStatic || mass <= 0 ? 0 : 1 / mass;
@@ -314,17 +360,69 @@ export class PhysicsWorld {
314
360
  f |= FLAG_SLEEPING;
315
361
  this.flags[i] = f;
316
362
  this.sleepTimer[i] = 0;
363
+ this.contact[i] = 0;
317
364
  this.awakeSlot[i] = -1;
318
365
  this.bodyCell[i] = this.cellOf(i);
319
366
  if ((f & FLAG_SLEEPING) === 0)
320
367
  this.addAwake(i);
321
368
  return i;
322
369
  }
370
+ /**
371
+ * Remove a body: it drops out of integration, the solver, and the broadphase, and its slot is
372
+ * queued for the next `addBody` to reuse. `id` and every other body's `id` stay valid — a dead
373
+ * slot is tombstoned (`FLAG_DEAD`), never swapped, since ids are handed out raw and stored as-is
374
+ * in joints and game state. There is no persistent contact set to consult, so — conservatively —
375
+ * any sleeping body whose AABB touches the removed body's (padded by `slop`) is woken; a body
376
+ * that was resting on it always qualifies.
377
+ */
378
+ removeBody(id) {
379
+ if (id < 0 || id >= this.bodyHigh)
380
+ return;
381
+ if ((this.flags[id] & FLAG_DEAD) !== 0)
382
+ return;
383
+ this.wakeTouching(id);
384
+ this.removeAwake(id);
385
+ this.flags[id] = FLAG_DEAD;
386
+ this.invMass[id] = 0;
387
+ this.velX[id] = 0;
388
+ this.velY[id] = 0;
389
+ this.velZ[id] = 0;
390
+ this.sleepTimer[id] = 0;
391
+ this.contact[id] = 0;
392
+ this.bodyCount -= 1;
393
+ this.bodyFree[this.bodyFreeCount++] = id;
394
+ }
395
+ wakeTouching(id) {
396
+ const pad = this.slop + 0.01;
397
+ const minX = this.posX[id] - this.halfX[id] - pad;
398
+ const maxX = this.posX[id] + this.halfX[id] + pad;
399
+ const minY = this.posY[id] - this.halfY[id] - pad;
400
+ const maxY = this.posY[id] + this.halfY[id] + pad;
401
+ const minZ = this.posZ[id] - this.halfZ[id] - pad;
402
+ const maxZ = this.posZ[id] + this.halfZ[id] + pad;
403
+ for (let j = 0; j < this.bodyHigh; j += 1) {
404
+ if (j === id)
405
+ continue;
406
+ const fj = this.flags[j];
407
+ if ((fj & (FLAG_DEAD | FLAG_STATIC)) !== 0 || (fj & FLAG_SLEEPING) === 0)
408
+ continue;
409
+ if (this.posX[j] - this.halfX[j] > maxX || this.posX[j] + this.halfX[j] < minX)
410
+ continue;
411
+ if (this.posY[j] - this.halfY[j] > maxY || this.posY[j] + this.halfY[j] < minY)
412
+ continue;
413
+ if (this.posZ[j] - this.halfZ[j] > maxZ || this.posZ[j] + this.halfZ[j] < minZ)
414
+ continue;
415
+ this.wake(j);
416
+ }
417
+ }
323
418
  isSleeping(i) {
324
419
  return (this.flags[i] & FLAG_SLEEPING) !== 0;
325
420
  }
421
+ isAlive(i) {
422
+ return (this.flags[i] & FLAG_DEAD) === 0;
423
+ }
326
424
  wake(i) {
327
- if ((this.flags[i] & FLAG_STATIC) !== 0)
425
+ if ((this.flags[i] & (FLAG_STATIC | FLAG_DEAD)) !== 0)
328
426
  return;
329
427
  this.flags[i] &= ~FLAG_SLEEPING;
330
428
  this.sleepTimer[i] = 0;
@@ -333,9 +431,40 @@ export class PhysicsWorld {
333
431
  this.prevZ[i] = this.posZ[i];
334
432
  this.addAwake(i);
335
433
  }
434
+ /** Overwrite a body's velocity directly, waking it if asleep. */
435
+ setVelocity(id, x, y, z) {
436
+ if ((this.flags[id] & FLAG_DEAD) !== 0)
437
+ return;
438
+ this.velX[id] = x;
439
+ this.velY[id] = y;
440
+ this.velZ[id] = z;
441
+ this.wake(id);
442
+ }
443
+ /** Move a body, keeping its current velocity, and wake it if asleep. */
444
+ setPosition(id, x, y, z) {
445
+ if ((this.flags[id] & FLAG_DEAD) !== 0)
446
+ return;
447
+ this.posX[id] = x;
448
+ this.posY[id] = y;
449
+ this.posZ[id] = z;
450
+ this.prevX[id] = x;
451
+ this.prevY[id] = y;
452
+ this.prevZ[id] = z;
453
+ this.bodyCell[id] = this.cellOf(id);
454
+ this.wake(id);
455
+ }
456
+ /** Move a body and zero its velocity — a hard reset (respawn, teleporter) instead of a slide. */
457
+ teleport(id, x, y, z) {
458
+ this.setPosition(id, x, y, z);
459
+ if ((this.flags[id] & FLAG_DEAD) !== 0)
460
+ return;
461
+ this.velX[id] = 0;
462
+ this.velY[id] = 0;
463
+ this.velZ[id] = 0;
464
+ }
336
465
  wakeAll() {
337
- for (let i = 0; i < this.bodyCount; i += 1) {
338
- if ((this.flags[i] & FLAG_STATIC) === 0) {
466
+ for (let i = 0; i < this.bodyHigh; i += 1) {
467
+ if ((this.flags[i] & (FLAG_STATIC | FLAG_DEAD)) === 0) {
339
468
  this.flags[i] &= ~FLAG_SLEEPING;
340
469
  this.sleepTimer[i] = 0;
341
470
  this.addAwake(i);
@@ -344,6 +473,8 @@ export class PhysicsWorld {
344
473
  }
345
474
  clear() {
346
475
  this.bodyCount = 0;
476
+ this.bodyHigh = 0;
477
+ this.bodyFreeCount = 0;
347
478
  this.accumulator = 0;
348
479
  this.awakeCount = 0;
349
480
  this.jointHigh = 0;
@@ -360,7 +491,7 @@ export class PhysicsWorld {
360
491
  let substeps = 0;
361
492
  this.stats.contacts = 0;
362
493
  this.stats.pairs = 0;
363
- this.contact.fill(0, 0, this.bodyCount);
494
+ this.contact.fill(0, 0, this.bodyHigh);
364
495
  while (this.accumulator >= this.fixedDt) {
365
496
  this.substep(this.fixedDt);
366
497
  this.accumulator -= this.fixedDt;
@@ -453,7 +584,7 @@ export class PhysicsWorld {
453
584
  }
454
585
  }
455
586
  buildGrid() {
456
- const n = this.bodyCount;
587
+ const n = this.bodyHigh;
457
588
  const start = this.cellStart;
458
589
  start.fill(0);
459
590
  // Sleeping bodies never move, so their cell index is cached from addBody / their last awake
@@ -463,13 +594,19 @@ export class PhysicsWorld {
463
594
  const i = this.awakeList[a];
464
595
  this.bodyCell[i] = this.cellOf(i);
465
596
  }
466
- for (let i = 0; i < n; i += 1)
597
+ // Dead slots (tombstoned by removeBody) sit inside [0, bodyHigh) but never enter the grid.
598
+ for (let i = 0; i < n; i += 1) {
599
+ if ((this.flags[i] & FLAG_DEAD) !== 0)
600
+ continue;
467
601
  start[this.bodyCell[i] + 1] += 1;
602
+ }
468
603
  for (let c = 0; c < this.numCells; c += 1) {
469
604
  start[c + 1] += start[c];
470
605
  this.cursor[c] = start[c];
471
606
  }
472
607
  for (let i = 0; i < n; i += 1) {
608
+ if ((this.flags[i] & FLAG_DEAD) !== 0)
609
+ continue;
473
610
  const c = this.bodyCell[i];
474
611
  this.sorted[this.cursor[c]++] = i;
475
612
  }
@@ -520,39 +657,94 @@ export class PhysicsWorld {
520
657
  }
521
658
  }
522
659
  resolve(i, j, restitutionPass, countPass) {
523
- const dx = this.posX[j] - this.posX[i];
524
- const px = this.halfX[i] + this.halfX[j] - Math.abs(dx);
525
- if (px <= 0)
526
- return;
527
- const dy = this.posY[j] - this.posY[i];
528
- const py = this.halfY[i] + this.halfY[j] - Math.abs(dy);
529
- if (py <= 0)
530
- return;
531
- const dz = this.posZ[j] - this.posZ[i];
532
- const pz = this.halfZ[i] + this.halfZ[j] - Math.abs(dz);
533
- if (pz <= 0)
534
- return;
535
- if (countPass) {
536
- this.stats.pairs += 1;
537
- this.stats.contacts += 1;
538
- this.contact[i] = 1;
539
- this.contact[j] = 1;
540
- }
541
660
  let nX = 0;
542
661
  let nY = 0;
543
662
  let nZ = 0;
544
- let pen;
545
- if (px <= py && px <= pz) {
546
- nX = dx < 0 ? -1 : 1;
547
- pen = px;
663
+ let pen = 0;
664
+ const sphereI = this.shape[i] === SHAPE_SPHERE;
665
+ const sphereJ = this.shape[j] === SHAPE_SPHERE;
666
+ let needsAxisNormal = true;
667
+ if (sphereI && sphereJ) {
668
+ const dx = this.posX[j] - this.posX[i];
669
+ const dy = this.posY[j] - this.posY[i];
670
+ const dz = this.posZ[j] - this.posZ[i];
671
+ const radiusSum = this.halfX[i] + this.halfX[j];
672
+ const dist2 = dx * dx + dy * dy + dz * dz;
673
+ if (dist2 >= radiusSum * radiusSum)
674
+ return;
675
+ const dist = Math.sqrt(dist2);
676
+ if (dist > 1e-9) {
677
+ nX = dx / dist;
678
+ nY = dy / dist;
679
+ nZ = dz / dist;
680
+ pen = radiusSum - dist;
681
+ }
682
+ else {
683
+ nY = 1;
684
+ pen = radiusSum;
685
+ }
686
+ needsAxisNormal = false;
548
687
  }
549
- else if (py <= pz) {
550
- nY = dy < 0 ? -1 : 1;
551
- pen = py;
688
+ else if (sphereI !== sphereJ) {
689
+ const box = sphereI ? j : i;
690
+ const sph = sphereI ? i : j;
691
+ const loX = this.posX[box] - this.halfX[box];
692
+ const hiX = this.posX[box] + this.halfX[box];
693
+ const loY = this.posY[box] - this.halfY[box];
694
+ const hiY = this.posY[box] + this.halfY[box];
695
+ const loZ = this.posZ[box] - this.halfZ[box];
696
+ const hiZ = this.posZ[box] + this.halfZ[box];
697
+ const sx = this.posX[sph];
698
+ const sy = this.posY[sph];
699
+ const sz = this.posZ[sph];
700
+ const dx = sx - (sx < loX ? loX : sx > hiX ? hiX : sx);
701
+ const dy = sy - (sy < loY ? loY : sy > hiY ? hiY : sy);
702
+ const dz = sz - (sz < loZ ? loZ : sz > hiZ ? hiZ : sz);
703
+ const dist2 = dx * dx + dy * dy + dz * dz;
704
+ if (dist2 > 1e-12) {
705
+ const radius = this.halfX[sph];
706
+ if (dist2 >= radius * radius)
707
+ return;
708
+ const dist = Math.sqrt(dist2);
709
+ const towardSphere = sph === j ? 1 / dist : -1 / dist;
710
+ nX = dx * towardSphere;
711
+ nY = dy * towardSphere;
712
+ nZ = dz * towardSphere;
713
+ pen = radius - dist;
714
+ needsAxisNormal = false;
715
+ }
552
716
  }
553
- else {
554
- nZ = dz < 0 ? -1 : 1;
555
- pen = pz;
717
+ if (needsAxisNormal) {
718
+ const dx = this.posX[j] - this.posX[i];
719
+ const px = this.halfX[i] + this.halfX[j] - Math.abs(dx);
720
+ if (px <= 0)
721
+ return;
722
+ const dy = this.posY[j] - this.posY[i];
723
+ const py = this.halfY[i] + this.halfY[j] - Math.abs(dy);
724
+ if (py <= 0)
725
+ return;
726
+ const dz = this.posZ[j] - this.posZ[i];
727
+ const pz = this.halfZ[i] + this.halfZ[j] - Math.abs(dz);
728
+ if (pz <= 0)
729
+ return;
730
+ if (px <= py && px <= pz) {
731
+ nX = dx < 0 ? -1 : 1;
732
+ pen = px;
733
+ }
734
+ else if (py <= pz) {
735
+ nY = dy < 0 ? -1 : 1;
736
+ pen = py;
737
+ }
738
+ else {
739
+ nZ = dz < 0 ? -1 : 1;
740
+ pen = pz;
741
+ }
742
+ }
743
+ if (countPass) {
744
+ this.stats.pairs += 1;
745
+ this.stats.contacts += 1;
746
+ this.contact[i] = 1;
747
+ this.contact[j] = 1;
556
748
  }
557
749
  const rvx = this.velX[j] - this.velX[i];
558
750
  const rvy = this.velY[j] - this.velY[i];
@@ -1,4 +1,4 @@
1
- import type { PhysicsBounds } from "./physicsWorld.js";
1
+ import { type PhysicsBounds } from "./physicsWorld.js";
2
2
  export interface SpatialGridConfig {
3
3
  /** Horizontal extent to index; only x/z of the bounds are used (a top-down plane). */
4
4
  bounds: PhysicsBounds;
@@ -1,3 +1,4 @@
1
+ import { MAX_BROADPHASE_CELLS } from "./physicsWorld.js";
1
2
  /**
2
3
  * A uniform-grid broad-phase over the x/z plane, separate from the rigid-body sim, for cheap
3
4
  * same-tick proximity across hundreds–thousands of simple movers (swarm enemies). Rebuild each
@@ -30,6 +31,10 @@ export class SpatialGrid {
30
31
  this.nx = Math.max(1, Math.ceil(spanX / config.cellSize));
31
32
  this.nz = Math.max(1, Math.ceil(spanZ / config.cellSize));
32
33
  this.numCells = this.nx * this.nz;
34
+ if (this.numCells > MAX_BROADPHASE_CELLS) {
35
+ throw new Error(`SpatialGrid: grid too large (${this.nx}x${this.nz} = ${this.numCells} cells, cap ${MAX_BROADPHASE_CELLS}). ` +
36
+ `bounds=[${config.bounds.min}]..[${config.bounds.max}] cellSize=${config.cellSize}; raise cellSize or shrink bounds.`);
37
+ }
33
38
  this.cellStart = new Int32Array(this.numCells + 1);
34
39
  this.cursor = new Int32Array(this.numCells);
35
40
  this.sorted = new Int32Array(config.capacity);
@@ -77,6 +77,12 @@ export declare class VehicleBody {
77
77
  private readonly surfaceFriction;
78
78
  private groundedCount;
79
79
  constructor(world: PhysicsWorld, config: VehicleBodyConfig);
80
+ /**
81
+ * A wheel's local offset rotated by the current heading — the ground-side mount and the
82
+ * chassis-side spring anchor both derive from this, so the suspension axis stays vertical (not a
83
+ * mix of rotated and unrotated ends) as the chassis turns.
84
+ */
85
+ private rotateOffset;
80
86
  private wheelMount;
81
87
  get position(): [number, number, number];
82
88
  get forward(): [number, number];
@@ -101,7 +101,7 @@ export class VehicleBody {
101
101
  const rest = wheel.restLength ?? this.suspensionRest;
102
102
  const id = world.springJoint({
103
103
  bodyA: this.chassis,
104
- anchorA: [wheel.offset[0], wheel.offset[1], wheel.offset[2]],
104
+ anchorA: this.rotateOffset(wheel.offset),
105
105
  anchorB: [worldPos[0], worldPos[1] - rest, worldPos[2]],
106
106
  restLength: rest,
107
107
  stiffness,
@@ -118,15 +118,20 @@ export class VehicleBody {
118
118
  });
119
119
  }
120
120
  }
121
- wheelMount(wheel) {
121
+ /**
122
+ * A wheel's local offset rotated by the current heading — the ground-side mount and the
123
+ * chassis-side spring anchor both derive from this, so the suspension axis stays vertical (not a
124
+ * mix of rotated and unrotated ends) as the chassis turns.
125
+ */
126
+ rotateOffset(offset) {
122
127
  const sin = Math.sin(this.heading);
123
128
  const cos = Math.cos(this.heading);
124
- const [ox, oy, oz] = wheel.offset;
125
- return [
126
- this.world.posX[this.chassis] + ox * cos + oz * sin,
127
- this.world.posY[this.chassis] + oy,
128
- this.world.posZ[this.chassis] - ox * sin + oz * cos,
129
- ];
129
+ const [ox, oy, oz] = offset;
130
+ return [ox * cos + oz * sin, oy, -ox * sin + oz * cos];
131
+ }
132
+ wheelMount(wheel) {
133
+ const [dx, dy, dz] = this.rotateOffset(wheel.offset);
134
+ return [this.world.posX[this.chassis] + dx, this.world.posY[this.chassis] + dy, this.world.posZ[this.chassis] + dz];
130
135
  }
131
136
  get position() {
132
137
  return [this.world.posX[this.chassis], this.world.posY[this.chassis], this.world.posZ[this.chassis]];
@@ -149,16 +154,8 @@ export class VehicleBody {
149
154
  return this.wheels.length;
150
155
  }
151
156
  resetTo(position, heading) {
152
- const w = this.world;
153
- const c = this.chassis;
154
- w.posX[c] = position[0];
155
- w.posY[c] = position[1];
156
- w.posZ[c] = position[2];
157
- w.velX[c] = 0;
158
- w.velY[c] = 0;
159
- w.velZ[c] = 0;
157
+ this.world.teleport(this.chassis, position[0], position[1], position[2]);
160
158
  this.heading = heading;
161
- w.wake(c);
162
159
  }
163
160
  update(dt, input) {
164
161
  const w = this.world;
@@ -212,6 +209,8 @@ export class VehicleBody {
212
209
  const gap = mount[1] - (groundY + radius);
213
210
  const maxTravel = rest + radius;
214
211
  const grounded = gap <= maxTravel;
212
+ const anchorA = this.rotateOffset(wheel.offset);
213
+ w.setJointAnchorA(this.springs[i], anchorA[0], anchorA[1], anchorA[2]);
215
214
  w.setJointAnchor(this.springs[i], mount[0], groundY + radius, mount[2]);
216
215
  state.worldX = mount[0];
217
216
  state.worldY = groundY + radius;
@@ -0,0 +1,26 @@
1
+ export interface CellGrid<T> {
2
+ readonly width: number;
3
+ readonly height: number;
4
+ readonly cells: readonly (T | null)[];
5
+ }
6
+ export interface CellRun<T> {
7
+ readonly value: T;
8
+ readonly cells: readonly {
9
+ readonly x: number;
10
+ readonly y: number;
11
+ }[];
12
+ readonly direction: "row" | "column";
13
+ }
14
+ export declare function createCellGrid<T>(width: number, height: number): CellGrid<T>;
15
+ export declare function inGridBounds<T>(grid: CellGrid<T>, x: number, y: number): boolean;
16
+ export declare function cellAt<T>(grid: CellGrid<T>, x: number, y: number): T | null;
17
+ export declare function withCell<T>(grid: CellGrid<T>, x: number, y: number, value: T | null): CellGrid<T>;
18
+ export declare function withCells<T>(grid: CellGrid<T>, entries: readonly {
19
+ readonly x: number;
20
+ readonly y: number;
21
+ readonly value: T | null;
22
+ }[]): CellGrid<T>;
23
+ export declare function fullRows<T>(grid: CellGrid<T>): number[];
24
+ export declare function clearRows<T>(grid: CellGrid<T>, rows: readonly number[]): CellGrid<T>;
25
+ export declare function collapseColumns<T>(grid: CellGrid<T>): CellGrid<T>;
26
+ export declare function findRuns<T>(grid: CellGrid<T>, minLength: number, matches?: (a: T, b: T) => boolean): CellRun<T>[];
@@ -0,0 +1,110 @@
1
+ export function createCellGrid(width, height) {
2
+ return { width, height, cells: new Array(width * height).fill(null) };
3
+ }
4
+ export function inGridBounds(grid, x, y) {
5
+ return x >= 0 && x < grid.width && y >= 0 && y < grid.height;
6
+ }
7
+ export function cellAt(grid, x, y) {
8
+ if (!inGridBounds(grid, x, y))
9
+ return null;
10
+ return grid.cells[y * grid.width + x] ?? null;
11
+ }
12
+ export function withCell(grid, x, y, value) {
13
+ if (!inGridBounds(grid, x, y))
14
+ return grid;
15
+ const cells = grid.cells.slice();
16
+ cells[y * grid.width + x] = value;
17
+ return { width: grid.width, height: grid.height, cells };
18
+ }
19
+ export function withCells(grid, entries) {
20
+ const cells = grid.cells.slice();
21
+ for (const entry of entries) {
22
+ if (inGridBounds(grid, entry.x, entry.y))
23
+ cells[entry.y * grid.width + entry.x] = entry.value;
24
+ }
25
+ return { width: grid.width, height: grid.height, cells };
26
+ }
27
+ export function fullRows(grid) {
28
+ const rows = [];
29
+ for (let y = 0; y < grid.height; y += 1) {
30
+ let full = true;
31
+ for (let x = 0; x < grid.width; x += 1) {
32
+ if (cellAt(grid, x, y) === null) {
33
+ full = false;
34
+ break;
35
+ }
36
+ }
37
+ if (full)
38
+ rows.push(y);
39
+ }
40
+ return rows;
41
+ }
42
+ export function clearRows(grid, rows) {
43
+ const removed = new Set(rows);
44
+ const kept = [];
45
+ for (let y = 0; y < grid.height; y += 1) {
46
+ if (removed.has(y))
47
+ continue;
48
+ kept.push(grid.cells.slice(y * grid.width, y * grid.width + grid.width));
49
+ }
50
+ const clearedCount = grid.height - kept.length;
51
+ const cells = [];
52
+ for (let i = 0; i < clearedCount; i += 1)
53
+ cells.push(...new Array(grid.width).fill(null));
54
+ for (const row of kept)
55
+ cells.push(...row);
56
+ return { width: grid.width, height: grid.height, cells };
57
+ }
58
+ export function collapseColumns(grid) {
59
+ const cells = grid.cells.slice();
60
+ for (let x = 0; x < grid.width; x += 1) {
61
+ const values = [];
62
+ for (let y = 0; y < grid.height; y += 1) {
63
+ const value = cellAt(grid, x, y);
64
+ if (value !== null)
65
+ values.push(value);
66
+ }
67
+ const emptyCount = grid.height - values.length;
68
+ for (let y = 0; y < grid.height; y += 1) {
69
+ cells[y * grid.width + x] = y < emptyCount ? null : values[y - emptyCount];
70
+ }
71
+ }
72
+ return { width: grid.width, height: grid.height, cells };
73
+ }
74
+ function scanRuns(length, cellAtIndex, minLength, matches) {
75
+ const runs = [];
76
+ let start = 0;
77
+ for (let i = 1; i <= length; i += 1) {
78
+ const prev = cellAtIndex(i - 1);
79
+ const cur = i < length ? cellAtIndex(i) : null;
80
+ const continues = prev !== null && cur !== null && matches(prev, cur);
81
+ if (!continues) {
82
+ const runLength = i - start;
83
+ const value = cellAtIndex(start);
84
+ if (runLength >= minLength && value !== null)
85
+ runs.push({ value, start, end: i });
86
+ start = i;
87
+ }
88
+ }
89
+ return runs;
90
+ }
91
+ export function findRuns(grid, minLength, matches = (a, b) => a === b) {
92
+ const runs = [];
93
+ for (let y = 0; y < grid.height; y += 1) {
94
+ for (const run of scanRuns(grid.width, (x) => cellAt(grid, x, y), minLength, matches)) {
95
+ const cells = [];
96
+ for (let x = run.start; x < run.end; x += 1)
97
+ cells.push({ x, y });
98
+ runs.push({ value: run.value, cells, direction: "row" });
99
+ }
100
+ }
101
+ for (let x = 0; x < grid.width; x += 1) {
102
+ for (const run of scanRuns(grid.height, (y) => cellAt(grid, x, y), minLength, matches)) {
103
+ const cells = [];
104
+ for (let y = run.start; y < run.end; y += 1)
105
+ cells.push({ x, y });
106
+ runs.push({ value: run.value, cells, direction: "column" });
107
+ }
108
+ }
109
+ return runs;
110
+ }
@@ -0,0 +1,24 @@
1
+ import { type CellGrid } from "./cellGrid.js";
2
+ export interface FallingPiece<TShape extends string = string> {
3
+ readonly shape: TShape;
4
+ readonly rotation: number;
5
+ readonly x: number;
6
+ readonly y: number;
7
+ }
8
+ export type ShapeTable<TShape extends string = string> = Record<TShape, readonly (readonly (readonly [number, number])[])[]>;
9
+ export declare function pieceCells<TShape extends string>(table: ShapeTable<TShape>, piece: FallingPiece<TShape>): readonly (readonly [number, number])[];
10
+ export declare function pieceCollides<T, TShape extends string>(grid: CellGrid<T>, table: ShapeTable<TShape>, piece: FallingPiece<TShape>): boolean;
11
+ export declare function mergePiece<T, TShape extends string>(grid: CellGrid<T>, table: ShapeTable<TShape>, piece: FallingPiece<TShape>, value: T): CellGrid<T>;
12
+ export declare function dropDistance<T, TShape extends string>(grid: CellGrid<T>, table: ShapeTable<TShape>, piece: FallingPiece<TShape>): number;
13
+ export declare function gravityInterval(level: number, base?: number, perLevel?: number, min?: number): number;
14
+ export declare function levelForLines(lines: number, linesPerLevel?: number): number;
15
+ export declare function lineScore(cleared: number, level: number, table?: readonly number[]): number;
16
+ export interface LockDelayState {
17
+ readonly delaySeconds: number;
18
+ readonly elapsed: number;
19
+ }
20
+ export declare function createLockDelay(delaySeconds: number): LockDelayState;
21
+ export declare function stepLockDelay(state: LockDelayState, grounded: boolean, dt: number): {
22
+ state: LockDelayState;
23
+ locked: boolean;
24
+ };