@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
@@ -2,11 +2,14 @@ import { createInputBuffer } from "../input/inputBuffer.js";
2
2
  import { applyHorizontalImpulses, applyMotionImpulses } from "../runtime/motionIntents.js";
3
3
  import { createCharacterController, } from "./characterController.js";
4
4
  import { groundFieldFor, hasEnvironmentTerrain, sampleSlope } from "../world/terrain.js";
5
- import { advancePlayerMotion, constrainStepToAxis, createEmptyMovementKeys, createPlayerMotionState, DEFAULT_OBSTACLE_PLAYER_RADIUS, obstacleSupportHeight, resolveMovementIntent, resolveObstacleStep, snapPositionToGrid, MOVEMENT_TUNING, } from "./movementModel.js";
5
+ import { advancePlayerMotion, constrainStepToAxis, createEmptyMovementKeys, createPlayerMotionState, DEFAULT_OBSTACLE_PLAYER_RADIUS, DEFAULT_OBSTACLE_PLAYER_HEIGHT, motionStepSeconds, obstacleSupportHeight, resolveMovementIntent, resolveObstacleStep, resolveObstacleVerticalStep, snapPositionToGrid, } from "./movementModel.js";
6
6
  import { advanceFreeFlight, createFreeFlightState, resolveFlightStep, resolveFreeFlightIntentFromInput, } from "./freeFlight.js";
7
7
  import { solidObstaclesNear } from "./solidObstacles.js";
8
+ import { withPoseRollback } from "./poseState.js";
8
9
  import { approachYaw, steerYaw } from "./steering.js";
10
+ import { resolveTerrainGradeStep } from "./terrainGrade.js";
9
11
  import { advanceVoxelPlayer, createVoxelPlayerBody, } from "./voxelController.js";
12
+ const CONTROLLER_CONFIG_FIELDS = ["radius", "height", "stepHeight", "maxSlopeDeg", "skinWidth", "crouchHeight", "snapDistance", "maxSlides", "mask"];
10
13
  const DEFAULT_TURN_SPEED = 2.4;
11
14
  const DEFAULT_WALK_SPEED = 2;
12
15
  const DEFAULT_SWIM_SPEED_MULTIPLIER = 0.65;
@@ -35,42 +38,41 @@ export function resolvePhysicsTuning(physics) {
35
38
  }
36
39
  /** Gather a game's collision/movement/physics/world config into a {@link PlayerMovementTuning} — call once per world; both the shell and a host pass the result to {@link stepPlayerMovement}. */
37
40
  export function resolvePlayerMovementTuning(opts) {
38
- const physics = resolvePhysicsTuning(opts.physics);
39
- const backpedal = opts.movement?.backpedalMult;
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 }),
41
+ const overrides = {
42
+ get gravityAcceleration() { return opts.physics?.gravity === undefined ? undefined : -opts.physics.gravity; },
43
+ get jumpVelocity() { return opts.physics?.jumpVelocity; },
44
+ get backpedalSpeedMultiplier() { return opts.movement?.backpedalMult; },
45
+ get groundAcceleration() { return opts.movement?.feel?.groundAcceleration; },
46
+ get airAcceleration() { return opts.movement?.feel?.airAcceleration; },
47
+ get groundFriction() { return opts.movement?.feel?.groundFriction; },
48
+ get runSpeedMultiplier() { return opts.movement?.feel?.runMultiplier; },
49
+ get crouchSpeedMultiplier() { return opts.movement?.feel?.crouchMultiplier; },
50
+ get jumpBufferMs() { return opts.movement?.feel?.jumpBufferMs; },
51
+ get coyoteMs() { return opts.movement?.feel?.coyoteMs; },
52
+ get jumpCutFactor() { return opts.movement?.feel?.jumpCutFactor; },
53
+ get apexGravityScale() { return opts.movement?.feel?.apexGravityScale; },
54
+ get apexSpeed() { return opts.movement?.feel?.apexSpeed; },
55
+ get fallGravityScale() { return opts.movement?.feel?.fallGravityScale; },
56
+ get landingRecoveryMs() { return opts.movement?.feel?.landingRecoveryMs; },
57
+ get landingSpeedScale() { return opts.movement?.feel?.landingSpeedScale; },
58
+ };
59
+ const defaultCapsule = {
60
+ radius: DEFAULT_OBSTACLE_PLAYER_RADIUS,
61
+ height: 1.8,
62
+ stepHeight: DEFAULT_PLAYER_STEP_HEIGHT,
63
+ };
64
+ const controller = {
65
+ get backend() { return opts.physics.backend; },
66
+ get capsule() { return opts.physics?.controller ?? defaultCapsule; },
56
67
  };
57
- const overrides = Object.keys(feelOverrides).length === 0 ? physics : { ...(physics ?? {}), ...feelOverrides };
58
68
  return {
59
- ...(opts.collision === undefined ? {} : { collision: opts.collision }),
60
- ...(opts.movement === undefined ? {} : { movement: opts.movement }),
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
- }),
69
+ get collision() { return opts.collision; },
70
+ get movement() { return opts.movement; },
71
+ get physics() {
72
+ return opts.physics === undefined && opts.movement?.feel === undefined && opts.movement?.backpedalMult === undefined
73
+ ? undefined : overrides;
74
+ },
75
+ get controller() { return opts.physics?.backend === undefined ? undefined : controller; },
74
76
  ground: groundFieldFor(opts.world),
75
77
  hasTerrain: hasEnvironmentTerrain(opts.world),
76
78
  };
@@ -84,10 +86,24 @@ function storeFor(ctx) {
84
86
  solids: { count: -1, set: new Set() },
85
87
  };
