@jgengine/core 0.18.0 → 0.19.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 (318) hide show
  1. package/CHANGELOG.md +265 -2
  2. package/dist/ai/decisionGraph.d.ts +96 -0
  3. package/dist/ai/decisionGraph.js +212 -0
  4. package/dist/ai/groupAssist.d.ts +10 -0
  5. package/dist/ai/groupAssist.js +11 -0
  6. package/dist/ai/mobBrainGraph.d.ts +3 -0
  7. package/dist/ai/mobBrainGraph.js +25 -0
  8. package/dist/ai/pursuit.d.ts +1 -1
  9. package/dist/ai/pursuit.js +1 -1
  10. package/dist/ai/tacticalQueries.d.ts +30 -0
  11. package/dist/ai/tacticalQueries.js +45 -0
  12. package/dist/ai/threat.d.ts +8 -0
  13. package/dist/ai/threat.js +10 -0
  14. package/dist/anim/animGraph.d.ts +155 -0
  15. package/dist/anim/animGraph.js +414 -0
  16. package/dist/anim/boneTexture.d.ts +55 -0
  17. package/dist/anim/boneTexture.js +43 -0
  18. package/dist/anim/footPlacement.d.ts +57 -0
  19. package/dist/anim/footPlacement.js +67 -0
  20. package/dist/anim/ikSolver.d.ts +51 -0
  21. package/dist/anim/ikSolver.js +124 -0
  22. package/dist/anim/locomotionGraph.d.ts +35 -0
  23. package/dist/anim/locomotionGraph.js +62 -0
  24. package/dist/assets/spriteAtlas.d.ts +21 -0
  25. package/dist/assets/spriteAtlas.js +103 -0
  26. package/dist/audio/audioFalloff.d.ts +12 -0
  27. package/dist/audio/doppler.d.ts +21 -0
  28. package/dist/audio/doppler.js +32 -0
  29. package/dist/audio/engineLayers.d.ts +83 -0
  30. package/dist/audio/engineLayers.js +115 -0
  31. package/dist/combat/abilityKit.d.ts +1 -0
  32. package/dist/combat/abilityKit.js +1 -0
  33. package/dist/combat/death.d.ts +7 -0
  34. package/dist/combat/shotOrigin.d.ts +6 -0
  35. package/dist/combat/shotOrigin.js +6 -0
  36. package/dist/combat/weaponFeedback.d.ts +36 -0
  37. package/dist/combat/weaponFeedback.js +42 -0
  38. package/dist/combat/weaponHandling.d.ts +161 -0
  39. package/dist/combat/weaponHandling.js +202 -0
  40. package/dist/combat/weaponPresentation.d.ts +115 -0
  41. package/dist/combat/weaponPresentation.js +96 -0
  42. package/dist/devtools/warnOnce.d.ts +8 -0
  43. package/dist/devtools/warnOnce.js +18 -0
  44. package/dist/economy/currency.d.ts +16 -2
  45. package/dist/economy/currency.js +55 -14
  46. package/dist/economy/wallet.d.ts +5 -4
  47. package/dist/economy/wallet.js +8 -4
  48. package/dist/editor/commandApply.js +1 -0
  49. package/dist/editor/commands.d.ts +4 -1
  50. package/dist/editor/document.d.ts +2 -1
  51. package/dist/editor/document.js +92 -1
  52. package/dist/editor/environment.d.ts +3 -0
  53. package/dist/editor/environment.js +24 -0
  54. package/dist/editor/index.d.ts +4 -2
  55. package/dist/editor/index.js +1 -1
  56. package/dist/editor/types.d.ts +20 -0
  57. package/dist/faction/reputation.d.ts +6 -0
  58. package/dist/faction/reputation.js +11 -0
  59. package/dist/game/cameraConfig.d.ts +59 -1
  60. package/dist/game/chat.d.ts +22 -1
  61. package/dist/game/chat.js +48 -19
  62. package/dist/game/controlGate.d.ts +6 -0
  63. package/dist/game/controlGate.js +30 -1
  64. package/dist/game/defineGame.d.ts +10 -1
  65. package/dist/game/events.d.ts +14 -1
  66. package/dist/game/lootTable.d.ts +4 -0
  67. package/dist/game/lootTable.js +16 -0
  68. package/dist/game/pixelPerfect.d.ts +25 -0
  69. package/dist/game/pixelPerfect.js +29 -0
  70. package/dist/game/playableGame.d.ts +167 -6
  71. package/dist/game/systemRuntime.js +6 -1
  72. package/dist/game/toasts.d.ts +8 -0
  73. package/dist/game/toasts.js +7 -0
  74. package/dist/game/vfxInstance.d.ts +9 -0
  75. package/dist/game/vfxInstance.js +12 -1
  76. package/dist/game/viewports.d.ts +43 -0
  77. package/dist/game/viewports.js +46 -0
  78. package/dist/game/worldItem.d.ts +5 -1
  79. package/dist/game/worldItem.js +0 -1
  80. package/dist/input/actionBindings.d.ts +17 -2
  81. package/dist/input/actionBindings.js +24 -11
  82. package/dist/input/actionContexts.d.ts +45 -0
  83. package/dist/input/actionContexts.js +113 -0
  84. package/dist/input/axisInput.d.ts +4 -0
  85. package/dist/input/axisInput.js +4 -0
  86. package/dist/input/axisShaper.d.ts +73 -0
  87. package/dist/input/axisShaper.js +105 -0
  88. package/dist/input/gamepadModel.d.ts +79 -0
  89. package/dist/input/gamepadModel.js +108 -0
  90. package/dist/input/haptics.d.ts +50 -0
  91. package/dist/input/haptics.js +74 -0
  92. package/dist/input/inputBuffer.d.ts +40 -0
  93. package/dist/input/inputBuffer.js +59 -0
  94. package/dist/input/lookChannel.d.ts +10 -0
  95. package/dist/input/lookChannel.js +6 -0
  96. package/dist/input/pointer.d.ts +2 -0
  97. package/dist/input/pointer.js +4 -0
  98. package/dist/item/affix.d.ts +6 -0
  99. package/dist/item/affix.js +6 -0
  100. package/dist/meta/changelog.d.ts +1 -1
  101. package/dist/meta/changelog.js +196 -1
  102. package/dist/movement/characterController.d.ts +76 -0
  103. package/dist/movement/characterController.js +216 -0
  104. package/dist/movement/freeFlight.d.ts +119 -0
  105. package/dist/movement/freeFlight.js +294 -0
  106. package/dist/movement/glideModel.d.ts +3 -0
  107. package/dist/movement/glideModel.js +6 -0
  108. package/dist/movement/grappleSwing.d.ts +2 -0
  109. package/dist/movement/grappleSwing.js +5 -1
  110. package/dist/movement/movementModel.d.ts +39 -0
  111. package/dist/movement/movementModel.js +62 -19
  112. package/dist/movement/movementProbe.d.ts +51 -0
  113. package/dist/movement/movementProbe.js +119 -0
  114. package/dist/movement/playerMovement.d.ts +30 -3
  115. package/dist/movement/playerMovement.js +304 -7
  116. package/dist/movement/serverStep.d.ts +5 -0
  117. package/dist/movement/serverStep.js +6 -0
  118. package/dist/movement/solidObstacles.d.ts +39 -8
  119. package/dist/movement/solidObstacles.js +71 -6
  120. package/dist/movement/voxelController.js +7 -7
  121. package/dist/multiplayer/poseSyncGate.d.ts +3 -1
  122. package/dist/multiplayer/poseSyncGate.js +9 -1
  123. package/dist/multiplayer/presenceContract.d.ts +6 -0
  124. package/dist/multiplayer/presenceModel.d.ts +2 -0
  125. package/dist/multiplayer/presenceModel.js +11 -0
  126. package/dist/nav/navFromEnvironment.d.ts +9 -3
  127. package/dist/nav/navFromEnvironment.js +18 -12
  128. package/dist/nav/navMesh.d.ts +74 -0
  129. package/dist/nav/navMesh.js +570 -0
  130. package/dist/physics/aircraftDynamics.d.ts +390 -0
  131. package/dist/physics/aircraftDynamics.js +704 -0
  132. package/dist/physics/boatDynamics.d.ts +116 -0
  133. package/dist/physics/boatDynamics.js +160 -0
  134. package/dist/physics/drivableVehicle.d.ts +22 -8
  135. package/dist/physics/drivableVehicle.js +4 -2
  136. package/dist/physics/flightDynamics.d.ts +29 -4
  137. package/dist/physics/flightDynamics.js +28 -5
  138. package/dist/physics/handlingProbe.d.ts +249 -0
  139. package/dist/physics/handlingProbe.js +606 -0
  140. package/dist/physics/kinematicVehicle.d.ts +37 -2
  141. package/dist/physics/kinematicVehicle.js +15 -0
  142. package/dist/physics/physicsBackend.d.ts +203 -0
  143. package/dist/physics/physicsBackend.js +49 -0
  144. package/dist/physics/physicsBackendConformance.d.ts +27 -0
  145. package/dist/physics/physicsBackendConformance.js +151 -0
  146. package/dist/physics/physicsWorldBackend.d.ts +45 -0
  147. package/dist/physics/physicsWorldBackend.js +518 -0
  148. package/dist/physics/vehicleBackendLink.d.ts +49 -0
  149. package/dist/physics/vehicleBackendLink.js +102 -0
  150. package/dist/physics/vehicleDynamics.d.ts +374 -0
  151. package/dist/physics/vehicleDynamics.js +737 -0
  152. package/dist/physics/worldColliders.d.ts +13 -0
  153. package/dist/physics/worldColliders.js +128 -0
  154. package/dist/puzzle/cellGrid.d.ts +15 -9
  155. package/dist/puzzle/cellGrid.js +13 -9
  156. package/dist/random/rng.d.ts +12 -1
  157. package/dist/random/rng.js +20 -3
  158. package/dist/render/environment.d.ts +23 -0
  159. package/dist/render/environment.js +1 -0
  160. package/dist/render/lookPreset.d.ts +19 -6
  161. package/dist/render/lookPreset.js +56 -4
  162. package/dist/render/postProcessing.d.ts +40 -1
  163. package/dist/render/postProcessing.js +1 -1
  164. package/dist/render/sprite2d.d.ts +38 -0
  165. package/dist/render/sprite2d.js +40 -0
  166. package/dist/runtime/adapter.d.ts +20 -1
  167. package/dist/runtime/adapter.js +8 -1
  168. package/dist/runtime/cameraDirector.d.ts +17 -3
  169. package/dist/runtime/cameraDirector.js +25 -0
  170. package/dist/runtime/commandInput.d.ts +52 -0
  171. package/dist/runtime/commandInput.js +71 -0
  172. package/dist/runtime/commandRunner.d.ts +39 -7
  173. package/dist/runtime/commandRunner.js +35 -5
  174. package/dist/runtime/context/combat.d.ts +1 -0
  175. package/dist/runtime/context/combat.js +2 -1
  176. package/dist/runtime/context/deathLoot.d.ts +2 -0
  177. package/dist/runtime/context/deathLoot.js +1 -0
  178. package/dist/runtime/gameContext.js +18 -1
  179. package/dist/runtime/gameContextTypes.d.ts +33 -7
  180. package/dist/runtime/gameRuntime.d.ts +23 -5
  181. package/dist/runtime/gameRuntime.js +21 -4
  182. package/dist/runtime/headlessRunner.d.ts +10 -0
  183. package/dist/runtime/headlessRunner.js +26 -8
  184. package/dist/runtime/hostPolicy.d.ts +3 -2
  185. package/dist/runtime/hostPolicy.js +8 -10
  186. package/dist/runtime/hostedGameRunner.js +34 -6
  187. package/dist/runtime/hostedWorldSession.d.ts +12 -16
  188. package/dist/runtime/hostedWorldSession.js +29 -6
  189. package/dist/runtime/hostedWorldStore.d.ts +20 -0
  190. package/dist/runtime/hostedWorldStore.js +1 -0
  191. package/dist/runtime/inputRecorder.d.ts +30 -0
  192. package/dist/runtime/inputRecorder.js +59 -0
  193. package/dist/runtime/inputSnapshot.d.ts +14 -0
  194. package/dist/runtime/inputSnapshot.js +11 -0
  195. package/dist/runtime/localPlayers.d.ts +73 -0
  196. package/dist/runtime/localPlayers.js +119 -0
  197. package/dist/runtime/poseInterpolation.d.ts +36 -0
  198. package/dist/runtime/poseInterpolation.js +91 -0
  199. package/dist/runtime/prediction.d.ts +45 -0
  200. package/dist/runtime/prediction.js +62 -0
  201. package/dist/runtime/simContext.d.ts +45 -0
  202. package/dist/runtime/simContext.js +65 -0
  203. package/dist/runtime/simLoop.d.ts +52 -0
  204. package/dist/runtime/simLoop.js +72 -0
  205. package/dist/runtime/simSnapshot.d.ts +32 -0
  206. package/dist/runtime/simSnapshot.js +60 -0
  207. package/dist/runtime/snapshot.d.ts +8 -0
  208. package/dist/runtime/snapshotBuffer.d.ts +33 -0
  209. package/dist/runtime/snapshotBuffer.js +58 -0
  210. package/dist/runtime/transport.d.ts +41 -2
  211. package/dist/runtime/transport.js +7 -1
  212. package/dist/runtime/worldChunks.d.ts +2 -0
  213. package/dist/runtime/worldChunks.js +14 -0
  214. package/dist/runtime/worldProjection.d.ts +9 -0
  215. package/dist/runtime/worldProjection.js +17 -1
  216. package/dist/runtime/worldSnapshot.d.ts +6 -0
  217. package/dist/runtime/worldSnapshot.js +3 -0
  218. package/dist/scene/behaviorRuntime.d.ts +29 -1
  219. package/dist/scene/behaviorRuntime.js +71 -4
  220. package/dist/scene/behaviors.d.ts +12 -1
  221. package/dist/scene/builtinSceneKinds.js +10 -0
  222. package/dist/scene/sceneKinds.d.ts +9 -0
  223. package/dist/scene/vehicleSeat.d.ts +5 -1
  224. package/dist/scene/vehicleSeat.js +5 -1
  225. package/dist/sensor/concealment.d.ts +6 -0
  226. package/dist/sensor/concealment.js +8 -0
  227. package/dist/sensor/freezeMonitor.d.ts +6 -0
  228. package/dist/sensor/freezeMonitor.js +8 -0
  229. package/dist/sensor/perception.d.ts +70 -0
  230. package/dist/sensor/perception.js +189 -0
  231. package/dist/sensor/recordingBuffer.d.ts +3 -0
  232. package/dist/sensor/recordingBuffer.js +8 -0
  233. package/dist/session/roundState.d.ts +4 -0
  234. package/dist/session/roundState.js +11 -0
  235. package/dist/settings/frameRateLimit.d.ts +16 -0
  236. package/dist/settings/frameRateLimit.js +37 -0
  237. package/dist/settings/graphicsProfile.d.ts +21 -0
  238. package/dist/settings/graphicsProfile.js +48 -0
  239. package/dist/settings/graphicsSettings.d.ts +40 -0
  240. package/dist/settings/graphicsSettings.js +81 -0
  241. package/dist/settings/settingsModel.d.ts +9 -1
  242. package/dist/settings/settingsModel.js +10 -0
  243. package/dist/stats/eventMeter.d.ts +1 -1
  244. package/dist/stats/eventMeter.js +1 -1
  245. package/dist/time/accrueSince.d.ts +10 -0
  246. package/dist/time/accrueSince.js +9 -0
  247. package/dist/time/rateWindow.d.ts +16 -0
  248. package/dist/time/rateWindow.js +14 -0
  249. package/dist/time/serverTick.d.ts +21 -3
  250. package/dist/time/serverTick.js +14 -13
  251. package/dist/vfx/feedbackMixer.d.ts +61 -0
  252. package/dist/vfx/feedbackMixer.js +106 -0
  253. package/dist/vfx/particleDirector.d.ts +68 -0
  254. package/dist/vfx/particleDirector.js +81 -0
  255. package/dist/visibility/simulationCulling.d.ts +8 -1
  256. package/dist/visibility/simulationCulling.js +7 -0
  257. package/dist/visibility/spatialIndex.d.ts +21 -0
  258. package/dist/visibility/spatialIndex.js +16 -0
  259. package/dist/world/authoredSolids.d.ts +46 -0
  260. package/dist/world/authoredSolids.js +75 -0
  261. package/dist/world/buildingGenerator.js +2 -2
  262. package/dist/world/buildingKit.d.ts +3 -1
  263. package/dist/world/buildings.d.ts +46 -2
  264. package/dist/world/buildings.js +53 -3
  265. package/dist/world/cityContent.js +103 -25
  266. package/dist/world/cityKind.d.ts +139 -0
  267. package/dist/world/cityKind.js +367 -16
  268. package/dist/world/features.d.ts +56 -3
  269. package/dist/world/features.js +13 -2
  270. package/dist/world/footprintGrid.d.ts +7 -0
  271. package/dist/world/footprintGrid.js +16 -0
  272. package/dist/world/interiors.d.ts +1 -0
  273. package/dist/world/interiors.js +5 -3
  274. package/dist/world/lod.d.ts +6 -0
  275. package/dist/world/lod.js +8 -0
  276. package/dist/world/placement.d.ts +7 -0
  277. package/dist/world/placement.js +2 -0
  278. package/dist/world/placementController.d.ts +1 -1
  279. package/dist/world/placementController.js +7 -0
  280. package/dist/world/raceCircuit.d.ts +168 -0
  281. package/dist/world/raceCircuit.js +797 -0
  282. package/dist/world/terraform.d.ts +8 -0
  283. package/dist/world/terraform.js +7 -0
  284. package/dist/world/terrain.d.ts +5 -1
  285. package/dist/world/terrain.js +5 -0
  286. package/dist/world/territory.d.ts +97 -0
  287. package/dist/world/territory.js +182 -0
  288. package/dist/world/walls.d.ts +7 -0
  289. package/dist/world/walls.js +7 -0
  290. package/dist/world/worldSolids.d.ts +84 -0
  291. package/dist/world/worldSolids.js +240 -0
  292. package/dist/world.d.ts +5 -3
  293. package/dist/world.js +4 -2
  294. package/package.json +1 -1
  295. package/skills/ce-handoff/SKILL.md +2 -2
  296. package/skills/game-design/SKILL.md +11 -1
  297. package/skills/jgengine/SKILL.md +9 -5
  298. package/skills/jgengine/api.md +205 -101
  299. package/skills/jgengine/capabilities.md +56 -0
  300. package/skills/jgengine/recipes/minimal-game.md +3 -0
  301. package/skills/jgengine-combat/SKILL.md +5 -0
  302. package/skills/jgengine-combat/api.md +138 -103
  303. package/skills/jgengine-combat/capabilities.md +29 -1
  304. package/skills/jgengine-combat/recipes/weapon-feel.md +50 -0
  305. package/skills/jgengine-gameplay/SKILL.md +10 -0
  306. package/skills/jgengine-gameplay/api.md +501 -453
  307. package/skills/jgengine-gameplay/capabilities.md +36 -0
  308. package/skills/jgengine-gameplay/reference-systems.md +4 -0
  309. package/skills/jgengine-world/SKILL.md +21 -3
  310. package/skills/jgengine-world/api.md +1172 -625
  311. package/skills/jgengine-world/capabilities.md +229 -3
  312. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  313. package/skills/jgengine-world/recipes/controls.md +44 -0
  314. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  315. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  316. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  317. package/skills/jgengine-world/reference.md +31 -2
  318. package/skills/jgengine-world/visibility.md +112 -0
