@jgengine/core 0.19.0 → 0.22.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 (266) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/dist/ai/decisionGraph.d.ts +2 -0
  3. package/dist/ai/decisionGraph.js +6 -0
  4. package/dist/ai/flock.d.ts +1 -1
  5. package/dist/ai/flock.js +7 -4
  6. package/dist/anim/animGraph.d.ts +33 -2
  7. package/dist/anim/animGraph.js +200 -12
  8. package/dist/anim/locomotionGraph.d.ts +11 -3
  9. package/dist/anim/locomotionGraph.js +25 -15
  10. package/dist/audio/audioFalloff.d.ts +4 -0
  11. package/dist/audio/voiceAllocator.d.ts +46 -0
  12. package/dist/audio/voiceAllocator.js +76 -0
  13. package/dist/combat/abilityKit.d.ts +19 -0
  14. package/dist/combat/abilityKit.js +40 -4
  15. package/dist/combat/death.d.ts +4 -3
  16. package/dist/combat/death.js +3 -3
  17. package/dist/combat/magazine.d.ts +9 -0
  18. package/dist/combat/magazine.js +24 -2
  19. package/dist/combat/projectiles.d.ts +94 -2
  20. package/dist/combat/projectiles.js +282 -61
  21. package/dist/combat/regenShield.d.ts +14 -0
  22. package/dist/combat/regenShield.js +23 -7
  23. package/dist/crafting/recipeCatalog.d.ts +16 -0
  24. package/dist/crafting/recipeCatalog.js +55 -0
  25. package/dist/economy/auctionBook.d.ts +1 -1
  26. package/dist/economy/auctionBook.js +11 -1
  27. package/dist/economy/listingBook.d.ts +1 -1
  28. package/dist/economy/listingBook.js +11 -1
  29. package/dist/economy/resourceLedger.js +3 -3
  30. package/dist/economy/shopStock.js +7 -6
  31. package/dist/economy/techTree.js +7 -6
  32. package/dist/economy/unlockPoints.js +3 -6
  33. package/dist/editor/commandApply.js +89 -16
  34. package/dist/editor/commands.d.ts +38 -1
  35. package/dist/editor/commands.js +320 -3
  36. package/dist/editor/creatorStorage.d.ts +59 -0
  37. package/dist/editor/creatorStorage.js +164 -0
  38. package/dist/editor/document.d.ts +4 -0
  39. package/dist/editor/document.js +116 -17
  40. package/dist/editor/index.d.ts +3 -0
  41. package/dist/editor/index.js +1 -0
  42. package/dist/editor/liveSync.d.ts +4 -1
  43. package/dist/editor/liveSync.js +26 -10
  44. package/dist/editor/materialAuthoring.d.ts +14 -0
  45. package/dist/editor/materialAuthoring.js +38 -0
  46. package/dist/editor/modelNodeVisibility.d.ts +11 -0
  47. package/dist/editor/modelNodeVisibility.js +29 -0
  48. package/dist/editor/movementCatalog.d.ts +90 -0
  49. package/dist/editor/movementCatalog.js +144 -0
  50. package/dist/editor/simulation.d.ts +60 -0
  51. package/dist/editor/simulation.js +202 -0
  52. package/dist/editor/staticPrefab.d.ts +17 -0
  53. package/dist/editor/staticPrefab.js +51 -0
  54. package/dist/editor/terrainMaterials.d.ts +10 -0
  55. package/dist/editor/terrainMaterials.js +13 -0
  56. package/dist/editor/types.d.ts +11 -0
  57. package/dist/game/cameraConfig.d.ts +3 -1
  58. package/dist/game/clipRoles.d.ts +2 -1
  59. package/dist/game/clipRoles.js +7 -3
  60. package/dist/game/contentValidation.d.ts +69 -0
  61. package/dist/game/contentValidation.js +118 -0
  62. package/dist/game/controlGate.d.ts +8 -0
  63. package/dist/game/controlGate.js +43 -6
  64. package/dist/game/defineGame.d.ts +32 -3
  65. package/dist/game/dialogueGraph.d.ts +20 -0
  66. package/dist/game/dialogueGraph.js +107 -0
  67. package/dist/game/events.d.ts +5 -0
  68. package/dist/game/keyValueStore.d.ts +17 -4
  69. package/dist/game/keyValueStore.js +35 -23
  70. package/dist/game/lootModifiers.d.ts +17 -0
  71. package/dist/game/lootModifiers.js +26 -0
  72. package/dist/game/narrativeAuthoring.d.ts +67 -0
  73. package/dist/game/narrativeAuthoring.js +122 -0
  74. package/dist/game/playableGame.d.ts +52 -20
  75. package/dist/game/quest.d.ts +15 -0
  76. package/dist/game/quest.js +209 -63
  77. package/dist/game/questCatalog.d.ts +50 -0
  78. package/dist/game/questCatalog.js +205 -0
  79. package/dist/game/saveStore.d.ts +6 -5
  80. package/dist/game/saveStore.js +57 -57
  81. package/dist/game/systemRuntime.d.ts +2 -2
  82. package/dist/game/systemRuntime.js +84 -33
  83. package/dist/gameplay.d.ts +3 -0
  84. package/dist/gameplay.js +3 -0
  85. package/dist/interaction/promptRegistry.d.ts +5 -4
  86. package/dist/interaction/promptRegistry.js +8 -2
  87. package/dist/inventory/inventoryModel.js +9 -6
  88. package/dist/inventory/shapedGrid.d.ts +48 -9
  89. package/dist/inventory/shapedGrid.js +69 -10
  90. package/dist/item/durability.js +4 -0
  91. package/dist/item/itemInstanceRegistry.d.ts +32 -1
  92. package/dist/item/itemInstanceRegistry.js +111 -7
  93. package/dist/item/itemgen.d.ts +1 -1
  94. package/dist/item/itemgen.js +1 -1
  95. package/dist/item/modularItem.js +7 -1
  96. package/dist/material/appearanceSignals.d.ts +53 -0
  97. package/dist/material/appearanceSignals.js +47 -0
  98. package/dist/material/hairCards.d.ts +47 -0
  99. package/dist/material/hairCards.js +136 -0
  100. package/dist/material/materialAsset.d.ts +162 -0
  101. package/dist/material/materialAsset.js +382 -0
  102. package/dist/meta/changelog.d.ts +1 -1
  103. package/dist/meta/changelog.js +348 -1
  104. package/dist/movement/characterController.d.ts +5 -2
  105. package/dist/movement/characterController.js +50 -23
  106. package/dist/movement/freeFlight.d.ts +2 -2
  107. package/dist/movement/freeFlight.js +3 -3
  108. package/dist/movement/movementModel.d.ts +24 -1
  109. package/dist/movement/movementModel.js +74 -7
  110. package/dist/movement/playerMovement.d.ts +28 -1
  111. package/dist/movement/playerMovement.js +425 -102
  112. package/dist/movement/poseState.d.ts +2 -0
  113. package/dist/movement/poseState.js +29 -1
  114. package/dist/movement/solidObstacles.d.ts +3 -1
  115. package/dist/movement/solidObstacles.js +8 -5
  116. package/dist/movement/terrainGrade.d.ts +13 -0
  117. package/dist/movement/terrainGrade.js +33 -0
  118. package/dist/movement/voxelController.d.ts +2 -2
  119. package/dist/movement/voxelController.js +3 -3
  120. package/dist/nav/navGrid.d.ts +19 -1
  121. package/dist/nav/navGrid.js +26 -10
  122. package/dist/nav/solidRoute.d.ts +50 -0
  123. package/dist/nav/solidRoute.js +144 -0
  124. package/dist/physics/ballisticSweep.d.ts +10 -3
  125. package/dist/physics/ballisticSweep.js +53 -29
  126. package/dist/physics/forceVolume.d.ts +41 -0
  127. package/dist/physics/forceVolume.js +88 -0
  128. package/dist/physics/kinematicVehicle.d.ts +8 -3
  129. package/dist/physics/kinematicVehicle.js +2 -2
  130. package/dist/physics/physicsWorldBackend.js +19 -3
  131. package/dist/physics/spatialGrid.d.ts +1 -1
  132. package/dist/physics/spatialGrid.js +8 -9
  133. package/dist/physics/vehicleDynamics.d.ts +3 -2
  134. package/dist/physics/vehicleDynamics.js +2 -2
  135. package/dist/physics/vehicleObstacles.d.ts +3 -2
  136. package/dist/physics/vehicleObstacles.js +29 -5
  137. package/dist/runtime/cameraDirector.d.ts +39 -2
  138. package/dist/runtime/cameraDirector.js +162 -0
  139. package/dist/runtime/commandDispatch.d.ts +14 -0
  140. package/dist/runtime/commandDispatch.js +19 -0
  141. package/dist/runtime/context/combat.d.ts +11 -4
  142. package/dist/runtime/context/combat.js +178 -11
  143. package/dist/runtime/context/deathLoot.d.ts +9 -8
  144. package/dist/runtime/context/deathLoot.js +16 -16
  145. package/dist/runtime/context/scene.d.ts +2 -0
  146. package/dist/runtime/context/scene.js +13 -21
  147. package/dist/runtime/descriptors/baseline.js +34 -3
  148. package/dist/runtime/descriptors/features.d.ts +2 -1
  149. package/dist/runtime/descriptors/features.js +18 -0
  150. package/dist/runtime/gameContext.js +79 -13
  151. package/dist/runtime/gameContextTypes.d.ts +14 -0
  152. package/dist/runtime/headlessRunner.d.ts +3 -1
  153. package/dist/runtime/headlessRunner.js +11 -11
  154. package/dist/runtime/hostedGameRunner.d.ts +24 -1
  155. package/dist/runtime/hostedGameRunner.js +156 -10
  156. package/dist/runtime/hostedWorldSession.d.ts +17 -5
  157. package/dist/runtime/hostedWorldSession.js +154 -16
  158. package/dist/runtime/hostedWorldStore.d.ts +2 -2
  159. package/dist/runtime/inputRecorder.d.ts +1 -1
  160. package/dist/runtime/inputRecorder.js +21 -1
  161. package/dist/runtime/inputSnapshot.d.ts +16 -4
  162. package/dist/runtime/inputSnapshot.js +31 -3
  163. package/dist/runtime/runtimeSave.d.ts +10 -6
  164. package/dist/runtime/runtimeSave.js +11 -5
  165. package/dist/runtime/simContext.d.ts +8 -3
  166. package/dist/runtime/simContext.js +7 -3
  167. package/dist/runtime/simSnapshot.js +2 -0
  168. package/dist/runtime/worldReplication.d.ts +2 -0
  169. package/dist/runtime/worldReplication.js +1 -1
  170. package/dist/scene/assetCatalog.d.ts +2 -0
  171. package/dist/scene/behaviorRuntime.d.ts +26 -5
  172. package/dist/scene/behaviorRuntime.js +263 -16
  173. package/dist/scene/behaviors.d.ts +30 -1
  174. package/dist/scene/behaviors.js +29 -0
  175. package/dist/scene/entityStore.d.ts +14 -3
  176. package/dist/scene/entityStore.js +45 -9
  177. package/dist/scene/mount.d.ts +9 -0
  178. package/dist/scene/mount.js +35 -0
  179. package/dist/scene/possession.d.ts +2 -0
  180. package/dist/scene/possession.js +21 -1
  181. package/dist/scene/sceneRaycast.d.ts +7 -0
  182. package/dist/scene/sceneRaycast.js +55 -0
  183. package/dist/scene/spatial.d.ts +20 -1
  184. package/dist/scene/spatial.js +94 -2
  185. package/dist/scene/vehicleSeat.d.ts +36 -2
  186. package/dist/scene/vehicleSeat.js +89 -2
  187. package/dist/stats/accumulatorMeter.d.ts +10 -0
  188. package/dist/stats/accumulatorMeter.js +6 -0
  189. package/dist/stats/eventMeter.d.ts +9 -2
  190. package/dist/stats/eventMeter.js +5 -2
  191. package/dist/store/defineStore.d.ts +3 -1
  192. package/dist/store/defineStore.js +3 -1
  193. package/dist/store/observableKeyedStore.d.ts +1 -1
  194. package/dist/store/observableKeyedStore.js +8 -6
  195. package/dist/time/simClock.d.ts +6 -2
  196. package/dist/time/simClock.js +5 -0
  197. package/dist/vfx/particleDirector.d.ts +58 -6
  198. package/dist/vfx/particleDirector.js +113 -22
  199. package/dist/vfx/particles.d.ts +68 -5
  200. package/dist/vfx/particles.js +284 -32
  201. package/dist/visibility/assetStreaming.d.ts +21 -3
  202. package/dist/visibility/assetStreaming.js +108 -24
  203. package/dist/visibility/settings.d.ts +8 -2
  204. package/dist/visibility/settings.js +3 -0
  205. package/dist/visibility/visibilitySystem.js +1 -0
  206. package/dist/work/expedition.d.ts +110 -0
  207. package/dist/work/expedition.js +195 -0
  208. package/dist/work/staffedStation.d.ts +29 -0
  209. package/dist/work/staffedStation.js +27 -0
  210. package/dist/world/authoredAnimation.d.ts +38 -0
  211. package/dist/world/authoredAnimation.js +99 -0
  212. package/dist/world/authoredObjects.d.ts +24 -4
  213. package/dist/world/authoredObjects.js +43 -16
  214. package/dist/world/authoredSimulation.d.ts +74 -0
  215. package/dist/world/authoredSimulation.js +302 -0
  216. package/dist/world/authoredWeather.d.ts +120 -0
  217. package/dist/world/authoredWeather.js +314 -0
  218. package/dist/world/buildingKit.d.ts +8 -3
  219. package/dist/world/buildingKit.js +21 -8
  220. package/dist/world/environmentForces.d.ts +21 -0
  221. package/dist/world/environmentForces.js +42 -0
  222. package/dist/world/features.d.ts +3 -3
  223. package/dist/world/footprintGrid.d.ts +55 -0
  224. package/dist/world/footprintGrid.js +201 -0
  225. package/dist/world/grassKind.d.ts +10 -0
  226. package/dist/world/grassKind.js +22 -0
  227. package/dist/world/pathTerrain.d.ts +1 -1
  228. package/dist/world/terraform.d.ts +2 -2
  229. package/dist/world/terraform.js +46 -18
  230. package/dist/world/weather.d.ts +17 -0
  231. package/dist/world/weather.js +116 -20
  232. package/dist/world/worldSolids.d.ts +11 -0
  233. package/dist/world/worldSolids.js +112 -1
  234. package/dist/world.d.ts +5 -5
  235. package/dist/world.js +4 -4
  236. package/package.json +1 -1
  237. package/skills/ce-handoff/SKILL.md +1 -1
  238. package/skills/game-design/SKILL.md +2 -0
  239. package/skills/jgengine/SKILL.md +23 -2
  240. package/skills/jgengine/api.md +28 -17
  241. package/skills/jgengine/recipes/minimal-game.md +7 -2
  242. package/skills/jgengine-combat/SKILL.md +8 -2
  243. package/skills/jgengine-combat/api.md +22 -10
  244. package/skills/jgengine-combat/recipes/portable-runtime-state.md +60 -6
  245. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +37 -3
  246. package/skills/jgengine-combat/reference.md +24 -2
  247. package/skills/jgengine-gameplay/SKILL.md +63 -0
  248. package/skills/jgengine-gameplay/api.md +131 -37
  249. package/skills/jgengine-gameplay/capabilities.md +68 -0
  250. package/skills/jgengine-gameplay/recipes/device-save-status.md +46 -0
  251. package/skills/jgengine-gameplay/recipes/expeditions.md +108 -0
  252. package/skills/jgengine-gameplay/recipes/narrative-authoring.md +76 -0
  253. package/skills/jgengine-gameplay/recipes/paged-item-storage.md +101 -0
  254. package/skills/jgengine-gameplay/recipes/quest-authoring.md +63 -0
  255. package/skills/jgengine-gameplay/recipes/shaped-inventory.md +50 -0
  256. package/skills/jgengine-gameplay/reference-content-validation.md +74 -0
  257. package/skills/jgengine-gameplay/reference-systems.md +119 -0
  258. package/skills/jgengine-world/SKILL.md +12 -3
  259. package/skills/jgengine-world/api.md +201 -58
  260. package/skills/jgengine-world/capabilities.md +116 -3
  261. package/skills/jgengine-world/recipes/character-animation.md +31 -8
  262. package/skills/jgengine-world/recipes/controls.md +4 -0
  263. package/skills/jgengine-world/recipes/npc-senses.md +12 -0
  264. package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
  265. package/skills/jgengine-world/reference.md +167 -5
  266. package/skills/jgengine-world/visibility.md +28 -1