86
88
  stores.set(ctx, store);
89
+ const retained = store;
90
+ ctx.scene.entity.subscribeMembership(() => {
91
+ for (const [userId, state] of retained.players) {
92
+ if (state.liveEntityId !== null) {
93
+ if (ctx.scene.entity.get(state.liveEntityId) === null)
94
+ retained.players.delete(userId);
95
+ }
96
+ else {
97
+ const entityId = ctx.player.possession.active(userId);
98
+ if (ctx.scene.entity.get(entityId) !== null)
99
+ state.liveEntityId = entityId;
100
+ }
101
+ }
102
+ });
87
103
  }
88
104
  return store;
89
105
  }
90
- function stateFor(store, userId) {
106
+ function stateFor(ctx, store, userId) {
91
107
  let state = store.players.get(userId);
92
108
  if (state === undefined) {
93
109
  state = {
@@ -97,14 +113,95 @@ function stateFor(store, userId) {
97
113
  motion: null,
98
114
  flight: null,
99
115
  controller: null,
116
+ controllerConfig: null,
100
117
  controllerJumpHeld: false,
101
118
  jumpBuffer: null,
102
119
  pendingController: null,
120
+ heightfieldHeight: null,
121
+ heightfieldEntityId: null,
122
+ liveEntityId: null,
123
+ entityId: null,
124
+ telemetry: null,
103
125
  };
126
+ const entityId = ctx.player.possession.active(userId);
127
+ if (ctx.scene.entity.get(entityId) !== null)
128
+ state.liveEntityId = entityId;
104
129
  store.players.set(userId, state);
105
130
  }
106
131
  return state;
107
132
  }
133
+ function invalidateTelemetry(state) {
134
+ state.entityId = null;
135
+ state.telemetry = null;
136
+ }
137
+ function validateCollisionHeight(height) {
138
+ if (!Number.isFinite(height) || height <= 0) {
139
+ throw new RangeError("PlayerMovementConfig.collisionHeight must be finite and positive.");
140
+ }
141
+ }
142
+ function retuneController(state, config) {
143
+ const previous = state.controllerConfig;
144
+ if (previous !== null) {
145
+ let changed = false;
146
+ for (const field of CONTROLLER_CONFIG_FIELDS) {
147
+ if (previous[field] !== config[field]) {
148
+ changed = true;
149
+ break;
150
+ }
151
+ }
152
+ if (!changed)
153
+ return;
154
+ }
155
+ const next = {
156
+ radius: config.radius,
157
+ height: config.height,
158
+ stepHeight: config.stepHeight,
159
+ maxSlopeDeg: config.maxSlopeDeg,
160
+ skinWidth: config.skinWidth,
161
+ crouchHeight: config.crouchHeight,
162
+ snapDistance: config.snapDistance,
163
+ maxSlides: config.maxSlides,
164
+ mask: config.mask,
165
+ };
166
+ state.controller.retune(next);
167
+ state.controllerConfig = next;
168
+ }
169
+ function positionAccepted(ctx, entityId, x, y, z) {
170
+ const actual = ctx.scene.entity.get(entityId)?.position;
171
+ return actual !== undefined && actual[0] === x && actual[1] === y && actual[2] === z;
172
+ }
173
+ function updateTelemetry(state, entityId, grounded, verticalVelocity, crouching, collisionHeight, requestedCollisionHeight, collisionHeightBlocked) {
174
+ state.entityId = entityId;
175
+ const telemetry = state.telemetry ??= { grounded, verticalVelocity, crouching };
176
+ telemetry.grounded = grounded;
177
+ telemetry.verticalVelocity = verticalVelocity;
178
+ telemetry.crouching = crouching;
179
+ if (collisionHeight === undefined) {
180
+ delete telemetry.collisionHeight;
181
+ delete telemetry.requestedCollisionHeight;
182
+ delete telemetry.collisionHeightBlocked;
183
+ }
184
+ else {
185
+ telemetry.collisionHeight = collisionHeight;
186
+ telemetry.requestedCollisionHeight = requestedCollisionHeight;
187
+ telemetry.collisionHeightBlocked = collisionHeightBlocked;
188
+ }
189
+ }
190
+ /**
191
+ * Read the entity's last shared movement result through indexed possession ownership. Returns `null` before a
192
+ * step, after movement restore/forget, when a commit policy replaces the motor proposal, or for an entity not
193
+ * currently driven by this motor. Reuses one live view;
194
+ * custom movers supply their own animation parameters. Flight never reports ground contact or a walking crouch.
195
+ *
196
+ * @capability movement-telemetry read physical grounded, vertical velocity and crouch state without animation-store writes
197
+ */
198
+ export function playerMovementTelemetry(ctx, entityId) {
199
+ const userId = ctx.player.possession.ownerOf(entityId) ?? entityId;
200
+ if (ctx.player.possession.active(userId) !== entityId || ctx.scene.entity.get(entityId) === null)
201
+ return null;
202
+ const state = stores.get(ctx)?.players.get(userId);
203
+ return state?.entityId === entityId ? state.telemetry : null;
204
+ }
108
205
  /** One player's current heading (radians), integrated by {@link stepPlayerMovement} — the shell reads it back into its camera/aim yaw. */
109
206
  export function playerMovementHeading(ctx, userId) {
110
207
  return stores.get(ctx)?.players.get(userId)?.heading ?? 0;
@@ -127,16 +224,32 @@ export function snapshotPlayerMovement(ctx, userId) {
127
224
  controller: controller === null ? null : copyController(controller),
128
225
  controllerJumpHeld: state.controllerJumpHeld,
129
226
  jumpBuffer: state.jumpBuffer?.snapshot() ?? null,
227
+ heightfieldHeight: state.heightfieldHeight,
228
+ heightfieldEntityId: state.heightfieldEntityId,
130
229
  };
131
230
  }
132
- /** Put a player's movement state back to a {@link snapshotPlayerMovement} copy, so the next {@link stepPlayerMovement} replays from there. */
231
+ /** Put a player's movement state back to a {@link snapshotPlayerMovement} copy. Pending state survives until first spawn; a driven actor's despawn clears it. */
133
232
  export function restorePlayerMovement(ctx, userId, snapshot) {
134
- const state = stateFor(storeFor(ctx), userId);
233
+ const heightfieldHeight = snapshot.heightfieldHeight === undefined
234
+ ? (snapshot.motion === null ? null : DEFAULT_OBSTACLE_PLAYER_HEIGHT) : snapshot.heightfieldHeight;
235
+ if (heightfieldHeight !== null)
236
+ validateCollisionHeight(heightfieldHeight);
237
+ const heightfieldEntityId = snapshot.heightfieldEntityId === undefined
238
+ ? (heightfieldHeight === null ? null : ctx.player.possession.active(userId)) : snapshot.heightfieldEntityId;
239
+ if (heightfieldEntityId !== null && typeof heightfieldEntityId !== "string") {
240
+ throw new RangeError("PlayerMovementSnapshot.heightfieldEntityId must be a string or null.");
241
+ }
242
+ const state = stateFor(ctx, storeFor(ctx), userId);
243
+ const liveEntityId = ctx.player.possession.active(userId);
244
+ state.liveEntityId = ctx.scene.entity.get(liveEntityId) === null ? null : liveEntityId;
245
+ invalidateTelemetry(state);
135
246
  state.heading = snapshot.heading;
136
247
  state.facing = snapshot.facing;
137
248
  state.voxelBody = snapshot.voxelBody === null ? null : { ...snapshot.voxelBody };
138
249
  state.motion = snapshot.motion === null ? null : { ...snapshot.motion };
139
250
  state.flight = snapshot.flight === null ? null : { ...snapshot.flight };
251
+ state.heightfieldHeight = heightfieldHeight;
252
+ state.heightfieldEntityId = heightfieldEntityId;
140
253
  state.controllerJumpHeld = snapshot.controllerJumpHeld;
141
254
  const jumpBuffer = snapshot.jumpBuffer ?? null;
142
255
  if (jumpBuffer === null) {
@@ -224,27 +337,81 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
224
337
  if (player.movement?.frozen === true)
225
338
  return;
226
339
  const store = storeFor(ctx);
227
- const state = stateFor(store, userId);
340
+ const retainedState = store.players.get(userId);
341
+ const state = stateFor(ctx, store, userId);
342
+ let collisionHeight = DEFAULT_OBSTACLE_PLAYER_HEIGHT;
343
+ let requestedCollisionHeight = DEFAULT_OBSTACLE_PLAYER_HEIGHT;
344
+ let collisionHeightBlocked = false;
228
345
  const held = new Set(input.held);
229
346
  const isDown = (action) => held.has(action);
347
+ let nextHeading = state.heading;
230
348
  if (heading !== undefined) {
231
- state.heading = heading;
349
+ nextHeading = heading;
232
350
  }
233
351
  else {
234
352
  const turnInput = (isDown("turnRight") ? 1 : 0) - (isDown("turnLeft") ? 1 : 0);
235
353
  const turnSpeed = tuning.movement?.turnSpeed ?? DEFAULT_TURN_SPEED;
236
354
  if (turnInput !== 0)
237
- state.heading = steerYaw(state.heading, turnInput, turnSpeed, dt);
355
+ nextHeading = steerYaw(nextHeading, turnInput, turnSpeed, dt);
238
356
  }
239
- const forwardX = Math.sin(state.heading);
240
- const forwardZ = Math.cos(state.heading);
357
+ const forwardX = Math.sin(nextHeading);
358
+ const forwardZ = Math.cos(nextHeading);
241
359
  const keys = createEmptyMovementKeys();
242
360
  keys.w = isDown("moveForward");
243
361
  keys.s = isDown("moveBack");
244
362
  keys.a = isDown("moveLeft");
245
363
  keys.d = isDown("moveRight");
246
- keys.shift = isDown("sprint") && (tuning.movement?.canSprint?.(ctx) ?? true);
247
- keys.space = isDown("jump");
364
+ let flightTuning;
365
+ try {
366
+ flightTuning = withPoseRollback(ctx.player.movement, playerId, () => {
367
+ keys.shift = isDown("sprint") && (tuning.movement?.canSprint?.(ctx) ?? true);
368
+ keys.space = isDown("jump");
369
+ keys.c = isDown("crouch") && ctx.player.movement.setPose(playerId, "crouch") === null;
370
+ if (!keys.c && tuning.controller === undefined) {
371
+ const currentPose = ctx.player.movement.getPose(playerId);
372
+ if (currentPose !== "prone")
373
+ ctx.player.movement.setPose(playerId, keys.shift ? "running" : "standing");
374
+ }
375
+ const flightTuning = flightTuningFor(tuning.movement, ctx);
376
+ if (flightTuning === null && tuning.controller === undefined && tuning.collision?.voxel !== true) {
377
+ const authoredHeight = tuning.movement?.collisionHeight;
378
+ requestedCollisionHeight = authoredHeight === undefined ? DEFAULT_OBSTACLE_PLAYER_HEIGHT : authoredHeight;
379
+ validateCollisionHeight(requestedCollisionHeight);
380
+ const previousHeight = state.heightfieldEntityId !== null && state.heightfieldEntityId !== playerId
381
+ ? null : state.heightfieldHeight;
382
+ collisionHeight = previousHeight ?? requestedCollisionHeight;
383
+ if (tuning.movement?.collideObjects !== false &&
384
+ (previousHeight === null && authoredHeight !== undefined || requestedCollisionHeight > collisionHeight)) {
385
+ const swim = tuning.movement?.swim;
386
+ const swimEnabled = swim === true || (typeof swim === "object" && swim !== null);
387
+ const floating = swimEnabled && tuning.ground.waterLevel !== undefined &&
388
+ tuning.ground.sampleHeight(player.position[0], player.position[2]) < tuning.ground.waterLevel;
389
+ const groundedSupport = state.motion?.grounded !== false && (state.motion?.jumpOffset ?? 0) <= 0 && !floating;
390
+ const supportStep = groundedSupport ? tuning.movement?.stepHeight ?? DEFAULT_PLAYER_STEP_HEIGHT : 0;
391
+ const bounds = gatherMovementObstacles(ctx, player.position, 0, 0, 0, supportStep, Math.max(collisionHeight, requestedCollisionHeight));
392
+ // An authored terrain-relative spawn can rest a few centimetres inside a walkable road plate.
393
+ // Test the same support feet the grounded motor will accept, including the resulting headroom.
394
+ const support = groundedSupport
395
+ ? obstacleSupportHeight(player.position[0], player.position[2], player.position[1], supportStep, bounds) : null;
396
+ const supportedPosition = support !== null && support > player.position[1]
397
+ ? [player.position[0], support, player.position[2]] : player.position;
398
+ const overlap = resolveObstacleVerticalStep(supportedPosition, 0, bounds, DEFAULT_OBSTACLE_PLAYER_RADIUS, requestedCollisionHeight).initialOverlap;
399
+ if (overlap && previousHeight === null) {
400
+ throw new RangeError(`PlayerMovementConfig.collisionHeight ${requestedCollisionHeight} intersects blocking geometry at entity ${playerId}. Move the authored placement or choose a height that fits.`);
401
+ }
402
+ collisionHeightBlocked = overlap;
403
+ }
404
+ if (!collisionHeightBlocked)
405
+ collisionHeight = requestedCollisionHeight;
406
+ }
407
+ return flightTuning;
408
+ });
409
+ }
410
+ catch (error) {
411
+ if (retainedState === undefined && store.players.get(userId) === state)
412
+ store.players.delete(userId);
413
+ throw error;
414
+ }
248
415
  // A frame carrying analog magnitudes (virtual joystick, gamepad stick) walks at its deflection
249
416
  // instead of slamming digital ±1 axes — the fix for "a slight stick tilt reads as a full strafe".
250
417
  const analog = input.analog ?? null;
@@ -255,9 +422,14 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
255
422
  right: (analog.moveRight ?? 0) - (analog.moveLeft ?? 0),
256
423
  };
257
424
  const intent = resolveMovementIntent(keys, true, analogMove);
258
- const motionBatch = ctx.player.motionFor(userId).takePending();
259
425
  const walkSpeed = player.movement?.walkSpeed ?? DEFAULT_WALK_SPEED;
260
- const flightTuning = flightTuningFor(tuning.movement, ctx);
426
+ if (flightTuning === null && tuning.controller === undefined && tuning.collision?.voxel !== true) {
427
+ state.heightfieldHeight = collisionHeight;
428
+ state.heightfieldEntityId = playerId;
429
+ }
430
+ state.liveEntityId = playerId;
431
+ state.heading = nextHeading;
432
+ const motionBatch = ctx.player.motionFor(userId).takePending();
261
433
  if (flightTuning !== null) {
262
434
  const value = (action) => input.analog?.[action] ?? (isDown(action) ? 1 : 0);
263
435
  let flightIntent = resolveFreeFlightIntentFromInput(isDown, value, input.pointer, flightTuning.bindings);
@@ -295,7 +467,7 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
295
467
  const cam = ctx.camera;
296
468
  cam?.setChaseTuning(normalizedFlightTuning.camera ?? null);
297
469
  }
298
- const step = advanceFreeFlight(flightState, flightIntent, state.heading, pitch, dt, normalizedFlightTuning);
470
+ const step = advanceFreeFlight(flightState, flightIntent, state.heading, pitch, dt, normalizedFlightTuning, { authoritativeStep: tuning.authoritativeStep });
299
471
  let stepX = step.stepX;
300
472
  let stepY = step.stepY;
301
473
  let stepZ = step.stepZ;
@@ -314,10 +486,12 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
314
486
  flightState.vy = 0;
315
487
  }
316
488
  }
489
+ let motorProposalAccepted = true;
317
490
  let nextX = player.position[0] + stepX;
318
491
  let nextY = player.position[1] + stepY;
319
492
  let nextZ = player.position[2] + stepZ;
320
493
  if (motionBatch !== null && motionBatch.y !== null) {
494
+ motorProposalAccepted = motionBatch.y === nextY;
321
495
  nextY = motionBatch.y;
322
496
  }
323
497
  if (tuning.movement?.beforeCommit !== undefined) {
@@ -330,6 +504,7 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
330
504
  };
331
505
  const replacement = tuning.movement.beforeCommit(frame);
332
506
  if (replacement !== undefined) {
507
+ motorProposalAccepted = motorProposalAccepted && replacement[0] === nextX && replacement[1] === nextY && replacement[2] === nextZ;
333
508
  nextX = replacement[0];
334
509
  nextY = replacement[1];
335
510
  nextZ = replacement[2];
@@ -340,6 +515,10 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
340
515
  rotationY: resolveBodyFacing(state, flightIntent.moving, flightState.vx, flightState.vz, player.rotationY, tuning.movement?.turnSpeed, dt),
341
516
  dt,
342
517
  });