@@ -1,6 +1,9 @@
1
+ import { createInputBuffer } from "../input/inputBuffer.js";
1
2
  import { applyHorizontalImpulses, applyMotionImpulses } from "../runtime/motionIntents.js";
3
+ import { createCharacterController, } from "./characterController.js";
2
4
  import { groundFieldFor, hasEnvironmentTerrain, sampleSlope } from "../world/terrain.js";
3
- import { advancePlayerMotion, constrainStepToAxis, createEmptyMovementKeys, createPlayerMotionState, DEFAULT_OBSTACLE_PLAYER_RADIUS, obstacleSupportHeight, resolveMovementIntent, resolveObstacleStep, snapPositionToGrid, } from "./movementModel.js";
5
+ import { advancePlayerMotion, constrainStepToAxis, createEmptyMovementKeys, createPlayerMotionState, DEFAULT_OBSTACLE_PLAYER_RADIUS, obstacleSupportHeight, resolveMovementIntent, resolveObstacleStep, snapPositionToGrid, MOVEMENT_TUNING, } from "./movementModel.js";
6
+ import { advanceFreeFlight, createFreeFlightState, resolveFlightStep, resolveFreeFlightIntentFromInput, } from "./freeFlight.js";
4
7
  import { solidObstaclesNear } from "./solidObstacles.js";