@@ -28,6 +28,8 @@ export interface PoseState {
28
28
  /** Restore every entity's pose and aim from a {@link snapshotAll} payload. */
29
29
  hydrateAll(state: PoseSnapshot): void;
30
30
  }
31
+ /** Restore one owned pose on synchronous failure; external implementations retain their semantic setter policy. @internal */
32
+ export declare function withPoseRollback<T>(state: PoseState, instanceId: string, operation: () => T): T;
31
33
  /**
32
34
  * Stance/pose transitions — stand, crouch, prone — that change the hitbox and movement.
33
35
  *
@@ -23,6 +23,32 @@ export const POSE_HITBOX = {
23
23
  speedMultiplier: MOVEMENT_TUNING.runSpeedMultiplier,
24
24
  },
25
25
  };
26
+ const ownedPoses = new WeakMap();
27
+ /** Restore one owned pose on synchronous failure; external implementations retain their semantic setter policy. @internal */
28
+ export function withPoseRollback(state, instanceId, operation) {
29
+ const poses = ownedPoses.get(state.getPose);
30
+ const previous = poses === undefined ? state.getPose(instanceId) : poses.get(instanceId);
31
+ try {
32
+ return operation();
33
+ }
34
+ catch (error) {
35
+ if (poses !== undefined) {
36
+ if (previous === undefined)
37
+ poses.delete(instanceId);
38
+ else
39
+ poses.set(instanceId, previous);
40
+ }
41
+ else {
42
+ try {
43
+ state.setPose(instanceId, previous);
44
+ }
45
+ finally {
46
+ throw error;
47
+ }
48
+ }
49
+ throw error;
50
+ }
51
+ }
26
52
  /**
27
53
  * Stance/pose transitions — stand, crouch, prone — that change the hitbox and movement.
28
54
  *
@@ -37,7 +63,7 @@ export function createPoseState(resolveAllowed) {
37
63
  function allowedAims(instanceId) {
38
64
  return resolveAllowed(instanceId)?.aim ?? ["hip"];
39
65
  }
40
- return {
66
+ const state = {
41
67
  getPose(instanceId) {
42
68
  return poses.get(instanceId) ?? "standing";
43
69
  },
@@ -74,4 +100,6 @@ export function createPoseState(resolveAllowed) {
74
100
  aims.set(id, aim);
75
101
  },
76
102
  };
103
+ ownedPoses.set(state.getPose, poses);
104
+ return state;
77
105
  }
@@ -1,7 +1,7 @@
1
1
  import type { GameContext } from "../runtime/gameContext.js";
2
2
  import { resolveColliders, type ResolvedCollider } from "../scene/colliders.js";
3
3
  import type { EntityPosition } from "../scene/entityStore.js";
4
- import type { WorldSolid } from "../world/worldSolids.js";
4
+ import type { WorldSolid, WorldSolids } from "../world/worldSolids.js";
5
5
  import { type CollisionObstacle } from "./movementModel.js";
6
6
  /** Largest blocking-collider extent in the scene, split so tower height cannot inflate the XZ query. */