518
+ if (motorProposalAccepted && positionAccepted(ctx, playerId, nextX, nextY, nextZ))
519
+ updateTelemetry(state, playerId, false, flightState.vy, false);
520
+ else
521
+ invalidateTelemetry(state);
343
522
  return;
344
523
  }
345
524
  if (flightTuning === null && state.flight !== null) {
@@ -361,6 +540,7 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
361
540
  });
362
541
  state.pendingController = null;
363
542
  }
543
+ retuneController(state, tuning.controller.capsule);
364
544
  const controllerState = controller.state();
365
545
  if (Math.hypot(controllerState.position[0] - player.position[0], controllerState.position[1] - player.position[1], controllerState.position[2] - player.position[2]) > 1e-6) {
366
546
  controller.restore({
@@ -370,43 +550,53 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
370
550
  groundBody: null,
371
551
  });
372
552
  }
373
- // Keep the existing input feel and horizontal impulse seam; the capsule owns vertical motion and collision.
374
553
  let horizontal = state.motion;
375
554
  if (horizontal === null) {
376
555
  horizontal = createPlayerMotionState();
377
556
  state.motion = horizontal;
378
557
  }
558
+ controller.setCrouch(tuning.controller.backend, intent.crouching);
379
559
  const grounded = controller.state().grounded;