5
8
  import { approachYaw, steerYaw } from "./steering.js";
6
9
  import { advanceVoxelPlayer, createVoxelPlayerBody, } from "./voxelController.js";
@@ -34,11 +37,40 @@ export function resolvePhysicsTuning(physics) {
34
37
  export function resolvePlayerMovementTuning(opts) {
35
38
  const physics = resolvePhysicsTuning(opts.physics);
36
39
  const backpedal = opts.movement?.backpedalMult;
37
- const overrides = backpedal === undefined ? physics : { ...(physics ?? {}), backpedalSpeedMultiplier: backpedal };
40
+ const feel = opts.movement?.feel;
41
+ const feelOverrides = {
42
+ ...(backpedal === undefined ? {} : { backpedalSpeedMultiplier: backpedal }),
43
+ ...(feel?.groundAcceleration === undefined ? {} : { groundAcceleration: feel.groundAcceleration }),
44
+ ...(feel?.airAcceleration === undefined ? {} : { airAcceleration: feel.airAcceleration }),
45
+ ...(feel?.groundFriction === undefined ? {} : { groundFriction: feel.groundFriction }),
46
+ ...(feel?.runMultiplier === undefined ? {} : { runSpeedMultiplier: feel.runMultiplier }),
47
+ ...(feel?.crouchMultiplier === undefined ? {} : { crouchSpeedMultiplier: feel.crouchMultiplier }),
48
+ ...(feel?.jumpBufferMs === undefined ? {} : { jumpBufferMs: feel.jumpBufferMs }),
49
+ ...(feel?.coyoteMs === undefined ? {} : { coyoteMs: feel.coyoteMs }),
50
+ ...(feel?.jumpCutFactor === undefined ? {} : { jumpCutFactor: feel.jumpCutFactor }),
51
+ ...(feel?.apexGravityScale === undefined ? {} : { apexGravityScale: feel.apexGravityScale }),
52
+ ...(feel?.apexSpeed === undefined ? {} : { apexSpeed: feel.apexSpeed }),
53
+ ...(feel?.fallGravityScale === undefined ? {} : { fallGravityScale: feel.fallGravityScale }),
54
+ ...(feel?.landingRecoveryMs === undefined ? {} : { landingRecoveryMs: feel.landingRecoveryMs }),
55
+ ...(feel?.landingSpeedScale === undefined ? {} : { landingSpeedScale: feel.landingSpeedScale }),
56
+ };
57
+ const overrides = Object.keys(feelOverrides).length === 0 ? physics : { ...(physics ?? {}), ...feelOverrides };
38
58
  return {
39
59
  ...(opts.collision === undefined ? {} : { collision: opts.collision }),
40
60
  ...(opts.movement === undefined ? {} : { movement: opts.movement }),
41
61
  ...(overrides === undefined ? {} : { physics: overrides }),
62
+ ...(opts.physics?.backend === undefined
63
+ ? {}
64
+ : {
65
+ controller: {
66
+ backend: opts.physics.backend,
67
+ capsule: opts.physics.controller ?? {
68
+ radius: DEFAULT_OBSTACLE_PLAYER_RADIUS,
69
+ height: 1.8,
70
+ stepHeight: DEFAULT_PLAYER_STEP_HEIGHT,
71
+ },
72
+ },
73
+ }),
42
74
  ground: groundFieldFor(opts.world),
43
75
  hasTerrain: hasEnvironmentTerrain(opts.world),
44
76
  };
@@ -58,7 +90,17 @@ function storeFor(ctx) {
58
90
  function stateFor(store, userId) {
59
91
  let state = store.players.get(userId);
60
92
  if (state === undefined) {
61
- state = { heading: 0, facing: null, voxelBody: null, motion: null };
93
+ state = {
94
+ heading: 0,
95
+ facing: null,
96
+ voxelBody: null,
97
+ motion: null,
98
+ flight: null,
99
+ controller: null,
100
+ controllerJumpHeld: false,
101
+ jumpBuffer: null,
102
+ pendingController: null,
103
+ };
62
104
  store.players.set(userId, state);
63
105
  }
64
106
  return state;
@@ -67,6 +109,54 @@ function stateFor(store, userId) {
67
109
  export function playerMovementHeading(ctx, userId) {
68
110
  return stores.get(ctx)?.players.get(userId)?.heading ?? 0;
69
111
  }
112
+ function copyController(state) {
113
+ return { ...state, position: [...state.position], groundNormal: [...state.groundNormal] };
114
+ }
115
+ /** Copy one player's movement state for prediction, rollback or a save; `null` when the player has not moved yet. */
116
+ export function snapshotPlayerMovement(ctx, userId) {
117
+ const state = stores.get(ctx)?.players.get(userId);
118
+ if (state === undefined)
119
+ return null;
120
+ const controller = state.controller?.snapshot() ?? state.pendingController;
121
+ return {
122
+ heading: state.heading,
123
+ facing: state.facing,
124
+ voxelBody: state.voxelBody === null ? null : { ...state.voxelBody },
125
+ motion: state.motion === null ? null : { ...state.motion },
126
+ flight: state.flight === null ? null : { ...state.flight },
127
+ controller: controller === null ? null : copyController(controller),
128
+ controllerJumpHeld: state.controllerJumpHeld,
129
+ jumpBuffer: state.jumpBuffer?.snapshot() ?? null,
130
+ };
131
+ }
132
+ /** Put a player's movement state back to a {@link snapshotPlayerMovement} copy, so the next {@link stepPlayerMovement} replays from there. */
133
+ export function restorePlayerMovement(ctx, userId, snapshot) {
134
+ const state = stateFor(storeFor(ctx), userId);
135
+ state.heading = snapshot.heading;
136
+ state.facing = snapshot.facing;
137
+ state.voxelBody = snapshot.voxelBody === null ? null : { ...snapshot.voxelBody };
138
+ state.motion = snapshot.motion === null ? null : { ...snapshot.motion };
139
+ state.flight = snapshot.flight === null ? null : { ...snapshot.flight };
140
+ state.controllerJumpHeld = snapshot.controllerJumpHeld;
141
+ const jumpBuffer = snapshot.jumpBuffer ?? null;
142
+ if (jumpBuffer === null) {
143
+ state.jumpBuffer = null;
144
+ }
145
+ else {
146
+ state.jumpBuffer ??= createInputBuffer({ windowMs: jumpBuffer.windowMs });
147
+ state.jumpBuffer.restore(jumpBuffer);
148
+ }
149
+ const controller = snapshot.controller === null ? null : copyController(snapshot.controller);
150
+ if (state.controller !== null && controller !== null) {
151
+ state.controller.restore(controller);
152
+ state.pendingController = null;
153
+ }
154
+ else {
155
+ if (controller === null)
156
+ state.controller = null;
157
+ state.pendingController = controller;
158
+ }
159
+ }
70
160
  /** Drop a player's retained movement state (heading + kinematic body) — call on leave so a rejoin starts fresh instead of resuming stale velocity. */
71
161
  export function forgetPlayerMovement(ctx, userId) {
72
162
  stores.get(ctx)?.players.delete(userId);
@@ -90,15 +180,41 @@ function resolveBodyFacing(state, moving, velocityX, velocityZ, fallbackYaw, tur
90
180
  state.facing = next;
91
181
  return next;
92
182
  }
183
+ function flightTuningFor(movement, ctx) {
184
+ const cfg = movement?.flight;
185
+ if (cfg === undefined || cfg === false)
186
+ return null;
187
+ if (cfg === true)
188
+ return { mode: "creative" };
189
+ if (typeof cfg === "object") {
190
+ if (cfg.canFly !== undefined && !cfg.canFly(ctx))
191
+ return null;
192
+ return {
193
+ mode: cfg.mode ?? "creative",
194
+ speed: cfg.speed,
195
+ verticalSpeed: cfg.verticalSpeed,
196
+ sprintMultiplier: cfg.sprintMultiplier,
197
+ acceleration: cfg.acceleration,
198
+ gravity: cfg.gravity,
199
+ thrust: cfg.thrust,
200
+ alignWithLook: cfg.alignWithLook,
201
+ collide: cfg.collide,
202
+ bindings: cfg.bindings,
203
+ camera: cfg.camera ?? null,
204
+ };
205
+ }
206
+ return null;
207
+ }
93
208
  /**
94
209
  * Integrate one player's movement for a tick from their held-input frame and commit the pose — the single
95
210
  * genre-agnostic controller both the shell (its local player) and a host (each connected player in `onTick`) call,
96
211
  * so single-player and server-authoritative movement are identical. Reads the player's controlled entity, terrain,
97
212
  * scene solids, and pending motion impulses; writes the entity pose via `setPose`. Retains heading + kinematic body
98
213
  * per `userId` on the `ctx`. Pass `heading` to override the internally-integrated yaw (the shell owns yaw for its
99
- * camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions.
214
+ * camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions. Pass `pitch` for
215
+ * 6DOF spectator flight when `alignWithLook` is set.
100
216
  */
101
- export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading) {
217
+ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitch) {
102
218
  const playerId = ctx.player.possession.active(userId);
103
219
  const player = ctx.scene.entity.get(playerId);
104
220
  if (player === null)
@@ -141,6 +257,181 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading) {
141
257
  const intent = resolveMovementIntent(keys, true, analogMove);
142
258
  const motionBatch = ctx.player.motionFor(userId).takePending();
143
259
  const walkSpeed = player.movement?.walkSpeed ?? DEFAULT_WALK_SPEED;
260
+ const flightTuning = flightTuningFor(tuning.movement, ctx);
261
+ if (flightTuning !== null) {
262
+ const value = (action) => input.analog?.[action] ?? (isDown(action) ? 1 : 0);
263
+ let flightIntent = resolveFreeFlightIntentFromInput(isDown, value, input.pointer, flightTuning.bindings);
264
+ if (tuning.movement?.canSprint !== undefined && !tuning.movement.canSprint(ctx)) {
265
+ flightIntent = { ...flightIntent, sprint: false };
266
+ }
267
+ let flightState = state.flight;
268
+ if (flightState === null) {
269
+ flightState = createFreeFlightState();
270
+ state.flight = flightState;
271
+ }
272
+ if (motionBatch !== null) {
273
+ flightState.vy = applyMotionImpulses(flightState.vy, motionBatch);
274
+ const [hx, hz] = applyHorizontalImpulses(flightState.vx, flightState.vz, motionBatch);
275
+ flightState.vx = hx;
276
+ flightState.vz = hz;
277
+ }
278
+ const normalizedFlightTuning = {
279
+ mode: flightTuning.mode,
280
+ speed: flightTuning.speed,
281
+ verticalSpeed: flightTuning.verticalSpeed,
282
+ sprintMultiplier: flightTuning.sprintMultiplier,
283
+ acceleration: flightTuning.acceleration,
284
+ gravity: flightTuning.gravity,
285
+ thrust: flightTuning.thrust,
286
+ alignWithLook: flightTuning.alignWithLook,
287
+ collide: flightTuning.collide,
288
+ bindings: flightTuning.bindings,
289
+ camera: flightTuning.camera,
290
+ };
291
+ if (normalizedFlightTuning.collide === undefined) {
292
+ normalizedFlightTuning.collide = normalizedFlightTuning.mode === "creative" || normalizedFlightTuning.mode === "hover";
293
+ }
294
+ if (normalizedFlightTuning.camera !== undefined) {
295
+ const cam = ctx.camera;
296
+ cam?.setChaseTuning(normalizedFlightTuning.camera ?? null);
297
+ }
298
+ const step = advanceFreeFlight(flightState, flightIntent, state.heading, pitch, dt, normalizedFlightTuning);
299
+ let stepX = step.stepX;
300
+ let stepY = step.stepY;
301
+ let stepZ = step.stepZ;
302
+ if (normalizedFlightTuning.collide !== false && tuning.movement?.collideObjects !== false) {
303
+ const obstacles = gatherMovementObstacles(ctx, player.position, stepX, stepZ);
304
+ const verticalResolved = resolveFlightStep(player.position, stepX, stepY, stepZ, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS);
305
+ stepY = verticalResolved.stepY;
306
+ const xzResolved = resolveObstacleStep(player.position, stepX, stepZ, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, 0);
307
+ stepX = xzResolved.stepX;
308
+ stepZ = xzResolved.stepZ;
309
+ const ground = tuning.ground.sampleHeight(player.position[0] + stepX, player.position[2] + stepZ);
310
+ const nextY = player.position[1] + stepY;
311
+ if (nextY < ground + 0.2) {
312
+ stepY = ground + 0.2 - player.position[1];
313
+ if (flightState.vy < 0)
314
+ flightState.vy = 0;
315
+ }
316
+ }
317
+ let nextX = player.position[0] + stepX;
318
+ let nextY = player.position[1] + stepY;
319
+ let nextZ = player.position[2] + stepZ;
320
+ if (motionBatch !== null && motionBatch.y !== null) {
321
+ nextY = motionBatch.y;
322
+ }
323
+ if (tuning.movement?.beforeCommit !== undefined) {
324
+ const frame = {
325
+ entityId: playerId,
326
+ current: player.position,
327
+ next: [nextX, nextY, nextZ],
328
+ dt,
329
+ ctx,
330
+ };
331
+ const replacement = tuning.movement.beforeCommit(frame);
332
+ if (replacement !== undefined) {
333
+ nextX = replacement[0];
334
+ nextY = replacement[1];
335
+ nextZ = replacement[2];
336
+ }
337
+ }
338
+ ctx.scene.entity.setPose(playerId, {
339
+ position: [nextX, nextY, nextZ],
340
+ rotationY: resolveBodyFacing(state, flightIntent.moving, flightState.vx, flightState.vz, player.rotationY, tuning.movement?.turnSpeed, dt),
341
+ dt,
342
+ });
343
+ return;
344
+ }
345
+ if (flightTuning === null && state.flight !== null) {
346
+ const cam = ctx.camera;
347
+ cam?.setChaseTuning(null);
348
+ }
349
+ if (tuning.controller !== undefined) {
350
+ let controller = state.controller;
351
+ if (controller === null) {
352
+ controller = createCharacterController(tuning.controller.capsule);
353
+ state.controller = controller;
354
+ controller.restore(state.pendingController ?? {
355
+ position: [player.position[0], player.position[1], player.position[2]],
356
+ verticalVelocity: 0,
357
+ grounded: true,
358
+ groundNormal: [0, 1, 0],
359
+ groundBody: null,
360
+ crouching: false,
361
+ });
362
+ state.pendingController = null;
363
+ }
364
+ const controllerState = controller.state();
365
+ if (Math.hypot(controllerState.position[0] - player.position[0], controllerState.position[1] - player.position[1], controllerState.position[2] - player.position[2]) > 1e-6) {
366
+ controller.restore({
367
+ ...controllerState,
368
+ position: [player.position[0], player.position[1], player.position[2]],
369
+ grounded: false,
370
+ groundBody: null,
371
+ });
372
+ }
373
+ // Keep the existing input feel and horizontal impulse seam; the capsule owns vertical motion and collision.
374
+ let horizontal = state.motion;
375
+ if (horizontal === null) {
376
+ horizontal = createPlayerMotionState();
377
+ state.motion = horizontal;
378
+ }
379
+ const grounded = controller.state().grounded;
380
+ horizontal.grounded = grounded;
381
+ horizontal.verticalVelocity = 0;
382
+ horizontal.jumpOffset = 0;
383
+ [horizontal.horizontalVelocityX, horizontal.horizontalVelocityZ] = applyHorizontalImpulses(horizontal.horizontalVelocityX, horizontal.horizontalVelocityZ, motionBatch);
384
+ const jumpPressed = intent.jumping && !state.controllerJumpHeld;
385
+ const horizontalStep = advancePlayerMotion(horizontal, { ...intent, jumping: false }, forwardX, forwardZ, walkSpeed, dt, tuning.physics);
386
+ horizontal.grounded = grounded;
387
+ horizontal.verticalVelocity = 0;
388
+ horizontal.jumpOffset = 0;
389
+ state.controllerJumpHeld = intent.jumping;
390
+ const current = controller.state();
391
+ controller.restore({
392
+ ...current,
393
+ verticalVelocity: applyMotionImpulses(current.verticalVelocity, motionBatch),
394
+ });
395
+ const result = controller.move(tuning.controller.backend, {
396
+ motion: [horizontalStep.stepX, 0, horizontalStep.stepZ],
397
+ dt,
398
+ gravity: tuning.physics?.gravityAcceleration ?? MOVEMENT_TUNING.gravityAcceleration,
399
+ ...(jumpPressed && !intent.crouching
400
+ ? { jumpVelocity: tuning.physics?.jumpVelocity ?? MOVEMENT_TUNING.jumpVelocity }
401
+ : {}),
402
+ crouch: intent.crouching,
403
+ });
404
+ let nextPosition = [
405
+ controller.state().position[0],
406
+ controller.state().position[1],
407
+ controller.state().position[2],
408
+ ];
409
+ if (motionBatch !== null && motionBatch.y !== null) {
410
+ nextPosition[1] = motionBatch.y;
411
+ controller.restore({ ...controller.state(), position: nextPosition });
412
+ }
413
+ if (tuning.movement?.beforeCommit !== undefined) {
414
+ const frame = {
415
+ entityId: playerId,
416
+ current: player.position,
417
+ next: nextPosition,
418
+ dt,
419
+ ctx,
420
+ };
421
+ const replacement = tuning.movement.beforeCommit(frame);
422
+ if (replacement !== undefined) {
423
+ nextPosition = [replacement[0], replacement[1], replacement[2]];
424
+ controller.restore({ ...controller.state(), position: nextPosition });
425
+ }
426
+ }
427
+ horizontal.grounded = result.grounded;
428
+ ctx.scene.entity.setPose(playerId, {
429
+ position: nextPosition,
430
+ rotationY: resolveBodyFacing(state, intent.moving, horizontal.horizontalVelocityX, horizontal.horizontalVelocityZ, player.rotationY, tuning.movement?.turnSpeed, dt),
431
+ dt,
432
+ });
433
+ return;
434
+ }
144
435
  if (tuning.collision?.voxel === true) {
145
436
  let body = state.voxelBody;
146
437
  if (body === null) {
@@ -187,9 +478,15 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading) {
187
478
  const submerged = swimEnabled &&
188
479
  waterLevel !== undefined &&
189
480
  tuning.ground.sampleHeight(player.position[0], player.position[2]) < waterLevel;
481
+ const jumpBufferMs = tuning.physics?.jumpBufferMs ?? 0;
482
+ let jumpBuffer = state.jumpBuffer;
483
+ if (jumpBuffer === null) {
484
+ jumpBuffer = createInputBuffer({ windowMs: jumpBufferMs });
485
+ state.jumpBuffer = jumpBuffer;
486
+ }
190
487
  const motionOptions = submerged
191
- ? { speedScale: swimSpeedMultiplier, floating: true }
192
- : undefined;
488
+ ? { speedScale: swimSpeedMultiplier, floating: true, buffer: jumpBuffer }
489
+ : { buffer: jumpBuffer };
193
490
  const prevJumpOffset = motion.jumpOffset;
194
491
  const step = advancePlayerMotion(motion, intent, forwardX, forwardZ, walkSpeed, dt, tuning.physics, motionOptions);
195
492
  // Airborne means "not resting on the surface below": any jump/impulse height before or after this
@@ -0,0 +1,5 @@
1
+ import type { GameContext } from "../runtime/gameContext.js";
2
+ import type { InputFrame } from "../runtime/inputSnapshot.js";
3
+ import { type PlayerMovementTuning } from "./playerMovement.js";
4
+ /** Advance one authoritative player's movement using the same controller as the shell. */
5
+ export declare function serverStep(ctx: GameContext, userId: string, frame: InputFrame, dt: number, tuning?: PlayerMovementTuning): void;
@@ -0,0 +1,6 @@
1
+ import { groundFieldFor } from "../world/terrain.js";
2
+ import { stepPlayerMovement } from "./playerMovement.js";
3
+ /** Advance one authoritative player's movement using the same controller as the shell. */
4
+ export function serverStep(ctx, userId, frame, dt, tuning) {
5
+ stepPlayerMovement(ctx, userId, frame, dt, tuning ?? { ground: groundFieldFor(), hasTerrain: false });
6
+ }
@@ -1,6 +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
5
  import { type CollisionObstacle } from "./movementModel.js";
5
6
  /** Largest blocking-collider extent in the scene, split so tower height cannot inflate the XZ query. */
6
7
  export interface ObstacleReach {
@@ -11,8 +12,8 @@ export interface ObstacleReach {
11
12
  }
12
13
  /**
13
14
  * The slice of the scene's object store this query needs. Narrow on purpose: the scene context wires
14
- * its own store here while it is still being constructed, and `ctx.scene.object` satisfies it as-is.
15
- * @internal
15
+ * its own store here while it is still being constructed, `ctx.scene.object` satisfies it as-is, and a
16
+ * pure rules package with no `GameContext` to import can satisfy it from its own object list.
16
17
  */
17
18
  export interface SolidObstacleSource {
18
19
  list(): readonly {
@@ -27,12 +28,12 @@ export interface SolidObstacleSource {
27
28
  }[];
28
29
  collidersOf(instanceId: string): Parameters<typeof resolveColliders>[0] | null;
29
30
  }
30
- /** Mutable reach cache; one per source, invalidated by object count. @internal */
31
+ /** Mutable reach cache; one per source, invalidated by object count. */
31
32
  export interface ObstacleReachCache {
32
33
  count: number;
33
34
  value: ObstacleReach;
34
35
  }
35
- /** A fresh {@link ObstacleReachCache} for a caller that owns its own source. @internal */
36
+ /** A fresh {@link ObstacleReachCache} for a caller that owns its own source. */
36
37
  export declare function createObstacleReachCache(): ObstacleReachCache;
37
38
  /**
38
39
  * Largest blocking reach in the scene, split into horizontal vs vertical and hard-capped. Cached per
@@ -41,7 +42,7 @@ export declare function createObstacleReachCache(): ObstacleReachCache;
41
42
  * @internal
42
43
  */
43
44
  export declare function solidObstacleReach(ctx: GameContext): ObstacleReach;
44
- /** {@link solidObstacleReach} against a bare source and caller-owned cache. @internal */
45
+ /** {@link solidObstacleReach} against a bare source and caller-owned cache. */
45
46
  export declare function sourceObstacleReach(source: SolidObstacleSource, cache: ObstacleReachCache): ObstacleReach;
46
47
  /** One blocking collider as a {@link CollisionObstacle}: a compound `boxes` obstacle for a mesh collider
47
48
  * carrying a small sub-box set (openings), else a single half-extents/offset AABB. Yaw is applied by
@@ -49,9 +50,16 @@ export declare function sourceObstacleReach(source: SolidObstacleSource, cache:
49
50
  * never under-covers.
50
51
  * @internal */
51
52
  export declare function obstacleFromCollider(collider: ResolvedCollider, position: EntityPosition, rotationY: number): CollisionObstacle;
53
+ /**
54
+ * A {@link WorldSolid} as a {@link CollisionObstacle}. Axis-aligned solids are one box; a yawed solid is
55
+ * cut into strips along its long axis, each fitted with an AABB, so a turned building does not block
56
+ * the street beside it the way a single enclosing box would.
57
+ * @capability solid-obstacles the one blocking-geometry query the player resolver and NPC movers share
58
+ */
59
+ export declare function obstacleFromSolid(solid: WorldSolid): CollisionObstacle;
52
60
  /**
53
61
  * Every blocking physical obstacle overlapping the box `position` ± `reachX`/`reachZ`, read from the
54
- * scene's resolved colliders.
62
+ * scene's resolved colliders and `ctx.world.solids`.
55
63
  *
56
64
  * This is the engine's single answer to "what is solid here". The player resolver, a walking NPC, and
57
65
  * anything else that has to stop at a wall read it rather than deriving their own set — three
@@ -61,7 +69,7 @@ export declare function obstacleFromCollider(collider: ResolvedCollider, positio
61
69
  * @capability solid-obstacles the one blocking-geometry query the player resolver and NPC movers share
62
70
  */
63
71
  export declare function solidObstaclesNear(ctx: GameContext, position: EntityPosition, reachX: number, reachZ: number, height?: number): CollisionObstacle[];
64
- /** {@link solidObstaclesNear} against a bare source and an already-resolved reach. @internal */
72
+ /** {@link solidObstaclesNear} against a bare source and an already-resolved reach. */
65
73
  export declare function sourceObstaclesNear(source: SolidObstacleSource, reach: ObstacleReach, position: EntityPosition, reachX: number, reachZ: number, height?: number): CollisionObstacle[];
66
74
  /**
67
75
  * Slide a walker's horizontal step against the same solid geometry that stops the player, returning the
@@ -71,6 +79,11 @@ export declare function sourceObstaclesNear(source: SolidObstacleSource, reach:
71
79
  * Bounded by the same broadphase as the player's gather: one `inBox` query sized to this step, never a
72
80
  * full-world scan, so it stays affordable per NPC per tick.
73
81
  *
82
+ * The sliding is already axis-separated: X and Z are resolved independently inside, so a caller that
83
+ * also splits its own movement by axis must hand this exactly one axis per call (`stepZ: 0`, then
84
+ * `stepX: 0`). Running an axis-split pass over a step this function already resolved double-cuts it,
85
+ * and the composition is wrong in either order.
86
+ *
74
87
  * @capability walker-solid-step slide an NPC's step against the geometry that blocks the player
75
88
  */
76
89
  export declare function resolveWalkerStep(ctx: GameContext, position: EntityPosition, stepX: number, stepZ: number, options?: {
@@ -80,7 +93,25 @@ export declare function resolveWalkerStep(ctx: GameContext, position: EntityPosi
80
93
  stepX: number;
81
94
  stepZ: number;
82
95
  };
83
- /** {@link resolveWalkerStep} against an already-gathered obstacle set. @internal */
96
+ /**
97
+ * {@link resolveWalkerStep} with no `GameContext`: gather plus slide against a bare
98
+ * {@link SolidObstacleSource} and a caller-owned {@link ObstacleReachCache}.
99
+ *
100
+ * This is the entry point for rules that live in a pure package — no React, no three.js, no engine
101
+ * context to import. Hold one cache per source for the life of that source (`createObstacleReachCache()`)
102
+ * rather than making a fresh one per step; it is what keeps the reach scan off the per-step path.
103
+ * The same axis-separation note as {@link resolveWalkerStep} applies.
104
+ *
105
+ * @capability walker-solid-step-source slide a step against solid geometry from a pure rules package
106
+ */
107
+ export declare function resolveSourceWalkerStep(source: SolidObstacleSource, cache: ObstacleReachCache, position: EntityPosition, stepX: number, stepZ: number, options?: {
108
+ radius?: number;
109
+ stepUpHeight?: number;
110
+ }): {
111
+ stepX: number;
112
+ stepZ: number;
113
+ };
114
+ /** {@link resolveWalkerStep} against an already-gathered obstacle set. */
84
115
  export declare function slideStep(position: EntityPosition, stepX: number, stepZ: number, obstacles: readonly CollisionObstacle[], radius?: number, stepUpHeight?: number): {
85
116
  stepX: number;
86
117
  stepZ: number;
@@ -26,9 +26,13 @@ const OBSTACLE_VERTICAL_CAP = 8;
26
26
  * multiply narrowphase cost without changing the blocked footprint.
27
27
  */
28
28
  const MOVEMENT_MESH_BOX_BUDGET = 12;
29
+ /** Most AABB strips an oriented world solid is sliced into, bounding the corner overhang a yawed box leaves. */
30
+ const SOLID_MAX_STRIPS = 8;
31
+ /** Yaw within this of a quarter turn is treated as axis-aligned (radians). */
32
+ const AXIS_ALIGNED_EPSILON = 1e-4;
29
33
  /** Feet-to-head span a walker is tested over — matches the player's, so both agree on what a wall is. */
30
34
  const WALKER_HEIGHT = 1.8;
31
- /** A fresh {@link ObstacleReachCache} for a caller that owns its own source. @internal */
35
+ /** A fresh {@link ObstacleReachCache} for a caller that owns its own source. */
32
36
  export function createObstacleReachCache() {
33
37
  return { count: -1, value: { horizontal: OBSTACLE_MAX_HALF_EXTENT, vertical: OBSTACLE_VERTICAL_FLOOR } };
34
38
  }
@@ -42,7 +46,7 @@ const reachCache = perContext(() => createObstacleReachCache());
42
46
  export function solidObstacleReach(ctx) {
43
47
  return sourceObstacleReach(ctx.scene.object, reachCache(ctx));
44
48
  }
45
- /** {@link solidObstacleReach} against a bare source and caller-owned cache. @internal */
49
+ /** {@link solidObstacleReach} against a bare source and caller-owned cache. */
46
50
  export function sourceObstacleReach(source, cache) {
47
51
  const objects = source.list();
48
52
  if (cache.count === objects.length)
@@ -141,9 +145,40 @@ export function obstacleFromCollider(collider, position, rotationY) {
141
145
  halfExtents: [Math.abs(hx * cos) + Math.abs(hz * sin), hy, Math.abs(hx * sin) + Math.abs(hz * cos)],
142
146
  };
143
147
  }
148
+ /**
149
+ * A {@link WorldSolid} as a {@link CollisionObstacle}. Axis-aligned solids are one box; a yawed solid is
150
+ * cut into strips along its long axis, each fitted with an AABB, so a turned building does not block
151
+ * the street beside it the way a single enclosing box would.
152
+ * @capability solid-obstacles the one blocking-geometry query the player resolver and NPC movers share
153
+ */
154
+ export function obstacleFromSolid(solid) {
155
+ const [hx, hy, hz] = solid.halfExtents;
156
+ const yaw = solid.rotationY ?? 0;
157
+ const quarter = yaw / (Math.PI / 2);
158
+ const nearest = Math.round(quarter);
159
+ if (Math.abs(quarter - nearest) < AXIS_ALIGNED_EPSILON) {
160
+ const swapped = Math.abs(nearest) % 2 === 1;
161
+ return { position: solid.center, halfExtents: swapped ? [hz, hy, hx] : [hx, hy, hz] };
162
+ }
163
+ const alongX = hx >= hz;
164
+ const long = alongX ? hx : hz;
165
+ const short = Math.max(alongX ? hz : hx, 1e-6);
166
+ const strips = Math.min(SOLID_MAX_STRIPS, Math.max(1, Math.ceil(long / short)));
167
+ const cos = Math.cos(yaw);
168
+ const sin = Math.sin(yaw);
169
+ const boxes = [];
170
+ for (let i = 0; i < strips; i += 1) {
171
+ const from = -long + (2 * long * i) / strips;
172
+ const to = from + (2 * long) / strips;
173
+ const boxMin = alongX ? [from, -hy, -hz] : [-hx, -hy, from];
174
+ const boxMax = alongX ? [to, hy, hz] : [hx, hy, to];
175
+ boxes.push(yawExpandLocalBox(boxMin, boxMax, cos, sin));
176
+ }
177
+ return { position: solid.center, boxes };
178
+ }
144
179
  /**
145
180
  * Every blocking physical obstacle overlapping the box `position` ± `reachX`/`reachZ`, read from the
146
- * scene's resolved colliders.
181
+ * scene's resolved colliders and `ctx.world.solids`.
147
182
  *
148
183
  * This is the engine's single answer to "what is solid here". The player resolver, a walking NPC, and
149
184
  * anything else that has to stop at a wall read it rather than deriving their own set — three
@@ -153,9 +188,16 @@ export function obstacleFromCollider(collider, position, rotationY) {
153
188
  * @capability solid-obstacles the one blocking-geometry query the player resolver and NPC movers share
154
189
  */
155
190
  export function solidObstaclesNear(ctx, position, reachX, reachZ, height = WALKER_HEIGHT) {
156
- return sourceObstaclesNear(ctx.scene.object, solidObstacleReach(ctx), position, reachX, reachZ, height);
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;
195
+ 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
+ obstacles.push(obstacleFromSolid(solid));
197
+ }
198
+ return obstacles;
157
199
  }
158
- /** {@link solidObstaclesNear} against a bare source and an already-resolved reach. @internal */
200
+ /** {@link solidObstaclesNear} against a bare source and an already-resolved reach. */
159
201
  export function sourceObstaclesNear(source, reach, position, reachX, reachZ, height = WALKER_HEIGHT) {
160
202
  const min = [
161
203
  position[0] - reachX - reach.horizontal,
@@ -191,6 +233,11 @@ export function sourceObstaclesNear(source, reach, position, reachX, reachZ, hei
191
233
  * Bounded by the same broadphase as the player's gather: one `inBox` query sized to this step, never a
192
234
  * full-world scan, so it stays affordable per NPC per tick.
193
235
  *
236
+ * The sliding is already axis-separated: X and Z are resolved independently inside, so a caller that
237
+ * also splits its own movement by axis must hand this exactly one axis per call (`stepZ: 0`, then
238
+ * `stepX: 0`). Running an axis-split pass over a step this function already resolved double-cuts it,
239
+ * and the composition is wrong in either order.
240
+ *
194
241
  * @capability walker-solid-step slide an NPC's step against the geometry that blocks the player
195
242
  */
196
243
  export function resolveWalkerStep(ctx, position, stepX, stepZ, options = {}) {
@@ -200,7 +247,25 @@ export function resolveWalkerStep(ctx, position, stepX, stepZ, options = {}) {
200
247
  const obstacles = solidObstaclesNear(ctx, position, Math.abs(stepX) + radius, Math.abs(stepZ) + radius);
201
248
  return slideStep(position, stepX, stepZ, obstacles, radius, options.stepUpHeight ?? 0);
202
249
  }
203
- /** {@link resolveWalkerStep} against an already-gathered obstacle set. @internal */
250
+ /**
251
+ * {@link resolveWalkerStep} with no `GameContext`: gather plus slide against a bare
252
+ * {@link SolidObstacleSource} and a caller-owned {@link ObstacleReachCache}.
253
+ *
254
+ * This is the entry point for rules that live in a pure package — no React, no three.js, no engine
255
+ * context to import. Hold one cache per source for the life of that source (`createObstacleReachCache()`)
256
+ * rather than making a fresh one per step; it is what keeps the reach scan off the per-step path.
257
+ * The same axis-separation note as {@link resolveWalkerStep} applies.
258
+ *
259
+ * @capability walker-solid-step-source slide a step against solid geometry from a pure rules package
260
+ */
261
+ export function resolveSourceWalkerStep(source, cache, position, stepX, stepZ, options = {}) {
262
+ if (stepX === 0 && stepZ === 0)
263
+ return { stepX, stepZ };
264
+ const radius = options.radius ?? DEFAULT_OBSTACLE_PLAYER_RADIUS;
265
+ const obstacles = sourceObstaclesNear(source, sourceObstacleReach(source, cache), position, Math.abs(stepX) + radius, Math.abs(stepZ) + radius);
266
+ return slideStep(position, stepX, stepZ, obstacles, radius, options.stepUpHeight ?? 0);
267
+ }
268
+ /** {@link resolveWalkerStep} against an already-gathered obstacle set. */
204
269
  export function slideStep(position, stepX, stepZ, obstacles, radius = DEFAULT_OBSTACLE_PLAYER_RADIUS, stepUpHeight = 0) {
205
270
  if (obstacles.length === 0)
206
271
  return { stepX, stepZ };
@@ -97,7 +97,7 @@ function supportTopWithin(x, z, fromY, toY, isSolid, dims) {
97
97
  }
98
98
  return best;
99
99
  }
100
- function targetHorizontalVelocity(intent, forwardX, forwardZ, baseSpeed) {
100
+ function targetHorizontalVelocity(intent, forwardX, forwardZ, baseSpeed, tuning) {
101
101
  if (!intent.moving)
102
102
  return { x: 0, z: 0 };
103
103
  let fx = forwardX;
@@ -120,9 +120,9 @@ function targetHorizontalVelocity(intent, forwardX, forwardZ, baseSpeed) {
120
120
  if (magSq < 1e-6)
121
121
  return { x: 0, z: 0 };
122
122
  const speedMultiplier = intent.crouching
123
- ? MOVEMENT_TUNING.crouchSpeedMultiplier
123
+ ? (tuning?.crouchSpeedMultiplier ?? MOVEMENT_TUNING.crouchSpeedMultiplier)
124
124
  : intent.running
125
- ? MOVEMENT_TUNING.runSpeedMultiplier
125
+ ? (tuning?.runSpeedMultiplier ?? MOVEMENT_TUNING.runSpeedMultiplier)
126
126
  : 1;
127
127
  const targetSpeed = baseSpeed * MOVEMENT_TUNING.walkSpeedMultiplier * speedMultiplier;
128
128
  const scale = targetSpeed / Math.sqrt(magSq);
@@ -176,15 +176,15 @@ export function advanceVoxelPlayer(body, intent, forwardX, forwardZ, baseSpeed,
176
176
  const dt = Math.min(rawDeltaSeconds, MOVEMENT_TUNING.maxFrameSeconds);
177
177
  const gravityAcceleration = tuning?.gravityAcceleration ?? MOVEMENT_TUNING.gravityAcceleration;
178
178
  const jumpVelocity = tuning?.jumpVelocity ?? MOVEMENT_TUNING.jumpVelocity;
179
- const target = targetHorizontalVelocity(intent, forwardX, forwardZ, baseSpeed);
179
+ const target = targetHorizontalVelocity(intent, forwardX, forwardZ, baseSpeed, tuning);
180
180
  const acceleration = body.grounded
181
- ? MOVEMENT_TUNING.groundAcceleration
182
- : MOVEMENT_TUNING.airAcceleration;
181
+ ? (tuning?.groundAcceleration ?? MOVEMENT_TUNING.groundAcceleration)
182
+ : (tuning?.airAcceleration ?? MOVEMENT_TUNING.airAcceleration);
183
183
  const accelerationBlend = 1 - Math.exp(-acceleration * dt);
184
184
  body.velocityX += (target.x - body.velocityX) * accelerationBlend;
185
185
  body.velocityZ += (target.z - body.velocityZ) * accelerationBlend;
186
186
  if (!intent.moving && body.grounded) {
187
- const friction = Math.exp(-MOVEMENT_TUNING.groundFriction * dt);
187
+ const friction = Math.exp(-(tuning?.groundFriction ?? MOVEMENT_TUNING.groundFriction) * dt);
188
188
  body.velocityX *= friction;
189
189
  body.velocityZ *= friction;
190
190
  }
@@ -16,4 +16,6 @@ export interface PoseSyncTuning {
16
16
  export interface PoseSyncGate {
17
17
  evaluate(pose: PlayerPose, nowMs: number): boolean;
18
18
  }
19
- export declare function createPoseSyncGate(tuning: PoseSyncTuning): PoseSyncGate;
19
+ /** Default ten-hertz client pose gate with a five-second heartbeat. */
20
+ export declare const DEFAULT_POSE_SYNC_TUNING: PoseSyncTuning;
21
+ export declare function createPoseSyncGate(tuning?: PoseSyncTuning): PoseSyncGate;