7
7
  export interface ObstacleReach {
@@ -16,6 +16,8 @@ export interface ObstacleReach {
16
16
  * pure rules package with no `GameContext` to import can satisfy it from its own object list.
17
17
  */
18
18
  export interface SolidObstacleSource {
19
+ /** Optional indexed world geometry that is not represented by scene objects. */
20
+ solids?: Pick<WorldSolids, "count" | "inBox">;
19
21
  list(): readonly {
20
22
  instanceId: string;
21
23
  position: EntityPosition;
@@ -15,7 +15,7 @@ const OBSTACLE_MAX_HALF_EXTENT = 4;
15
15
  const OBSTACLE_HORIZONTAL_CAP = 22;
16
16
  /**
17
17
  * Vertical broadphase floor/cap. A walker only needs nearby ground props / step-ups — never full tower
18
- * height. Using building height for Y was the Vice Isle pose killer: a 50 m tower turned `inBox` into
18
+ * height. Using building height for Y was the Harbor Heat pose killer: a 50 m tower turned `inBox` into
19
19
  * a 100^3 empty cell walk every frame.
20
20
  */
21
21
  const OBSTACLE_VERTICAL_FLOOR = 3;
@@ -189,13 +189,15 @@ export function obstacleFromSolid(solid) {
189
189
  */
190
190
  export function solidObstaclesNear(ctx, position, reachX, reachZ, height = WALKER_HEIGHT) {
191
191
  const obstacles = sourceObstaclesNear(ctx.scene.object, solidObstacleReach(ctx), position, reachX, reachZ, height);
192
- const solids = ctx.world.solids;
193
- if (solids.count() === 0)
194
- return obstacles;
192
+ appendWorldSolidObstacles(obstacles, ctx.world.solids, position, reachX, reachZ, height);
193
+ return obstacles;
194
+ }
195
+ function appendWorldSolidObstacles(obstacles, solids, position, reachX, reachZ, height) {
196
+ if (solids === undefined || solids.count() === 0)
197
+ return;
195
198
  for (const solid of solids.inBox([position[0] - reachX, position[1] - height, position[2] - reachZ], [position[0] + reachX, position[1] + 2 * height, position[2] + reachZ])) {
196
199
  obstacles.push(obstacleFromSolid(solid));
197
200
  }
198
- return obstacles;
199
201
  }
200
202
  /** {@link solidObstaclesNear} against a bare source and an already-resolved reach. */
201
203
  export function sourceObstaclesNear(source, reach, position, reachX, reachZ, height = WALKER_HEIGHT) {
@@ -223,6 +225,7 @@ export function sourceObstaclesNear(source, reach, position, reachX, reachZ, hei
223
225
  obstacles.push(obstacleFromCollider(collider, object.position, object.rotationY));
224
226
  }
225
227
  }
228
+ appendWorldSolidObstacles(obstacles, source.solids, position, reachX, reachZ, height);
226
229
  return obstacles;
227
230
  }
228
231
  /**
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Constrain an uphill heightfield step by its sampled rise/run grade. Try the full step, then
3
+ * X-only, then Z-only, preserving downhill travel and sliding along a traversable axis.
4
+ * The sampler can represent a game's terrain policy independently of the ground used for feet.
5
+ * This endpoint test also applies while jumping; it is not continuous terrain collision.
6
+ * @capability uphill-grade-step constrain sampled heightfield ascent with axis sliding and a configurable terrain-policy sampler
7
+ */
8
+ export declare function resolveTerrainGradeStep(sampler: {
9
+ sampleHeight(x: number, z: number): number;
10
+ } | ((x: number, z: number) => number), position: readonly [number, number, number], stepX: number, stepZ: number, maxClimbGrade: number): {
11
+ stepX: number;
12
+ stepZ: number;
13
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Constrain an uphill heightfield step by its sampled rise/run grade. Try the full step, then
3
+ * X-only, then Z-only, preserving downhill travel and sliding along a traversable axis.
4
+ * The sampler can represent a game's terrain policy independently of the ground used for feet.
5
+ * This endpoint test also applies while jumping; it is not continuous terrain collision.
6
+ * @capability uphill-grade-step constrain sampled heightfield ascent with axis sliding and a configurable terrain-policy sampler
7
+ */
8
+ export function resolveTerrainGradeStep(sampler, position, stepX, stepZ, maxClimbGrade) {
9
+ if (!Number.isFinite(maxClimbGrade) || maxClimbGrade < 0) {
10
+ throw new RangeError("maxClimbGrade must be finite and nonnegative.");
11
+ }
12
+ const startX = position[0];
13
+ const startZ = position[2];
14
+ const sampleHeight = (x, z) => {
15
+ const height = typeof sampler === "function" ? sampler(x, z) : sampler.sampleHeight(x, z);
16
+ if (!Number.isFinite(height)) {
17
+ throw new RangeError(`climbGradeHeight/sampleHeight must return a finite height at (${x}, ${z}); received ${height}.`);
18
+ }
19
+ return height;
20
+ };
21
+ const height = sampleHeight(startX, startZ);
22
+ const allowed = (dx, dz) => {
23
+ const distance = Math.hypot(dx, dz);
24
+ return distance < 0.0001 || (sampleHeight(startX + dx, startZ + dz) - height) / distance <= maxClimbGrade;
25
+ };
26
+ if (allowed(stepX, stepZ))
27
+ return { stepX, stepZ };
28
+ if (allowed(stepX, 0))
29
+ return { stepX, stepZ: 0 };
30
+ if (allowed(0, stepZ))
31
+ return { stepX: 0, stepZ };
32
+ return { stepX: 0, stepZ: 0 };
33
+ }
@@ -19,7 +19,7 @@
19
19
  * block at cell y therefore has its top face at world y+1, so a player standing
20
20
  * on the surface layer (cell y=-1) rests with their feet at y=0.
21
21
  */
22
- import { type MovementIntent, type MovementTuningOverrides } from "./movementModel.js";
22
+ import { type MovementIntent, type MovementTuningOverrides, type MotionFrameOptions } from "./movementModel.js";
23
23
  export type SolidQuery = (x: number, y: number, z: number) => boolean;
24
24
  /** Footprint + height of the player box, in world units. */
25
25
  export interface VoxelPlayerDims {
@@ -58,4 +58,4 @@ export type GroundHeightQuery = (x: number, z: number) => number;
58
58
  * supported (resets jump state like landing on a solid cell). Where terrain and
59
59
  * a solid cell's top disagree, the higher of the two wins.
60
60
  */
61
- export declare function advanceVoxelPlayer(body: VoxelPlayerBody, intent: MovementIntent, forwardX: number, forwardZ: number, baseSpeed: number, rawDeltaSeconds: number, isSolid: SolidQuery, dims?: VoxelPlayerDims, tuning?: MovementTuningOverrides, groundHeight?: GroundHeightQuery): void;
61
+ export declare function advanceVoxelPlayer(body: VoxelPlayerBody, intent: MovementIntent, forwardX: number, forwardZ: number, baseSpeed: number, rawDeltaSeconds: number, isSolid: SolidQuery, dims?: VoxelPlayerDims, tuning?: MovementTuningOverrides, groundHeight?: GroundHeightQuery, options?: Pick<MotionFrameOptions, "authoritativeStep">): void;
@@ -19,7 +19,7 @@
19
19
  * block at cell y therefore has its top face at world y+1, so a player standing
20
20
  * on the surface layer (cell y=-1) rests with their feet at y=0.
21
21
  */
22
- import { MOVEMENT_TUNING } from "./movementModel.js";
22
+ import { MOVEMENT_TUNING, motionStepSeconds } from "./movementModel.js";
23
23
  export const DEFAULT_VOXEL_DIMS = {
24
24
  halfWidth: 0.3,
25
25
  height: 1.8,
@@ -172,8 +172,8 @@ function moveAxis(body, axis, delta, isSolid, dims) {
172
172
  * supported (resets jump state like landing on a solid cell). Where terrain and
173
173
  * a solid cell's top disagree, the higher of the two wins.
174
174
  */
175
- export function advanceVoxelPlayer(body, intent, forwardX, forwardZ, baseSpeed, rawDeltaSeconds, isSolid, dims = DEFAULT_VOXEL_DIMS, tuning, groundHeight) {
176
- const dt = Math.min(rawDeltaSeconds, MOVEMENT_TUNING.maxFrameSeconds);
175
+ export function advanceVoxelPlayer(body, intent, forwardX, forwardZ, baseSpeed, rawDeltaSeconds, isSolid, dims = DEFAULT_VOXEL_DIMS, tuning, groundHeight, options) {
176
+ const dt = motionStepSeconds(rawDeltaSeconds, options?.authoritativeStep);
177
177
  const gravityAcceleration = tuning?.gravityAcceleration ?? MOVEMENT_TUNING.gravityAcceleration;
178
178
  const jumpVelocity = tuning?.jumpVelocity ?? MOVEMENT_TUNING.jumpVelocity;
179
179
  const target = targetHorizontalVelocity(intent, forwardX, forwardZ, baseSpeed, tuning);
@@ -17,6 +17,10 @@ export interface FindPathOptions {
17
17
  clearance?: number;
18
18
  /** Multiplier (>=1) applied to the base cost of stepping from one cell center to a neighbour's. Use to penalize slopes, terrain, etc. */
19
19
  stepCost?: (from: NavPoint, to: NavPoint) => number;
20
+ /** Additional edge/shortcut policy. Called after grid occupancy checks. */
21
+ canTraverse?: (from: NavPoint, to: NavPoint) => boolean;
22
+ /** Maximum expanded cells. Omit for the legacy whole-grid search. */
23
+ maxNodes?: number;
20
24
  }
21
25
  export interface NavGrid {
22
26
  readonly cols: number;
@@ -44,6 +48,20 @@ export declare function createNavGrid(config: NavGridConfig): NavGrid;
44
48
  * the nearest walkable cell so a click on an obstacle still routes to its edge.
45
49
  */
46
50
  export declare function findPath(grid: NavGrid, from: NavPoint, to: NavPoint, options?: FindPathOptions): NavPoint[] | null;
51
+ /** Bounded grid search distinguishes exhausted work from a proven unreachable goal. */
52
+ export type FindPathResult = {
53
+ status: "path";
54
+ points: NavPoint[];
55
+ visited: number;
56
+ } | {
57
+ status: "no-path" | "budget";
58
+ visited: number;
59
+ };
60
+ /**
61
+ * Same endpoint snapping as {@link findPath}, with explicit search-budget exhaustion.
62
+ * @capability bounded-grid-route distinguish no path from exhausted grid search work
63
+ */
64
+ export declare function findPathResult(grid: NavGrid, from: NavPoint, to: NavPoint, options?: FindPathOptions): FindPathResult;
47
65
  /**
48
66
  * `FindPathOptions.stepCost` factory that penalizes steep terrain: cost is
49
67
  * `1 + weight * |Δheight| / horizontalDistance`, so with the default weight a
@@ -53,4 +71,4 @@ export declare function slopeStepCost(field: {
53
71
  sampleHeight(x: number, z: number): number;
54
72
  }, weight?: number): (from: NavPoint, to: NavPoint) => number;
55
73
  /** Remove waypoints the mover can skip because it has clear line-of-sight past them. */
56
- export declare function smoothPath(grid: NavGrid, points: readonly NavPoint[]): NavPoint[];
74
+ export declare function smoothPath(grid: NavGrid, points: readonly NavPoint[], canTraverse?: (from: NavPoint, to: NavPoint) => boolean): NavPoint[];
@@ -174,14 +174,27 @@ function nearestPassable(grid, point, passable) {
174
174
  * the nearest walkable cell so a click on an obstacle still routes to its edge.
175
175
  */
176
176
  export function findPath(grid, from, to, options = {}) {
177
+ const result = findPathResult(grid, from, to, options);
178
+ return result.status === "path" ? result.points : null;
179
+ }
180
+ /**
181
+ * Same endpoint snapping as {@link findPath}, with explicit search-budget exhaustion.
182
+ * @capability bounded-grid-route distinguish no path from exhausted grid search work
183
+ */
184
+ export function findPathResult(grid, from, to, options = {}) {
185
+ const maxNodes = options.maxNodes ?? Number.POSITIVE_INFINITY;
186
+ if (!(maxNodes >= 0))
187
+ throw new Error("navGrid maxNodes must be nonnegative.");
177
188
  const passable = withClearance(grid, options.clearance ?? 0);
178
189
  const stepCost = options.stepCost;
179
190
  const startCell = grid.cellAt(from);
180
191
  const goalCell = grid.cellAt(to);
181
192
  const start = passable(startCell.col, startCell.row) ? startCell : nearestPassable(grid, from, passable);
182
- const goal = passable(goalCell.col, goalCell.row) ? goalCell : nearestPassable(grid, to, passable);
193
+ const exactGoal = passable(goalCell.col, goalCell.row);
194
+ const goal = exactGoal ? goalCell : nearestPassable(grid, to, passable);
183
195
  if (start === null || goal === null)
184
- return null;
196
+ return { status: "no-path", visited: 0 };
197
+ const pointAt = (col, row) => exactGoal && col === goal.col && row === goal.row ? to : grid.center(col, row);
185
198
  const cols = grid.cols;
186
199
  const keyOf = (col, row) => row * cols + col;
187
200
  const startKey = keyOf(start.col, start.row);
@@ -222,6 +235,8 @@ export function findPath(grid, from, to, options = {}) {
222
235
  found = true;
223
236
  break;
224
237
  }
238
+ if (closed.size >= maxNodes)
239
+ return { status: "budget", visited: closed.size };
225
240
  closed.add(current.key);
226
241
  const col = current.key % cols;
227
242
  const row = (current.key - col) / cols;
@@ -236,7 +251,9 @@ export function findPath(grid, from, to, options = {}) {
236
251
  const nKey = keyOf(nc, nr);
237
252
  if (closed.has(nKey))
238
253
  continue;
239
- const multiplier = stepCost === undefined ? 1 : Math.max(1, stepCost(grid.center(col, row), grid.center(nc, nr)));
254
+ if (options.canTraverse !== undefined && !options.canTraverse(pointAt(col, row), pointAt(nc, nr)))
255
+ continue;
256
+ const multiplier = stepCost === undefined ? 1 : Math.max(1, stepCost(pointAt(col, row), pointAt(nc, nr)));
240
257
  const tentative = g + step * multiplier;
241
258
  if (tentative < (gScore.get(nKey) ?? Number.POSITIVE_INFINITY)) {
242
259
  gScore.set(nKey, tentative);
@@ -246,7 +263,7 @@ export function findPath(grid, from, to, options = {}) {
246
263
  }
247
264
  }
248
265
  if (!found)
249
- return null;
266
+ return { status: "no-path", visited: closed.size };
250
267
  const cells = [];
251
268
  let cursor = goalKey;
252
269
  while (cursor !== undefined) {
@@ -254,10 +271,9 @@ export function findPath(grid, from, to, options = {}) {
254
271
  cells.unshift({ col, row: (cursor - col) / cols });
255
272
  cursor = cameFrom.get(cursor);
256
273
  }
257
- const raw = cells.map((cell) => grid.center(cell.col, cell.row));
258
- if (passable(goalCell.col, goalCell.row))
259
- raw[raw.length - 1] = [to[0], to[1]];
260
- return options.smooth === false ? raw : smoothPath(grid, raw);
274
+ const raw = cells.map((cell) => [...pointAt(cell.col, cell.row)]);
275
+ const points = options.smooth === false ? raw : smoothPath(grid, raw, options.canTraverse);
276
+ return { status: "path", points, visited: closed.size };
261
277
  }
262
278
  const DEFAULT_SLOPE_STEP_WEIGHT = 1;
263
279
  /**
@@ -275,13 +291,13 @@ export function slopeStepCost(field, weight = DEFAULT_SLOPE_STEP_WEIGHT) {
275
291
  };
276
292
  }
277
293
  /** Remove waypoints the mover can skip because it has clear line-of-sight past them. */
278
- export function smoothPath(grid, points) {
294
+ export function smoothPath(grid, points, canTraverse) {
279
295
  if (points.length <= 2)
280
296
  return points.slice();
281
297
  const result = [points[0]];
282
298
  let anchor = 0;
283
299
  for (let i = 2; i < points.length; i += 1) {
284
- if (!grid.lineOfSight(points[anchor], points[i])) {
300
+ if (!grid.lineOfSight(points[anchor], points[i]) || (canTraverse !== undefined && !canTraverse(points[anchor], points[i]))) {
285
301
  result.push(points[i - 1]);
286
302
  anchor = i - 1;
287
303
  }
@@ -0,0 +1,50 @@
1
+ import type { CollisionObstacle } from "../movement/movementModel.js";
2
+ import type { Aabb } from "../world/geometry.js";
3
+ import { type NavPoint } from "./navGrid.js";
4
+ import type { Waypoint } from "./pathFollow.js";
5
+ /** Caller-owned local collision query and terrain policy; no movement or perception state. */
6
+ export interface SolidRouteOptions {
7
+ bounds: Aabb;
8
+ cellSize: number;
9
+ /** Query the indexed movement geometry, including mover clearance outside these bounds. */
10
+ obstaclesInBounds: (bounds: Aabb) => Iterable<CollisionObstacle>;
11
+ radius?: number;
12
+ height?: number;
13
+ /** Intermediate waypoint elevation. Defaults to the start's feet height. */
14
+ sampleHeight?: (x: number, z: number) => number;
15
+ /** Terrain/door policy for every edge and smoothing shortcut. */
16
+ canTraverse?: (from: Waypoint, to: Waypoint) => boolean;
17
+ /** Cost multiplier >=1. Suppresses smoothing by default to retain the priced route. */
18
+ stepCost?: (from: NavPoint, to: NavPoint) => number;
19
+ smooth?: boolean;
20
+ /** Grid allocation cap. Default 4096. */
21
+ maxCells?: number;
22
+ /** Gathered obstacle/sub-box cap. Default 256. */
23
+ maxBoxes?: number;
24
+ /** Search expansion cap. Default 1024. */
25
+ maxNodes?: number;
26
+ /** Collision-box tests and terrain-policy calls combined. Default 100000. */
27
+ maxChecks?: number;
28
+ }
29
+ /** Work counters exclude the caller's spatial-query implementation. */
30
+ export interface SolidRouteWork {
31
+ cells: number;
32
+ boxes: number;
33
+ visited: number;
34
+ checks: number;
35
+ }
36
+ /** Failures never carry a partial or snapped route. */
37
+ export type SolidRouteResult = {
38
+ status: "path";
39
+ waypoints: Waypoint[];
40
+ work: SolidRouteWork;
41
+ } | {
42
+ status: "no-path" | "budget";
43
+ work: SolidRouteWork;
44
+ };
45
+ /**
46
+ * Plan an exact-endpoint local route over indexed movement boxes using the shared nav grid.
47
+ * Every edge is swept at body width; bounded failure leaves retry/stop policy with the caller.
48
+ * @capability solid-route bounded local planning over indexed collision geometry
49
+ */
50
+ export declare function planSolidRoute(from: Waypoint, to: Waypoint, options: SolidRouteOptions): SolidRouteResult;
@@ -0,0 +1,144 @@
1
+ import { createNavGrid, findPathResult } from "./navGrid.js";
2
+ function intersects(from, to, box, radius, height) {
3
+ if (box.max[1] <= Math.min(from[1], to[1]) || box.min[1] >= Math.max(from[1], to[1]) + height)
4
+ return false;
5
+ let enter = 0;
6
+ let exit = 1;
7
+ for (const axis of [0, 2]) {
8
+ const min = box.min[axis] - radius;
9
+ const max = box.max[axis] + radius;
10
+ const delta = to[axis] - from[axis];
11
+ if (delta === 0) {
12
+ if (from[axis] <= min || from[axis] >= max)
13
+ return false;
14
+ }
15
+ else {
16
+ const a = (min - from[axis]) / delta;
17
+ const b = (max - from[axis]) / delta;
18
+ enter = Math.max(enter, Math.min(a, b));
19
+ exit = Math.min(exit, Math.max(a, b));
20
+ if (enter >= exit)
21
+ return false;
22
+ }
23
+ }
24
+ return enter < exit;
25
+ }
26
+ /**
27
+ * Plan an exact-endpoint local route over indexed movement boxes using the shared nav grid.
28
+ * Every edge is swept at body width; bounded failure leaves retry/stop policy with the caller.
29
+ * @capability solid-route bounded local planning over indexed collision geometry
30
+ */
31
+ export function planSolidRoute(from, to, options) {
32
+ const { bounds, cellSize } = options;
33
+ const radius = options.radius ?? 0.3;
34
+ const height = options.height ?? 1.8;
35
+ const maxCells = options.maxCells ?? 4096;
36
+ const maxBoxes = options.maxBoxes ?? 256;
37
+ const maxNodes = options.maxNodes ?? 1024;
38
+ const maxChecks = options.maxChecks ?? 100000;
39
+ if (![...from, ...to, bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, cellSize, radius, height].every(Number.isFinite) ||
40
+ cellSize <= 0 || radius < 0 || height <= 0 || bounds.maxX <= bounds.minX || bounds.maxZ <= bounds.minZ ||
41
+ ![maxCells, maxBoxes, maxNodes, maxChecks].every((value) => Number.isSafeInteger(value) && value >= 0)) {
42
+ throw new Error("solidRoute requires finite geometry and nonnegative integer budgets.");
43
+ }
44
+ const work = { cells: 0, boxes: 0, visited: 0, checks: 0 };
45
+ const failure = (status) => ({ status, work });
46
+ const inside = (point) => point[0] >= bounds.minX && point[0] < bounds.maxX && point[2] >= bounds.minZ && point[2] < bounds.maxZ;
47
+ if (!inside(from) || !inside(to))
48
+ return failure("no-path");
49
+ const cells = Math.ceil((bounds.maxX - bounds.minX) / cellSize) * Math.ceil((bounds.maxZ - bounds.minZ) / cellSize);
50
+ if (cells > maxCells)
51
+ return failure("budget");
52
+ const boxes = [];
53
+ const query = { minX: bounds.minX - radius, maxX: bounds.maxX + radius, minZ: bounds.minZ - radius, maxZ: bounds.maxZ + radius };
54
+ for (const obstacle of options.obstaclesInBounds(query)) {
55
+ const half = obstacle.halfExtents ?? [0.5, 0.5, 0.5];
56
+ const offset = obstacle.offset ?? [0, 0, 0];
57
+ const local = obstacle.boxes?.length ? obstacle.boxes : [{
58
+ min: [offset[0] - half[0], offset[1] - half[1], offset[2] - half[2]],
59
+ max: [offset[0] + half[0], offset[1] + half[1], offset[2] + half[2]],
60
+ }];
61
+ for (const box of local) {
62
+ if (boxes.length >= maxBoxes)
63
+ return failure("budget");
64
+ const worldBox = {
65
+ min: [obstacle.position[0] + box.min[0], obstacle.position[1] + box.min[1], obstacle.position[2] + box.min[2]],
66
+ max: [obstacle.position[0] + box.max[0], obstacle.position[1] + box.max[1], obstacle.position[2] + box.max[2]],
67
+ };
68
+ if (![...worldBox.min, ...worldBox.max].every(Number.isFinite) || worldBox.min.some((value, axis) => value > worldBox.max[axis])) {
69
+ throw new Error("solidRoute obstacle boxes must be finite and ordered.");
70
+ }
71
+ boxes.push(worldBox);
72
+ work.boxes++;
73
+ }
74
+ }
75
+ let exhausted = false;
76
+ const spend = () => {
77
+ if (work.checks >= maxChecks) {
78
+ exhausted = true;
79
+ return false;
80
+ }
81
+ work.checks++;
82
+ return true;
83
+ };
84
+ const clear = (a, b) => {
85
+ if (exhausted)
86
+ return false;
87
+ for (const box of boxes) {
88
+ if (!spend() || intersects(a, b, box, radius, height))
89
+ return false;
90
+ }
91
+ return options.canTraverse === undefined || (a[0] === b[0] && a[1] === b[1] && a[2] === b[2]) ||
92
+ (spend() && options.canTraverse(a, b));
93
+ };
94
+ if (!clear(from, from) || !clear(to, to))
95
+ return failure(exhausted ? "budget" : "no-path");
96
+ const directClear = clear(from, to);
97
+ if (options.stepCost === undefined && directClear)
98
+ return { status: "path", waypoints: [[...from], [...to]], work };
99
+ if (exhausted)
100
+ return failure("budget");
101
+ const grid = createNavGrid({ bounds, cellSize });
102
+ const start = grid.cellAt([from[0], from[2]]);
103
+ const goal = grid.cellAt([to[0], to[2]]);
104
+ if (start.col === goal.col && start.row === goal.row) {
105
+ return directClear ? { status: "path", waypoints: [[...from], [...to]], work } : failure("no-path");
106
+ }
107
+ const center = grid.center;
108
+ grid.center = (col, row) => col === start.col && row === start.row ? [from[0], from[2]] :
109
+ col === goal.col && row === goal.row ? [to[0], to[2]] : center(col, row);
110
+ const waypoint = (point) => {
111
+ if (point[0] === from[0] && point[1] === from[2])
112
+ return from;
113
+ if (point[0] === to[0] && point[1] === to[2])
114
+ return to;
115
+ const y = options.sampleHeight?.(point[0], point[1]) ?? from[1];
116
+ if (!Number.isFinite(y))
117
+ throw new Error("solidRoute sampleHeight must be finite.");
118
+ return [point[0], y, point[1]];
119
+ };
120
+ for (let row = 0; row < grid.rows; row++) {
121
+ for (let col = 0; col < grid.cols; col++) {
122
+ const point = waypoint(grid.center(col, row));
123
+ grid.setWalkable(col, row, inside(point) && clear(point, point));
124
+ work.cells++;
125
+ if (exhausted)
126
+ return failure("budget");
127
+ }
128
+ }
129
+ const result = findPathResult(grid, [from[0], from[2]], [to[0], to[2]], {
130
+ maxNodes, stepCost: options.stepCost, smooth: options.smooth ?? options.stepCost === undefined,
131
+ canTraverse: (a, b) => clear(waypoint(a), waypoint(b)),
132
+ });
133
+ work.visited = result.visited;
134
+ if (exhausted)
135
+ return failure("budget");
136
+ if (result.status !== "path")
137
+ return failure(result.status);
138
+ const waypoints = result.points.map((point) => [...waypoint(point)]);
139
+ for (let i = 1; i < waypoints.length; i++) {
140
+ if (!clear(waypoints[i - 1], waypoints[i]))
141
+ return failure(exhausted ? "budget" : "no-path");
142
+ }
143
+ return { status: "path", waypoints, work };
144
+ }
@@ -11,8 +11,15 @@ export interface BallisticSweepOptions {
11
11
  radius?: number;
12
12
  }
13
13
  /**
14
- * Marches the closed-form arc (constant gravity, straight lateral) through `world` and reports the
15
- * first sample inside any live body's AABB — sleeping bodies included — refined by one bisection
16
- * between the last clear sample and the hit sample. Returns `null` when the whole arc is clear.
14
+ * Sweeps each short chord of the closed-form arc through live body AABBs, including sleeping bodies.
15
+ * Thin cover between samples blocks; `step` controls the approximation of the curved path.
17
16
  */
18
17
  export declare function createBallisticSweep(world: PhysicsWorld, options?: BallisticSweepOptions): BallisticSweep;
18
+ /** Contact fraction for a point segment against a translating axis-aligned box; inflate extents for a projectile radius.
19
+ * @capability moving-box-sweep detect contact with boxes crossing a projectile segment between simulation poses
20
+ */
21
+ export declare function sweepMovingBounds(from: readonly [number, number, number], to: readonly [number, number, number], targetFrom: readonly [number, number, number], targetTo: readonly [number, number, number], halfExtents: readonly [number, number, number]): number | null;
22
+ /** Contact fraction for a point segment against a translating sphere; add the projectile radius to `radius`.
23
+ * @capability moving-sphere-sweep detect contact with spheres crossing a projectile segment between simulation poses
24
+ */
25
+ export declare function sweepMovingSphere(from: readonly [number, number, number], to: readonly [number, number, number], targetFrom: readonly [number, number, number], targetTo: readonly [number, number, number], radius: number): number | null;
@@ -1,49 +1,73 @@
1
+ import { intersectAabb } from "../scene/objectQuery.js";
1
2
  const DEFAULT_SWEEP_STEP = 1 / 60;
2
3
  /**
3
- * Marches the closed-form arc (constant gravity, straight lateral) through `world` and reports the
4
- * first sample inside any live body's AABB — sleeping bodies included — refined by one bisection
5
- * between the last clear sample and the hit sample. Returns `null` when the whole arc is clear.
4
+ * Sweeps each short chord of the closed-form arc through live body AABBs, including sleeping bodies.
5
+ * Thin cover between samples blocks; `step` controls the approximation of the curved path.
6
6
  */
7
7
  export function createBallisticSweep(world, options = {}) {
8
8
  const step = options.step ?? DEFAULT_SWEEP_STEP;
9
9
  const radius = options.radius ?? 0;
10
- function overlapsAnyBody(x, y, z) {
11
- for (let i = 0; i < world.highWater; i += 1) {
12
- if (!world.isAlive(i))
13
- continue;
14
- if (Math.abs(x - world.posX[i]) > world.halfX[i] + radius)
15
- continue;
16
- if (Math.abs(y - world.posY[i]) > world.halfY[i] + radius)
17
- continue;
18
- if (Math.abs(z - world.posZ[i]) > world.halfZ[i] + radius)
19
- continue;
20
- return true;
21
- }
22
- return false;
23
- }
10
+ if (!Number.isFinite(step) || step <= 0 || !Number.isFinite(radius) || radius < 0)
11
+ throw new RangeError("Ballistic sweep requires a finite positive step and nonnegative radius");
24
12
  return (origin, velocity, gravity, maxTime) => {
13
+ if (!origin.every(Number.isFinite) || !velocity.every(Number.isFinite) || !Number.isFinite(gravity) || !Number.isFinite(maxTime) || maxTime < 0 || Math.ceil(maxTime / step) > 100_000)
14
+ throw new RangeError("Ballistic sweep requires finite inputs within its 100000 segment budget");
25
15
  const pointAt = (t) => [
26
16
  origin[0] + velocity[0] * t,
27
17
  origin[1] + velocity[1] * t - 0.5 * gravity * t * t,
28
18
  origin[2] + velocity[2] * t,
29
19
  ];
30
- let lastClear = 0;
31
20
  let t = 0;
32
21
  for (;;) {
33
- const sample = pointAt(t);
34
- if (overlapsAnyBody(sample[0], sample[1], sample[2])) {
35
- if (t === 0)
36
- return { point: sample, time: 0 };
37
- const mid = (lastClear + t) / 2;
38
- const midSample = pointAt(mid);
39
- return overlapsAnyBody(midSample[0], midSample[1], midSample[2])
40
- ? { point: midSample, time: mid }
41
- : { point: sample, time: t };
22
+ const nextTime = Math.min(t + step, maxTime);
23
+ const from = pointAt(t);
24
+ const to = pointAt(nextTime);
25
+ let fraction = null;
26
+ for (let i = 0; i < world.highWater; i += 1) {
27
+ if (!world.isAlive(i))
28
+ continue;
29
+ const contact = sweepMovingBounds(from, to, [world.posX[i], world.posY[i], world.posZ[i]], [world.posX[i], world.posY[i], world.posZ[i]], [world.halfX[i] + radius, world.halfY[i] + radius, world.halfZ[i] + radius]);
30
+ if (contact !== null && (fraction === null || contact < fraction))
31
+ fraction = contact;
32
+ }
33
+ if (fraction !== null) {
34
+ const time = t + (nextTime - t) * fraction;
35
+ return { point: pointAt(time), time };
42
36
  }
43
37
  if (t >= maxTime)
44
38
  return null;
45
- lastClear = t;
46
- t = Math.min(t + step, maxTime);
39
+ t = nextTime;
47
40
  }
48
41
  };
49
42
  }
43
+ /** Contact fraction for a point segment against a translating axis-aligned box; inflate extents for a projectile radius.
44
+ * @capability moving-box-sweep detect contact with boxes crossing a projectile segment between simulation poses
45
+ */
46
+ export function sweepMovingBounds(from, to, targetFrom, targetTo, halfExtents) {
47
+ const origin = [from[0] - targetFrom[0], from[1] - targetFrom[1], from[2] - targetFrom[2]];
48
+ const motion = [to[0] - targetTo[0] - origin[0], to[1] - targetTo[1] - origin[1], to[2] - targetTo[2] - origin[2]];
49
+ return intersectAabb(origin, motion, [-halfExtents[0], -halfExtents[1], -halfExtents[2]], halfExtents, 1)?.distance ?? null;
50
+ }
51
+ /** Contact fraction for a point segment against a translating sphere; add the projectile radius to `radius`.
52
+ * @capability moving-sphere-sweep detect contact with spheres crossing a projectile segment between simulation poses
53
+ */
54
+ export function sweepMovingSphere(from, to, targetFrom, targetTo, radius) {
55
+ const ox = from[0] - targetFrom[0];
56
+ const oy = from[1] - targetFrom[1];
57
+ const oz = from[2] - targetFrom[2];
58
+ const dx = to[0] - targetTo[0] - ox;
59
+ const dy = to[1] - targetTo[1] - oy;
60
+ const dz = to[2] - targetTo[2] - oz;
61
+ const c = ox * ox + oy * oy + oz * oz - radius * radius;
62
+ if (c <= 0)
63
+ return 0;
64
+ const a = dx * dx + dy * dy + dz * dz;
65
+ if (a <= 1e-18)
66
+ return null;
67
+ const b = ox * dx + oy * dy + oz * dz;
68
+ const discriminant = b * b - a * c;
69
+ if (discriminant < 0)
70
+ return null;
71
+ const fraction = (-b - Math.sqrt(discriminant)) / a;
72
+ return fraction >= 0 && fraction <= 1 ? fraction : null;
73
+ }