380
560
  horizontal.grounded = grounded;
381
- horizontal.verticalVelocity = 0;
561
+ horizontal.verticalVelocity = applyMotionImpulses(controller.state().verticalVelocity, motionBatch);
382
562
  horizontal.jumpOffset = 0;
383
563
  [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;
564
+ const controllerIntent = {
565
+ ...intent,
566
+ crouching: controller.state().crouching,
567
+ running: intent.running && !controller.state().crouching,
568
+ };
569
+ if (state.jumpBuffer === null) {
570
+ horizontal.jumpHeld = state.controllerJumpHeld;
571
+ state.jumpBuffer = createInputBuffer({ windowMs: tuning.physics?.jumpBufferMs ?? 0 });
572
+ }
573
+ const horizontalStep = advancePlayerMotion(horizontal, controllerIntent, forwardX, forwardZ, walkSpeed, dt, tuning.physics, { buffer: state.jumpBuffer, externalGrounding: true, authoritativeStep: tuning.authoritativeStep });
389
574
  state.controllerJumpHeld = intent.jumping;
390
- const current = controller.state();
391
575
  controller.restore({
392
- ...current,
393
- verticalVelocity: applyMotionImpulses(current.verticalVelocity, motionBatch),
576
+ ...controller.state(),
577
+ verticalVelocity: horizontal.verticalVelocity,
578
+ grounded: horizontal.grounded,
394
579
  });
395
580
  const result = controller.move(tuning.controller.backend, {
396
581
  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,
582
+ dt: motionStepSeconds(dt, tuning.authoritativeStep),
403
583
  });
584
+ horizontal.verticalVelocity = controller.state().verticalVelocity;
585
+ horizontal.jumpOffset = 0;
586
+ if (!grounded && result.grounded)
587
+ horizontal.landedAtMs = horizontal.clockMs;
588
+ horizontal.wasAirborne = !result.grounded;
589
+ if (result.grounded)
590
+ horizontal.jumpRising = false;
591
+ ctx.player.movement.setPose(playerId, controller.state().crouching ? "crouch" : intent.running ? "running" : "standing");
592
+ let motorProposalAccepted = true;
404
593
  let nextPosition = [
405
594
  controller.state().position[0],
406
595
  controller.state().position[1],
407
596
  controller.state().position[2],
408
597
  ];
409
598
  if (motionBatch !== null && motionBatch.y !== null) {
599
+ motorProposalAccepted = motionBatch.y === nextPosition[1];
410
600
  nextPosition[1] = motionBatch.y;
411
601
  controller.restore({ ...controller.state(), position: nextPosition });
412
602
  }
@@ -420,6 +610,7 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
420
610
  };
421
611
  const replacement = tuning.movement.beforeCommit(frame);
422
612
  if (replacement !== undefined) {
613
+ motorProposalAccepted = motorProposalAccepted && replacement[0] === nextPosition[0] && replacement[1] === nextPosition[1] && replacement[2] === nextPosition[2];
423
614
  nextPosition = [replacement[0], replacement[1], replacement[2]];
424
615
  controller.restore({ ...controller.state(), position: nextPosition });
425
616
  }
@@ -430,6 +621,10 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
430
621
  rotationY: resolveBodyFacing(state, intent.moving, horizontal.horizontalVelocityX, horizontal.horizontalVelocityZ, player.rotationY, tuning.movement?.turnSpeed, dt),
431
622
  dt,
432
623
  });
624
+ if (motorProposalAccepted && positionAccepted(ctx, playerId, nextPosition[0], nextPosition[1], nextPosition[2]))
625
+ updateTelemetry(state, playerId, controller.state().grounded, controller.state().verticalVelocity, controller.state().crouching);
626
+ else
627
+ invalidateTelemetry(state);
433
628
  return;
434
629
  }
435
630
  if (tuning.collision?.voxel === true) {
@@ -452,7 +647,8 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
452
647
  };
453
648
  body.velocityY = applyMotionImpulses(body.velocityY, motionBatch);
454
649
  [body.velocityX, body.velocityZ] = applyHorizontalImpulses(body.velocityX, body.velocityZ, motionBatch);
455
- advanceVoxelPlayer(body, intent, forwardX, forwardZ, walkSpeed, dt, isSolid, dims, tuning.physics, tuning.hasTerrain ? (x, z) => tuning.ground.sampleHeight(x, z) : undefined);
650
+ advanceVoxelPlayer(body, intent, forwardX, forwardZ, walkSpeed, dt, isSolid, dims, tuning.physics, tuning.hasTerrain ? (x, z) => tuning.ground.sampleHeight(x, z) : undefined, { authoritativeStep: tuning.authoritativeStep });
651
+ const motorProposalAccepted = motionBatch === null || motionBatch.y === null || motionBatch.y === body.y;
456
652
  if (motionBatch !== null && motionBatch.y !== null)
457
653
  body.y = motionBatch.y;
458
654
  ctx.scene.entity.setPose(playerId, {
@@ -460,6 +656,10 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
460
656
  rotationY: resolveBodyFacing(state, intent.moving, body.velocityX, body.velocityZ, player.rotationY, tuning.movement?.turnSpeed, dt),
461
657
  dt,
462
658
  });
659
+ if (motorProposalAccepted && positionAccepted(ctx, playerId, body.x, body.y, body.z))
660
+ updateTelemetry(state, playerId, body.grounded, body.velocityY, intent.crouching);
661
+ else
662
+ invalidateTelemetry(state);
463
663
  return;
464
664
  }
465
665
  let motion = state.motion;
@@ -485,9 +685,10 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
485
685
  state.jumpBuffer = jumpBuffer;
486
686
  }
487
687
  const motionOptions = submerged
488
- ? { speedScale: swimSpeedMultiplier, floating: true, buffer: jumpBuffer }
489
- : { buffer: jumpBuffer };
688
+ ? { speedScale: swimSpeedMultiplier, floating: true, buffer: jumpBuffer, authoritativeStep: tuning.authoritativeStep }
689
+ : { buffer: jumpBuffer, externalGrounding: true, authoritativeStep: tuning.authoritativeStep };
490
690
  const prevJumpOffset = motion.jumpOffset;
691
+ const groundedBeforeStep = motion.grounded;
491
692
  const step = advancePlayerMotion(motion, intent, forwardX, forwardZ, walkSpeed, dt, tuning.physics, motionOptions);
492
693
  // Airborne means "not resting on the surface below": any jump/impulse height before or after this
493
694
  // frame's integration. Grounded-only forgiveness (step-up) and airborne-only landing key off it.
@@ -500,11 +701,14 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
500
701
  stepZ = constrained.stepZ;
501
702
  }
502
703
  const stepHeight = tuning.movement?.stepHeight ?? DEFAULT_PLAYER_STEP_HEIGHT;
704
+ const verticalStep = airborne ? motion.jumpOffset - prevJumpOffset : 0;
705
+ const queryMinStepY = Math.min(0, verticalStep);
706
+ const queryMaxStepY = Math.max(stepHeight, verticalStep);
503
707
  let obstacles = null;
504
708
  if (tuning.movement?.collideObjects !== false) {
505
- obstacles = gatherMovementObstacles(ctx, player.position, stepX, stepZ);
709
+ obstacles = gatherMovementObstacles(ctx, player.position, stepX, stepZ, queryMinStepY, queryMaxStepY, collisionHeight);
506
710
  // While grounded, a box the player could simply step onto is a ledge, not a wall.
507
- const resolved = resolveObstacleStep(player.position, stepX, stepZ, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, airborne ? 0 : stepHeight);
711
+ const resolved = resolveObstacleStep(player.position, stepX, stepZ, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, airborne ? 0 : stepHeight, collisionHeight);
508
712
  stepX = resolved.stepX;
509
713
  stepZ = resolved.stepZ;
510
714
  }
@@ -527,46 +731,155 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
527
731
  nextZ += slope.downhill[1] * slide;
528
732
  }
529
733
  }
530
- const groundAtNext = tuning.ground.sampleHeight(nextX, nextZ);
734
+ const maxClimbGrade = tuning.movement?.maxClimbGrade;
735
+ // The full/X/Z candidates share four unique grade samples (origin and three endpoints), even
736
+ // when headroom rejects the grade resolver's first choice. Preserve the game sampler's budget.
737
+ const gradeSamples = maxClimbGrade === undefined ? null : [];
738
+ const gradeSampler = (x, z) => {
739
+ for (const sample of gradeSamples)
740
+ if (sample.x === x && sample.z === z)
741
+ return sample.height;
742
+ const authoredSampler = tuning.movement?.climbGradeHeight;
743
+ const height = authoredSampler === undefined ? tuning.ground.sampleHeight(x, z) : authoredSampler(x, z);
744
+ gradeSamples.push({ x, z, height });
745
+ return height;
746
+ };
747
+ if (maxClimbGrade !== undefined) {
748
+ const accepted = resolveTerrainGradeStep(gradeSampler, player.position, nextX - player.position[0], nextZ - player.position[2], maxClimbGrade);
749
+ nextX = player.position[0] + accepted.stepX;
750
+ nextZ = player.position[2] + accepted.stepZ;
751
+ }
531
752
  // Blocking colliders are walkable surfaces: the effective ground under the player is the higher of
532
753
  // the terrain and the tallest object top the player can stand on here. While grounded a top within
533
754
  // stepHeight above the feet is stepped onto (matching the obstruction's ledge forgiveness); while
534
755
  // airborne only tops at/below the feet catch, so a jump lands ON a crate instead of sinking inside
535
756
  // it and being rubber-banded out by depenetration.
536
- const supportY = obstacles !== null
537
- ? obstacleSupportHeight(nextX, nextZ, player.position[1], airborne ? 0 : stepHeight, obstacles)
538
- : null;
539
- const effectiveGround = supportY !== null && supportY > groundAtNext ? supportY : groundAtNext;
540
- let nextY;
541
- if (airborne) {
542
- // Integrate the jump arc in absolute space (previous feet + this frame's offset delta) so the
543
- // arc stays continuous when the ground under the player changes mid-flight, and land on the
544
- // effective ground — terrain or object top — the moment the descending feet reach it.
545
- const nextFeet = player.position[1] + (motion.jumpOffset - prevJumpOffset);
546
- if (nextFeet <= effectiveGround && motion.verticalVelocity <= 0) {
547
- nextY = effectiveGround;
548
- motion.jumpOffset = 0;
549
- motion.verticalVelocity = 0;
550
- motion.grounded = true;
551
- }
552
- else {
553
- nextY = Math.max(nextFeet, effectiveGround);
554
- motion.jumpOffset = nextY - effectiveGround;
555
- motion.grounded = false;
757
+ const absoluteHeightOverride = motionBatch?.y !== undefined && motionBatch.y !== null;
758
+ const terrainSamples = [];
759
+ const terrainHeightAt = (x, z) => {
760
+ for (const sample of terrainSamples)
761
+ if (sample.x === x && sample.z === z)
762
+ return sample.height;
763
+ const height = tuning.ground.sampleHeight(x, z);
764
+ terrainSamples.push({ x, z, height });
765
+ return height;
766
+ };
767
+ if (obstacles !== null && !absoluteHeightOverride) {
768
+ // A terrain-following style may accept a rise taller than stepHeight. Include those proposed
769
+ // full/X/Z body spans without querying the whole world or falling all the way to distant ground.
770
+ const rise = Math.max(terrainHeightAt(nextX, nextZ), terrainHeightAt(nextX, player.position[2]), terrainHeightAt(player.position[0], nextZ)) - player.position[1];
771
+ if (rise > queryMaxStepY) {
772
+ obstacles = gatherMovementObstacles(ctx, player.position, nextX - player.position[0], nextZ - player.position[2], queryMinStepY, rise, collisionHeight);
556
773
  }
557
774
  }
558
- else if (!submerged && player.position[1] - effectiveGround > stepHeight) {
559
- // Walked off a ledge taller than a step (a crate edge, a cliff): fall under gravity from here
560
- // instead of teleporting the feet down to the ground in one frame.
561
- nextY = player.position[1];
562
- motion.jumpOffset = nextY - effectiveGround;
563
- motion.verticalVelocity = 0;
564
- motion.grounded = false;
565
- }
566
- else {
567
- nextY = effectiveGround;
775
+ const validStartingBounds = obstacles === null || !resolveObstacleVerticalStep(player.position, 0, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, collisionHeight).initialOverlap;
776
+ const resolveCandidate = (x, z) => {
777
+ const terrainHeight = terrainHeightAt(x, z);
778
+ const supportY = obstacles === null ? null : obstacleSupportHeight(x, z, player.position[1], airborne ? 0 : stepHeight, obstacles);
779
+ const ground = supportY !== null && supportY > terrainHeight ? supportY : terrainHeight;
780
+ let y = ground;
781
+ let grounded = motion.grounded;
782
+ let velocity = motion.verticalVelocity;
783
+ let offset = motion.jumpOffset;
784
+ let rising = motion.jumpRising;
785
+ let blockedRise = false;
786
+ if (airborne) {
787
+ // Keep the arc in absolute space while the terrain/support changes below it.
788
+ const nextFeet = player.position[1] + (motion.jumpOffset - prevJumpOffset);
789
+ if (nextFeet <= ground && velocity <= 0) {
790
+ offset = 0;
791
+ velocity = 0;
792
+ grounded = true;
793
+ }
794
+ else {
795
+ y = Math.max(nextFeet, ground);
796
+ offset = y - ground;
797
+ grounded = false;
798
+ }
799
+ }
800
+ else if (!submerged && player.position[1] - ground > stepHeight) {
801
+ y = player.position[1];
802
+ offset = y - ground;
803
+ velocity = 0;
804
+ grounded = false;
805
+ }
806
+ if (obstacles !== null && !absoluteHeightOverride) {
807
+ const vertical = resolveObstacleVerticalStep([x, player.position[1], z], y - player.position[1], obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, collisionHeight);
808
+ if (vertical.hitCeiling) {
809
+ const clippedY = player.position[1] + vertical.stepY;
810
+ // A surface above the ceiling cannot be reached by snapping entirely through a thin roof.
811
+ blockedRise = ground > vertical.ceilingFeetY + vertical.ceilingTolerance;
812
+ y = Math.max(clippedY, ground);
813
+ offset = Math.max(0, y - ground);
814
+ velocity = 0;
815
+ rising = false;
816
+ grounded = y === ground;
817
+ }
818
+ else if (vertical.landed) {
819
+ y = player.position[1] + vertical.stepY;
820
+ offset = 0;
821
+ velocity = 0;
822
+ rising = false;
823
+ grounded = true;
824
+ }
825
+ }
826
+ const clear = obstacles === null || absoluteHeightOverride || !validStartingBounds ||
827
+ (!blockedRise && !resolveObstacleVerticalStep([x, y, z], 0, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, collisionHeight).initialOverlap);
828
+ return { x, z, y, ground, grounded, velocity, offset, rising, clear };
829
+ };
830
+ let candidate = resolveCandidate(nextX, nextZ);
831
+ if (!candidate.clear) {
832
+ const gradeAllows = (x, z) => {
833
+ if (maxClimbGrade === undefined)
834
+ return true;
835
+ const accepted = resolveTerrainGradeStep(gradeSampler, player.position, x - player.position[0], z - player.position[2], maxClimbGrade);
836
+ return accepted.stepX === x - player.position[0] && accepted.stepZ === z - player.position[2];
837
+ };
838
+ const tryAxis = (x, z) => {
839
+ if (obstacles !== null) {
840
+ // The original Z sweep used post-X coordinates. An alternate axis must be swept again from
841
+ // the original pose; reject a changed endpoint rather than accepting an unsampled grade.
842
+ const swept = resolveObstacleStep(player.position, x - player.position[0], z - player.position[2], obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, airborne ? 0 : stepHeight, collisionHeight);
843
+ if (swept.stepX !== x - player.position[0] || swept.stepZ !== z - player.position[2])
844
+ return false;
845
+ }
846
+ if (!gradeAllows(x, z))
847
+ return false;
848
+ const alternative = resolveCandidate(x, z);
849
+ if (!alternative.clear)
850
+ return false;
851
+ candidate = alternative;
852
+ return true;
853
+ };
854
+ const xAccepted = nextX !== player.position[0] && tryAxis(nextX, player.position[2]);
855
+ const zAccepted = !xAccepted && nextZ !== player.position[2] && tryAxis(player.position[0], nextZ);
856
+ if (!xAccepted && !zAccepted) {
857
+ candidate = resolveCandidate(player.position[0], player.position[2]);
858
+ if (!candidate.clear) {
859
+ // An inflated support edge cannot raise the body into a lintel. Retain the valid starting
860
+ // pose rather than forcing feet into support or repairing an illegal authored placement.
861
+ candidate.y = player.position[1];
862
+ candidate.grounded = groundedBeforeStep;
863
+ candidate.offset = prevJumpOffset;
864
+ candidate.velocity = groundedBeforeStep ? 0 : motion.verticalVelocity;
865
+ candidate.rising = groundedBeforeStep ? false : motion.jumpRising;
866
+ }
867
+ }
568
868
  }
869
+ nextX = candidate.x;
870
+ nextZ = candidate.z;
871
+ let nextY = candidate.y;
872
+ const effectiveGround = candidate.ground;
873
+ motion.grounded = candidate.grounded;
874
+ motion.verticalVelocity = candidate.velocity;
875
+ motion.jumpOffset = candidate.offset;
876
+ motion.jumpRising = candidate.rising;
877
+ let motorProposalAccepted = true;
878
+ if (!groundedBeforeStep && motion.grounded)
879
+ motion.landedAtMs = motion.clockMs;
880
+ motion.wasAirborne = !motion.grounded;
569
881
  if (motionBatch !== null && motionBatch.y !== null) {
882
+ motorProposalAccepted = motionBatch.y === nextY;
570
883
  nextY = motionBatch.y;
571
884
  motion.jumpOffset = motionBatch.y - effectiveGround;
572
885
  }
@@ -583,6 +896,7 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
583
896
  };
584
897
  const replacement = tuning.movement.beforeCommit(frame);
585
898
  if (replacement !== undefined) {
899
+ motorProposalAccepted = motorProposalAccepted && replacement[0] === nextX && replacement[1] === nextY && replacement[2] === nextZ;
586
900
  nextX = replacement[0];
587
901
  nextY = replacement[1];
588
902
  nextZ = replacement[2];
@@ -593,11 +907,20 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
593
907
  rotationY: resolveBodyFacing(state, intent.moving, motion.horizontalVelocityX, motion.horizontalVelocityZ, player.rotationY, tuning.movement?.turnSpeed, dt),
594
908
  dt,
595
909
  });
910
+ if (motorProposalAccepted && positionAccepted(ctx, playerId, nextX, nextY, nextZ))
911
+ updateTelemetry(state, playerId, motion.grounded, motion.verticalVelocity, intent.crouching, collisionHeight, requestedCollisionHeight, collisionHeightBlocked);
912
+ else
913
+ invalidateTelemetry(state);
596
914
  }
597
915
  /**
598
916
  * The player's blocking obstacles for this step, from the shared solid seam. A walking NPC calls
599
917
  * `resolveWalkerStep` against the same query, so "solid" means one thing engine-wide.
600
918
  */
601
- function gatherMovementObstacles(ctx, position, stepX, stepZ) {
602
- return solidObstaclesNear(ctx, position, Math.abs(stepX) + DEFAULT_OBSTACLE_PLAYER_RADIUS, Math.abs(stepZ) + DEFAULT_OBSTACLE_PLAYER_RADIUS);
919
+ function gatherMovementObstacles(ctx, position, stepX, stepZ, minStepY = 0, maxStepY = 0, playerHeight = DEFAULT_OBSTACLE_PLAYER_HEIGHT) {
920
+ if (!Number.isFinite(minStepY) || !Number.isFinite(maxStepY))
921
+ throw new RangeError("Movement obstacle query requires a finite vertical step.");
922
+ const minY = Math.min(0, minStepY);
923
+ const maxY = Math.max(0, maxStepY);
924
+ const queryPosition = minY < 0 ? [position[0], position[1] + minY, position[2]] : position;
925
+ return solidObstaclesNear(ctx, queryPosition, Math.abs(stepX) + DEFAULT_OBSTACLE_PLAYER_RADIUS, Math.abs(stepZ) + DEFAULT_OBSTACLE_PLAYER_RADIUS, playerHeight + maxY - minY);
603
926
  }