@woosh/meep-engine 3.16.0 → 3.17.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 (410) hide show
  1. package/editor/particles/effect/ParticleEffectDocument.d.ts +21 -6
  2. package/editor/particles/effect/ParticleEffectDocument.d.ts.map +1 -1
  3. package/editor/particles/effect/ParticleEffectDocument.js +38 -17
  4. package/editor/view/node-graph/NodeGraphClipboard.d.ts +186 -0
  5. package/editor/view/node-graph/NodeGraphClipboard.d.ts.map +1 -0
  6. package/editor/view/node-graph/NodeGraphClipboard.js +231 -0
  7. package/editor/view/node-graph/NodeGraphEditorView.d.ts +59 -1
  8. package/editor/view/node-graph/NodeGraphEditorView.d.ts.map +1 -1
  9. package/editor/view/node-graph/NodeGraphEditorView.js +248 -5
  10. package/editor/view/node-graph/NodeGraphView.d.ts.map +1 -1
  11. package/editor/view/node-graph/NodeGraphView.js +84 -8
  12. package/editor/view/node-graph/actions/connection_bind_actions.d.ts +23 -0
  13. package/editor/view/node-graph/actions/connection_bind_actions.d.ts.map +1 -0
  14. package/editor/view/node-graph/actions/connection_bind_actions.js +39 -0
  15. package/editor/view/particles/effect/ParticleCodeEditorView.d.ts.map +1 -1
  16. package/editor/view/particles/effect/ParticleCodeEditorView.js +4 -3
  17. package/editor/view/particles/effect/ParticleCurveEditorView.d.ts +20 -0
  18. package/editor/view/particles/effect/ParticleCurveEditorView.d.ts.map +1 -0
  19. package/editor/view/particles/effect/ParticleCurveEditorView.js +574 -0
  20. package/editor/view/particles/effect/ParticleEmitterInspectorView.d.ts.map +1 -1
  21. package/editor/view/particles/effect/ParticleEmitterInspectorView.js +27 -1
  22. package/editor/view/particles/effect/ParticleGradientEditorView.d.ts +24 -0
  23. package/editor/view/particles/effect/ParticleGradientEditorView.d.ts.map +1 -0
  24. package/editor/view/particles/effect/ParticleGradientEditorView.js +472 -0
  25. package/editor/view/particles/effect/ParticleGraphEditorView.d.ts +5 -0
  26. package/editor/view/particles/effect/ParticleGraphEditorView.d.ts.map +1 -1
  27. package/editor/view/particles/effect/ParticleGraphEditorView.js +61 -31
  28. package/editor/view/particles/effect/ParticleNodeParametersView.d.ts.map +1 -1
  29. package/editor/view/particles/effect/ParticleNodeParametersView.js +33 -0
  30. package/editor/view/particles/effect/particle-editor.css +123 -35
  31. package/package.json +1 -1
  32. package/src/core/binary/allocator/OffsetAllocator.d.ts +25 -16
  33. package/src/core/binary/allocator/OffsetAllocator.d.ts.map +1 -1
  34. package/src/core/binary/allocator/OffsetAllocator.js +10 -1
  35. package/src/core/collection/array/typed/float32_array_hash.d.ts +21 -0
  36. package/src/core/collection/array/typed/float32_array_hash.d.ts.map +1 -0
  37. package/src/core/collection/array/typed/float32_array_hash.js +38 -0
  38. package/src/core/graph/csr/CSRGraph.d.ts +8 -1
  39. package/src/core/graph/csr/CSRGraph.d.ts.map +1 -1
  40. package/src/core/graph/csr/CSRGraph.js +14 -6
  41. package/src/core/model/node-graph/visual/layout/LayoutProblem.d.ts +24 -0
  42. package/src/core/model/node-graph/visual/layout/LayoutProblem.d.ts.map +1 -1
  43. package/src/core/model/node-graph/visual/layout/LayoutProblem.js +27 -0
  44. package/src/core/model/node-graph/visual/layout/NodeGraphLayoutOptions.d.ts +26 -0
  45. package/src/core/model/node-graph/visual/layout/NodeGraphLayoutOptions.d.ts.map +1 -1
  46. package/src/core/model/node-graph/visual/layout/NodeGraphLayoutOptions.js +28 -0
  47. package/src/core/model/node-graph/visual/layout/RouteGrid.d.ts +92 -3
  48. package/src/core/model/node-graph/visual/layout/RouteGrid.d.ts.map +1 -1
  49. package/src/core/model/node-graph/visual/layout/RouteGrid.js +405 -242
  50. package/src/core/model/node-graph/visual/layout/RouteSearch.d.ts +50 -5
  51. package/src/core/model/node-graph/visual/layout/RouteSearch.d.ts.map +1 -1
  52. package/src/core/model/node-graph/visual/layout/RouteSearch.js +160 -24
  53. package/src/core/model/node-graph/visual/layout/RouteSet.d.ts +7 -0
  54. package/src/core/model/node-graph/visual/layout/RouteSet.d.ts.map +1 -1
  55. package/src/core/model/node-graph/visual/layout/RouteSet.js +8 -0
  56. package/src/core/model/node-graph/visual/layout/layout_assign_coordinates.js +9 -3
  57. package/src/core/model/node-graph/visual/layout/layout_build_problem.d.ts.map +1 -1
  58. package/src/core/model/node-graph/visual/layout/layout_build_problem.js +7 -0
  59. package/src/core/model/node-graph/visual/layout/layout_insert_lanes.d.ts +9 -3
  60. package/src/core/model/node-graph/visual/layout/layout_insert_lanes.d.ts.map +1 -1
  61. package/src/core/model/node-graph/visual/layout/layout_insert_lanes.js +17 -3
  62. package/src/core/model/node-graph/visual/layout/layout_node_graph.d.ts +9 -6
  63. package/src/core/model/node-graph/visual/layout/layout_node_graph.d.ts.map +1 -1
  64. package/src/core/model/node-graph/visual/layout/layout_node_graph.js +49 -7
  65. package/src/core/model/node-graph/visual/layout/layout_order_layers.d.ts +11 -1
  66. package/src/core/model/node-graph/visual/layout/layout_order_layers.d.ts.map +1 -1
  67. package/src/core/model/node-graph/visual/layout/layout_order_layers.js +135 -16
  68. package/src/core/model/node-graph/visual/layout/route_connections.d.ts +29 -0
  69. package/src/core/model/node-graph/visual/layout/route_connections.d.ts.map +1 -1
  70. package/src/core/model/node-graph/visual/layout/route_connections.js +716 -309
  71. package/src/core/model/node-graph/visual/layout/route_grid_build.d.ts +3 -2
  72. package/src/core/model/node-graph/visual/layout/route_grid_build.d.ts.map +1 -1
  73. package/src/core/model/node-graph/visual/layout/route_grid_build.js +7 -15
  74. package/src/core/model/node-graph/visual/layout/route_nudge.d.ts +6 -0
  75. package/src/core/model/node-graph/visual/layout/route_nudge.d.ts.map +1 -1
  76. package/src/core/model/node-graph/visual/layout/route_nudge.js +42 -13
  77. package/src/engine/animation/AnimationUtils.js +246 -246
  78. package/src/engine/animation/Animations.js +74 -74
  79. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js +2078 -2078
  80. package/src/engine/ecs/EventType.d.ts +0 -1
  81. package/src/engine/ecs/EventType.js +1 -26
  82. package/src/engine/ecs/ik/ik_write_local_rotation.d.ts +1 -1
  83. package/src/engine/ecs/ik/ik_write_local_rotation.d.ts.map +1 -1
  84. package/src/engine/ecs/ik/ik_write_local_rotation.js +3 -7
  85. package/src/engine/ecs/parent/EntityNode.d.ts.map +1 -1
  86. package/src/engine/ecs/parent/EntityNode.js +4 -9
  87. package/src/engine/ecs/terrain/ecs/cling/ClingToTerrainSystem.d.ts.map +1 -1
  88. package/src/engine/ecs/terrain/ecs/cling/ClingToTerrainSystem.js +360 -364
  89. package/src/engine/ecs/transform/TRANSFORM64_EVENT_CHANGE.d.ts +41 -0
  90. package/src/engine/ecs/transform/TRANSFORM64_EVENT_CHANGE.d.ts.map +1 -0
  91. package/src/engine/ecs/transform/TRANSFORM64_EVENT_CHANGE.js +40 -0
  92. package/src/engine/ecs/transform/TRANSFORM64_MIGRATION_PLAN_2026_09_04.md +8 -0
  93. package/src/engine/ecs/transform/t64_announce_change.d.ts +10 -7
  94. package/src/engine/ecs/transform/t64_announce_change.d.ts.map +1 -1
  95. package/src/engine/ecs/transform/t64_announce_change.js +12 -12
  96. package/src/engine/ecs/transform-attachment/TRANSFORM_ATTACHMENT_EVENT_CHANGE.d.ts +30 -0
  97. package/src/engine/ecs/transform-attachment/TRANSFORM_ATTACHMENT_EVENT_CHANGE.d.ts.map +1 -0
  98. package/src/engine/ecs/transform-attachment/TRANSFORM_ATTACHMENT_EVENT_CHANGE.js +29 -0
  99. package/src/engine/ecs/transform-attachment/TransformAttachmentSystem.d.ts.map +1 -1
  100. package/src/engine/ecs/transform-attachment/TransformAttachmentSystem.js +13 -21
  101. package/src/engine/graphics/ecs/sprite/SpriteSystemPE.d.ts.map +1 -1
  102. package/src/engine/graphics/ecs/sprite/SpriteSystemPE.js +4 -8
  103. package/src/engine/graphics3/LightSystem.d.ts.map +1 -1
  104. package/src/engine/graphics3/LightSystem.js +4 -8
  105. package/src/engine/graphics3/MeshSystem.d.ts.map +1 -1
  106. package/src/engine/graphics3/MeshSystem.js +9 -19
  107. package/src/engine/graphics3/ParticleEmitterSystem.d.ts.map +1 -1
  108. package/src/engine/graphics3/ParticleEmitterSystem.js +4 -8
  109. package/src/engine/graphics3/ShadedGeometrySystem.d.ts.map +1 -1
  110. package/src/engine/graphics3/ShadedGeometrySystem.js +4 -10
  111. package/src/engine/grid/transform2grid/Transform2GridPositionSystem.d.ts +1 -1
  112. package/src/engine/grid/transform2grid/Transform2GridPositionSystem.d.ts.map +1 -1
  113. package/src/engine/grid/transform2grid/Transform2GridPositionSystem.js +4 -8
  114. package/src/engine/intelligence/behavior/ecs/OrbitingBehavior.js +218 -218
  115. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  116. package/src/engine/physics/ecs/PhysicsSystem.js +2 -5
  117. package/src/engine/sound/ecs/SoundListenerSystem.d.ts.map +1 -1
  118. package/src/engine/sound/ecs/SoundListenerSystem.js +6 -8
  119. package/src/engine/sound/simulation/ecs/AcousticSimulationSystem.d.ts +0 -4
  120. package/src/engine/sound/simulation/ecs/AcousticSimulationSystem.d.ts.map +1 -1
  121. package/src/engine/sound/simulation/ecs/AcousticSimulationSystem.js +5 -11
  122. package/src/shade/playground/particle_editor/README.md +43 -2
  123. package/src/shade/playground/particle_system/particle_prototype.d.ts.map +1 -1
  124. package/src/shade/playground/particle_system/particle_prototype.js +4 -5
  125. package/src/shade/playground/particle_system/particle_scene.js +4 -4
  126. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  127. package/src/shade/renderer/Renderer.js +13 -1
  128. package/src/shade/renderer/particles/DESIGN.md +104 -19
  129. package/src/shade/renderer/particles/GPUParticleSystem.d.ts +64 -50
  130. package/src/shade/renderer/particles/GPUParticleSystem.d.ts.map +1 -1
  131. package/src/shade/renderer/particles/GPUParticleSystem.js +176 -78
  132. package/src/shade/renderer/particles/ParticleConstants.d.ts +59 -23
  133. package/src/shade/renderer/particles/ParticleConstants.d.ts.map +1 -1
  134. package/src/shade/renderer/particles/ParticleConstants.js +66 -23
  135. package/src/shade/renderer/particles/ParticleRenderMode.d.ts +19 -0
  136. package/src/shade/renderer/particles/ParticleRenderMode.d.ts.map +1 -0
  137. package/src/shade/renderer/particles/ParticleRenderMode.js +33 -0
  138. package/src/shade/renderer/particles/data/PARTICLE_EMITTER_STRUCT.d.ts.map +1 -1
  139. package/src/shade/renderer/particles/data/PARTICLE_EMITTER_STRUCT.js +167 -166
  140. package/src/shade/renderer/particles/data/particle_emitter_record.d.ts +37 -0
  141. package/src/shade/renderer/particles/data/particle_emitter_record.d.ts.map +1 -0
  142. package/src/shade/renderer/particles/data/particle_emitter_record.js +109 -0
  143. package/src/shade/renderer/particles/graph/ParticleGroupBuilder.d.ts +3 -3
  144. package/src/shade/renderer/particles/graph/ParticleGroupBuilder.js +303 -303
  145. package/src/shade/renderer/particles/graph/ParticleNodeDescription.d.ts +18 -0
  146. package/src/shade/renderer/particles/graph/ParticleNodeDescription.d.ts.map +1 -1
  147. package/src/shade/renderer/particles/graph/ParticleNodeDescription.js +152 -133
  148. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.d.ts +0 -11
  149. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.d.ts.map +1 -1
  150. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.js +720 -621
  151. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts.map +1 -1
  152. package/src/shade/renderer/particles/graph/compile_particle_graph.js +8 -3
  153. package/src/shade/renderer/particles/graph/flatten_particle_graph.d.ts.map +1 -1
  154. package/src/shade/renderer/particles/graph/flatten_particle_graph.js +244 -213
  155. package/src/shade/renderer/particles/graph/groups/particle_builtin_groups.d.ts.map +1 -1
  156. package/src/shade/renderer/particles/graph/groups/particle_builtin_groups.js +6 -0
  157. package/src/shade/renderer/particles/graph/groups/particle_groups_geometry.d.ts.map +1 -1
  158. package/src/shade/renderer/particles/graph/groups/particle_groups_geometry.js +294 -319
  159. package/src/shade/renderer/particles/graph/groups/particle_groups_noise.d.ts.map +1 -1
  160. package/src/shade/renderer/particles/graph/groups/particle_groups_noise.js +524 -517
  161. package/src/shade/renderer/particles/graph/groups/particle_groups_psrdnoise.d.ts +22 -0
  162. package/src/shade/renderer/particles/graph/groups/particle_groups_psrdnoise.d.ts.map +1 -0
  163. package/src/shade/renderer/particles/graph/groups/particle_groups_psrdnoise.js +517 -0
  164. package/src/shade/renderer/particles/graph/groups/simplex_reference.d.ts.map +1 -1
  165. package/src/shade/renderer/particles/graph/groups/simplex_reference.js +185 -159
  166. package/src/shade/renderer/particles/graph/make_particle_group.d.ts.map +1 -1
  167. package/src/shade/renderer/particles/graph/make_particle_group.js +2 -20
  168. package/src/shade/renderer/particles/graph/particle_compare_ops.d.ts +17 -0
  169. package/src/shade/renderer/particles/graph/particle_compare_ops.d.ts.map +1 -0
  170. package/src/shade/renderer/particles/graph/particle_compare_ops.js +22 -0
  171. package/src/shade/renderer/particles/graph/particle_graph_diagnostic.d.ts +28 -8
  172. package/src/shade/renderer/particles/graph/particle_graph_diagnostic.d.ts.map +1 -1
  173. package/src/shade/renderer/particles/graph/particle_graph_diagnostic.js +49 -25
  174. package/src/shade/renderer/particles/graph/particle_node_parameters.d.ts +40 -0
  175. package/src/shade/renderer/particles/graph/particle_node_parameters.d.ts.map +1 -1
  176. package/src/shade/renderer/particles/graph/particle_node_parameters.js +312 -234
  177. package/src/shade/renderer/particles/graph/particle_node_presentation.d.ts.map +1 -1
  178. package/src/shade/renderer/particles/graph/particle_node_presentation.js +59 -3
  179. package/src/shade/renderer/particles/graph/particle_ramp.d.ts +230 -0
  180. package/src/shade/renderer/particles/graph/particle_ramp.d.ts.map +1 -0
  181. package/src/shade/renderer/particles/graph/particle_ramp.js +403 -0
  182. package/src/shade/renderer/particles/graph/validate_particle_graph.d.ts +7 -1
  183. package/src/shade/renderer/particles/graph/validate_particle_graph.d.ts.map +1 -1
  184. package/src/shade/renderer/particles/graph/validate_particle_graph.js +66 -10
  185. package/src/shade/renderer/particles/graph_particles.d.ts +23 -69
  186. package/src/shade/renderer/particles/graph_particles.d.ts.map +1 -1
  187. package/src/shade/renderer/particles/graph_particles.js +105 -262
  188. package/src/shade/renderer/particles/graph_particles_avboit.d.ts.map +1 -1
  189. package/src/shade/renderer/particles/graph_particles_avboit.js +3 -2
  190. package/src/shade/renderer/particles/graph_particles_billboard.d.ts +37 -0
  191. package/src/shade/renderer/particles/graph_particles_billboard.d.ts.map +1 -0
  192. package/src/shade/renderer/particles/graph_particles_billboard.js +93 -0
  193. package/src/shade/renderer/particles/isa/InstructionStream.d.ts +51 -22
  194. package/src/shade/renderer/particles/isa/InstructionStream.d.ts.map +1 -1
  195. package/src/shade/renderer/particles/isa/InstructionStream.js +98 -38
  196. package/src/shade/renderer/particles/isa/ParticleProgram.d.ts +49 -8
  197. package/src/shade/renderer/particles/isa/ParticleProgram.d.ts.map +1 -1
  198. package/src/shade/renderer/particles/isa/ParticleProgram.js +95 -150
  199. package/src/shade/renderer/particles/isa/ParticleVMISA.d.ts +306 -64
  200. package/src/shade/renderer/particles/isa/ParticleVMISA.d.ts.map +1 -1
  201. package/src/shade/renderer/particles/isa/ParticleVMISA.js +505 -136
  202. package/src/shade/renderer/particles/isa/particle_assembly.d.ts +36 -12
  203. package/src/shade/renderer/particles/isa/particle_assembly.d.ts.map +1 -1
  204. package/src/shade/renderer/particles/isa/particle_assembly.js +354 -139
  205. package/src/shade/renderer/particles/isa/validate_particle_program.d.ts +9 -0
  206. package/src/shade/renderer/particles/isa/validate_particle_program.d.ts.map +1 -0
  207. package/src/shade/renderer/particles/isa/validate_particle_program.js +128 -0
  208. package/src/shade/renderer/particles/layout/ParticleLayout.d.ts +23 -0
  209. package/src/shade/renderer/particles/layout/ParticleLayout.d.ts.map +1 -1
  210. package/src/shade/renderer/particles/layout/ParticleLayout.js +68 -0
  211. package/src/shade/renderer/particles/optimizer/dag/optimize_particle_program_vector.d.ts +77 -0
  212. package/src/shade/renderer/particles/optimizer/dag/optimize_particle_program_vector.d.ts.map +1 -0
  213. package/src/shade/renderer/particles/optimizer/dag/optimize_particle_program_vector.js +219 -0
  214. package/src/shade/renderer/particles/optimizer/dag/particle_vector_extract.d.ts +83 -0
  215. package/src/shade/renderer/particles/optimizer/dag/particle_vector_extract.d.ts.map +1 -0
  216. package/src/shade/renderer/particles/optimizer/dag/particle_vector_extract.js +283 -0
  217. package/src/shade/renderer/particles/optimizer/dag/particle_vector_graph.d.ts +133 -0
  218. package/src/shade/renderer/particles/optimizer/dag/particle_vector_graph.d.ts.map +1 -0
  219. package/src/shade/renderer/particles/optimizer/dag/particle_vector_graph.js +334 -0
  220. package/src/shade/renderer/particles/optimizer/dag/particle_vector_groups.d.ts +117 -0
  221. package/src/shade/renderer/particles/optimizer/dag/particle_vector_groups.d.ts.map +1 -0
  222. package/src/shade/renderer/particles/optimizer/dag/particle_vector_groups.js +70 -0
  223. package/src/shade/renderer/particles/optimizer/dag/particle_vector_lift.d.ts +97 -0
  224. package/src/shade/renderer/particles/optimizer/dag/particle_vector_lift.d.ts.map +1 -0
  225. package/src/shade/renderer/particles/optimizer/dag/particle_vector_lift.js +462 -0
  226. package/src/shade/renderer/particles/optimizer/dag/particle_vector_lower.d.ts +116 -0
  227. package/src/shade/renderer/particles/optimizer/dag/particle_vector_lower.d.ts.map +1 -0
  228. package/src/shade/renderer/particles/optimizer/dag/particle_vector_lower.js +687 -0
  229. package/src/shade/renderer/particles/optimizer/dag/particle_vector_pack.d.ts +57 -0
  230. package/src/shade/renderer/particles/optimizer/dag/particle_vector_pack.d.ts.map +1 -0
  231. package/src/shade/renderer/particles/optimizer/dag/particle_vector_pack.js +860 -0
  232. package/src/shade/renderer/particles/optimizer/dag/particle_vector_rules.d.ts +115 -0
  233. package/src/shade/renderer/particles/optimizer/dag/particle_vector_rules.d.ts.map +1 -0
  234. package/src/shade/renderer/particles/optimizer/dag/particle_vector_rules.js +264 -0
  235. package/src/shade/renderer/particles/optimizer/optimize_particle_program.d.ts +81 -42
  236. package/src/shade/renderer/particles/optimizer/optimize_particle_program.d.ts.map +1 -1
  237. package/src/shade/renderer/particles/optimizer/optimize_particle_program.js +477 -478
  238. package/src/shade/renderer/particles/optimizer/particle_integer_bounds.d.ts +100 -0
  239. package/src/shade/renderer/particles/optimizer/particle_integer_bounds.d.ts.map +1 -0
  240. package/src/shade/renderer/particles/optimizer/particle_integer_bounds.js +236 -0
  241. package/src/shade/renderer/particles/optimizer/particle_program_analysis.d.ts +139 -59
  242. package/src/shade/renderer/particles/optimizer/particle_program_analysis.d.ts.map +1 -1
  243. package/src/shade/renderer/particles/optimizer/particle_program_analysis.js +764 -647
  244. package/src/shade/renderer/particles/optimizer/particle_program_equivalence.d.ts +40 -7
  245. package/src/shade/renderer/particles/optimizer/particle_program_equivalence.d.ts.map +1 -1
  246. package/src/shade/renderer/particles/optimizer/particle_program_equivalence.js +376 -213
  247. package/src/shade/renderer/particles/optimizer/particle_program_fuzz.d.ts +30 -3
  248. package/src/shade/renderer/particles/optimizer/particle_program_fuzz.d.ts.map +1 -1
  249. package/src/shade/renderer/particles/optimizer/particle_program_fuzz.js +337 -223
  250. package/src/shade/renderer/particles/optimizer/particle_vm_semantics.d.ts +24 -38
  251. package/src/shade/renderer/particles/optimizer/particle_vm_semantics.d.ts.map +1 -1
  252. package/src/shade/renderer/particles/optimizer/particle_vm_semantics.js +166 -206
  253. package/src/shade/renderer/particles/optimizer/pass_allocate_registers.d.ts +99 -0
  254. package/src/shade/renderer/particles/optimizer/pass_allocate_registers.d.ts.map +1 -0
  255. package/src/shade/renderer/particles/optimizer/pass_allocate_registers.js +313 -0
  256. package/src/shade/renderer/particles/optimizer/pass_dedup_constants.d.ts +3 -2
  257. package/src/shade/renderer/particles/optimizer/pass_dedup_constants.d.ts.map +1 -1
  258. package/src/shade/renderer/particles/optimizer/pass_dedup_constants.js +198 -197
  259. package/src/shade/renderer/particles/optimizer/pass_eliminate_dead_code.d.ts.map +1 -1
  260. package/src/shade/renderer/particles/optimizer/pass_fold_constants.d.ts +19 -0
  261. package/src/shade/renderer/particles/optimizer/pass_fold_constants.d.ts.map +1 -1
  262. package/src/shade/renderer/particles/optimizer/pass_fold_constants.js +678 -561
  263. package/src/shade/renderer/particles/optimizer/pass_inline_constants.d.ts +22 -0
  264. package/src/shade/renderer/particles/optimizer/pass_inline_constants.d.ts.map +1 -0
  265. package/src/shade/renderer/particles/optimizer/pass_inline_constants.js +221 -0
  266. package/src/shade/renderer/particles/optimizer/pass_narrow_widths.d.ts.map +1 -1
  267. package/src/shade/renderer/particles/optimizer/pass_narrow_widths.js +63 -72
  268. package/src/shade/renderer/particles/optimizer/pass_pack_registers.d.ts.map +1 -1
  269. package/src/shade/renderer/particles/optimizer/pass_pack_registers.js +87 -86
  270. package/src/shade/renderer/particles/optimizer/pass_peephole.d.ts.map +1 -1
  271. package/src/shade/renderer/particles/optimizer/pass_peephole.js +392 -271
  272. package/src/shade/renderer/particles/optimizer/pass_propagate_copies.d.ts.map +1 -1
  273. package/src/shade/renderer/particles/optimizer/pass_propagate_copies.js +361 -268
  274. package/src/shade/renderer/particles/optimizer/pass_schedule.d.ts +4 -0
  275. package/src/shade/renderer/particles/optimizer/pass_schedule.d.ts.map +1 -0
  276. package/src/shade/renderer/particles/optimizer/pass_schedule.js +244 -0
  277. package/src/shade/renderer/particles/optimizer/pass_share_subexpressions.d.ts.map +1 -1
  278. package/src/shade/renderer/particles/optimizer/pass_share_subexpressions.js +154 -151
  279. package/src/shade/renderer/particles/particle_test_fixtures.d.ts +20 -0
  280. package/src/shade/renderer/particles/particle_test_fixtures.d.ts.map +1 -1
  281. package/src/shade/renderer/particles/particle_test_fixtures.js +229 -203
  282. package/src/shade/renderer/particles/runtime/EmitterRegistry.d.ts +99 -25
  283. package/src/shade/renderer/particles/runtime/EmitterRegistry.d.ts.map +1 -1
  284. package/src/shade/renderer/particles/runtime/EmitterRegistry.js +438 -286
  285. package/src/shade/renderer/particles/runtime/GPUParticleEmitterContext.d.ts +109 -0
  286. package/src/shade/renderer/particles/runtime/GPUParticleEmitterContext.d.ts.map +1 -0
  287. package/src/shade/renderer/particles/runtime/GPUParticleEmitterContext.js +127 -0
  288. package/src/shade/renderer/particles/runtime/ParticleEmitter.d.ts +87 -102
  289. package/src/shade/renderer/particles/runtime/ParticleEmitter.d.ts.map +1 -1
  290. package/src/shade/renderer/particles/runtime/ParticleEmitter.js +184 -163
  291. package/src/shade/renderer/particles/runtime/ProgramArena.d.ts +26 -0
  292. package/src/shade/renderer/particles/runtime/ProgramArena.d.ts.map +1 -1
  293. package/src/shade/renderer/particles/runtime/ProgramHeap.d.ts +130 -0
  294. package/src/shade/renderer/particles/runtime/ProgramHeap.d.ts.map +1 -0
  295. package/src/shade/renderer/particles/runtime/ProgramHeap.js +595 -0
  296. package/src/shade/renderer/particles/runtime/ProgramPlacement.d.ts +91 -0
  297. package/src/shade/renderer/particles/runtime/ProgramPlacement.d.ts.map +1 -0
  298. package/src/shade/renderer/particles/runtime/ProgramPlacement.js +106 -0
  299. package/src/shade/renderer/particles/runtime/create_particle_effect.d.ts +5 -2
  300. package/src/shade/renderer/particles/runtime/create_particle_effect.d.ts.map +1 -1
  301. package/src/shade/renderer/particles/runtime/create_particle_effect.js +22 -19
  302. package/src/shade/renderer/particles/shaders/chunk_particle_emit_step.d.ts +28 -0
  303. package/src/shade/renderer/particles/shaders/chunk_particle_emit_step.d.ts.map +1 -0
  304. package/src/shade/renderer/particles/shaders/chunk_particle_emit_step.js +136 -0
  305. package/src/shade/renderer/particles/shaders/chunk_particle_simulate_step.d.ts +35 -0
  306. package/src/shade/renderer/particles/shaders/chunk_particle_simulate_step.d.ts.map +1 -0
  307. package/src/shade/renderer/particles/shaders/chunk_particle_simulate_step.js +133 -0
  308. package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts +18 -9
  309. package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts.map +1 -1
  310. package/src/shade/renderer/particles/shaders/shader_particle_emit.js +20 -80
  311. package/src/shade/renderer/particles/shaders/shader_particle_emit_wide.d.ts +33 -0
  312. package/src/shade/renderer/particles/shaders/shader_particle_emit_wide.d.ts.map +1 -0
  313. package/src/shade/renderer/particles/shaders/shader_particle_emit_wide.js +114 -0
  314. package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts +8 -0
  315. package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts.map +1 -1
  316. package/src/shade/renderer/particles/shaders/shader_particle_simulate.js +33 -101
  317. package/src/shade/renderer/particles/shaders/shader_particle_simulate_wide.d.ts +45 -0
  318. package/src/shade/renderer/particles/shaders/shader_particle_simulate_wide.d.ts.map +1 -0
  319. package/src/shade/renderer/particles/shaders/shader_particle_simulate_wide.js +123 -0
  320. package/src/shade/renderer/particles/sort/graph_particle_sort.d.ts +51 -0
  321. package/src/shade/renderer/particles/sort/graph_particle_sort.d.ts.map +1 -0
  322. package/src/shade/renderer/particles/sort/graph_particle_sort.js +120 -0
  323. package/src/shade/renderer/particles/vm/PARTICLE_VM_CONTEXT_STRUCT.d.ts +6 -0
  324. package/src/shade/renderer/particles/vm/PARTICLE_VM_CONTEXT_STRUCT.d.ts.map +1 -0
  325. package/src/shade/renderer/particles/vm/PARTICLE_VM_CONTEXT_STRUCT.js +16 -0
  326. package/src/shade/renderer/particles/vm/ParticleVMReference.d.ts +4 -1
  327. package/src/shade/renderer/particles/vm/ParticleVMReference.d.ts.map +1 -1
  328. package/src/shade/renderer/particles/vm/ParticleVMReference.js +261 -140
  329. package/src/shade/renderer/particles/vm/chunk_particle_vm.d.ts.map +1 -1
  330. package/src/shade/renderer/particles/vm/chunk_particle_vm.js +217 -255
  331. package/src/shade/renderer/particles/vm/chunk_particle_vm_registers_fast.d.ts +43 -0
  332. package/src/shade/renderer/particles/vm/chunk_particle_vm_registers_fast.d.ts.map +1 -0
  333. package/src/shade/renderer/particles/vm/chunk_particle_vm_registers_fast.js +55 -0
  334. package/src/shade/renderer/particles/vm/chunk_particle_vm_registers_wide.d.ts +62 -0
  335. package/src/shade/renderer/particles/vm/chunk_particle_vm_registers_wide.d.ts.map +1 -0
  336. package/src/shade/renderer/particles/vm/chunk_particle_vm_registers_wide.js +86 -0
  337. package/src/shade/renderer/shader/chunk/math/chunk_mod_v3f.d.ts +22 -0
  338. package/src/shade/renderer/shader/chunk/math/chunk_mod_v3f.d.ts.map +1 -0
  339. package/src/shade/renderer/shader/chunk/math/chunk_mod_v3f.js +32 -0
  340. package/src/shade/renderer/shader/chunk/math/chunk_mod_v4f.d.ts +9 -0
  341. package/src/shade/renderer/shader/chunk/math/chunk_mod_v4f.d.ts.map +1 -0
  342. package/src/shade/renderer/shader/chunk/math/chunk_mod_v4f.js +19 -0
  343. package/src/shade/renderer/shader/chunk/noise/psrdnoise/README.md +65 -0
  344. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise2.d.ts +24 -0
  345. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise2.d.ts.map +1 -0
  346. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise2.js +83 -0
  347. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise2_hash.d.ts +25 -0
  348. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise2_hash.d.ts.map +1 -0
  349. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise2_hash.js +42 -0
  350. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise2_kernel.d.ts +31 -0
  351. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise2_kernel.d.ts.map +1 -0
  352. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise2_kernel.js +80 -0
  353. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3.d.ts +26 -0
  354. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3.d.ts.map +1 -0
  355. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3.js +109 -0
  356. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3_kernel.d.ts +38 -0
  357. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3_kernel.d.ts.map +1 -0
  358. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3_kernel.js +129 -0
  359. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3_permute.d.ts +21 -0
  360. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3_permute.d.ts.map +1 -0
  361. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3_permute.js +35 -0
  362. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3_skew.d.ts +19 -0
  363. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3_skew.d.ts.map +1 -0
  364. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3_skew.js +28 -0
  365. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3_unskew.d.ts +13 -0
  366. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3_unskew.d.ts.map +1 -0
  367. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_psrdnoise3_unskew.js +22 -0
  368. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_srdnoise2.d.ts +22 -0
  369. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_srdnoise2.d.ts.map +1 -0
  370. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_srdnoise2.js +59 -0
  371. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_srdnoise3.d.ts +23 -0
  372. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_srdnoise3.d.ts.map +1 -0
  373. package/src/shade/renderer/shader/chunk/noise/psrdnoise/chunk_srdnoise3.js +74 -0
  374. package/src/shade/wgsl/emulator/ComputeShaderEmulator.d.ts +34 -1
  375. package/src/shade/wgsl/emulator/ComputeShaderEmulator.d.ts.map +1 -1
  376. package/src/shade/wgsl/emulator/ComputeShaderEmulator.js +41 -1
  377. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.d.ts +3 -2
  378. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.d.ts.map +1 -1
  379. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.js +49 -10
  380. package/src/view/minimap/dom/MinimapCameraView.d.ts.map +1 -1
  381. package/src/view/minimap/dom/MinimapCameraView.js +4 -9
  382. package/src/core/model/node-graph/visual/layout/BoxLayoutSpec.d.ts +0 -15
  383. package/src/core/model/node-graph/visual/layout/BoxLayoutSpec.d.ts.map +0 -1
  384. package/src/core/model/node-graph/visual/layout/ConnectedBoxLayouter.d.ts +0 -45
  385. package/src/core/model/node-graph/visual/layout/ConnectedBoxLayouter.d.ts.map +0 -1
  386. package/src/core/model/node-graph/visual/layout/ConnectionEndpointLayoutSpec.d.ts +0 -24
  387. package/src/core/model/node-graph/visual/layout/ConnectionEndpointLayoutSpec.d.ts.map +0 -1
  388. package/src/core/model/node-graph/visual/layout/ConnectionLayoutSpec.d.ts +0 -19
  389. package/src/core/model/node-graph/visual/layout/ConnectionLayoutSpec.d.ts.map +0 -1
  390. package/src/engine/ecs/transform/__probe.d.ts +0 -4
  391. package/src/engine/ecs/transform/__probe.d.ts.map +0 -1
  392. package/src/engine/ecs/util/hideEntityGracefully.d.ts +0 -9
  393. package/src/engine/ecs/util/hideEntityGracefully.d.ts.map +0 -1
  394. package/src/shade/renderer/buffer/table/gpu_database_emulator_image.d.ts +0 -27
  395. package/src/shade/renderer/buffer/table/gpu_database_emulator_image.d.ts.map +0 -1
  396. package/src/shade/renderer/particles/cull/chunk_particle_cull.d.ts +0 -14
  397. package/src/shade/renderer/particles/cull/chunk_particle_cull.d.ts.map +0 -1
  398. package/src/shade/renderer/particles/data/PARTICLE_EMITTER_LAYOUT.d.ts +0 -156
  399. package/src/shade/renderer/particles/data/PARTICLE_EMITTER_LAYOUT.d.ts.map +0 -1
  400. package/src/shade/renderer/particles/runtime/Emitter.d.ts +0 -219
  401. package/src/shade/renderer/particles/runtime/Emitter.d.ts.map +0 -1
  402. package/src/shade/renderer/particles/runtime/ParticleSpawnScheduler.d.ts +0 -77
  403. package/src/shade/renderer/particles/runtime/ParticleSpawnScheduler.d.ts.map +0 -1
  404. package/src/shade/renderer/particles/runtime/ProgramArena.js +0 -155
  405. package/src/shade/renderer/particles/runtime/emitter_database_image.d.ts +0 -15
  406. package/src/shade/renderer/particles/runtime/emitter_database_image.d.ts.map +0 -1
  407. package/src/shade/renderer/particles/runtime/spawn_request_encoding.d.ts +0 -34
  408. package/src/shade/renderer/particles/runtime/spawn_request_encoding.d.ts.map +0 -1
  409. package/src/shade/renderer/particles/shaders/chunk_particle_context.d.ts +0 -32
  410. package/src/shade/renderer/particles/shaders/chunk_particle_context.d.ts.map +0 -1
@@ -1,2078 +1,2078 @@
1
- import { assert } from "../../../core/assert.js";
2
- import { Ray3 } from "../../../core/geom/3d/ray/Ray3.js";
3
- import { CapsuleShape3D } from "../../../core/geom/3d/shape/CapsuleShape3D.js";
4
- import { TransformedShape3D } from "../../../core/geom/3d/shape/TransformedShape3D.js";
5
- import Quaternion from "../../../core/geom/Quaternion.js";
6
- import Vector3 from "../../../core/geom/Vector3.js";
7
- import { clamp } from "../../../core/math/clamp.js";
8
- import { DEG_TO_RAD } from "../../../core/math/DEG_TO_RAD.js";
9
- import { lerp } from "../../../core/math/lerp.js";
10
- import { ResourceAccessKind } from "../../../core/model/ResourceAccessKind.js";
11
- import { ResourceAccessSpecification } from "../../../core/model/ResourceAccessSpecification.js";
12
- import { SerializationMetadata } from "../../ecs/components/SerializationMetadata.js";
13
- import Entity from "../../ecs/Entity.js";
14
- import { System } from "../../ecs/System.js";
15
- import { Transform64 } from "../../ecs/transform/Transform64.js";
16
- import { Camera } from "../../graphics/ecs/camera/Camera.js";
17
- import { BodyKind } from "../../physics/ecs/BodyKind.js";
18
- import { Collider } from "../../physics/ecs/Collider.js";
19
- import { PhysicsSystem } from "../../physics/ecs/PhysicsSystem.js";
20
- import { RigidBody } from "../../physics/ecs/RigidBody.js";
21
- import { PhysicsSurfacePoint } from "../../physics/queries/PhysicsSurfacePoint.js";
22
- import { KinematicMover } from "./collision/KinematicMover.js";
23
- import { EyeOffsetStack } from "./composer/EyeOffsetStack.js";
24
- import { FirstPersonPlayerController } from "./FirstPersonPlayerController.js";
25
- import { DecisionPoint } from "./mastery/DecisionPoint.js";
26
- import { computeJumpFromApex } from "./math/computeJumpFromApex.js";
27
- import { computeLRCBreathRate } from "./math/computeLRCBreathRate.js";
28
- import { computeMassRatios } from "./math/computeMassRatios.js";
29
- import { jumpForceForHeight, launchVelocityForForce } from "./math/jumpDynamics.js";
30
- import { Spring } from "./math/Spring.js";
31
- import { stepTowards } from "./math/stepTowards.js";
32
- import { FirstPersonActionState, FirstPersonLocomotionMode } from "./pose/FirstPersonPose.js";
33
- import { FirstPersonPosture } from "./pose/FirstPersonPosture.js";
34
- import { FirstPersonSensors } from "./sensors/FirstPersonSensors.js";
35
- import { t64_announce_change } from "../../ecs/transform/t64_announce_change.js";
36
-
37
- // ---------------------------------------------------------------------------
38
- // Scratch allocations — reused per frame to avoid GC pressure
39
- // ---------------------------------------------------------------------------
40
- const SCRATCH_V3_A = new Vector3();
41
- const SCRATCH_V3_B = new Vector3();
42
- const SCRATCH_V3_C = new Vector3();
43
- const SCRATCH_Q_A = new Quaternion();
44
- const SCRATCH_Q_B = new Quaternion();
45
- const SCRATCH_Q_C = new Quaternion();
46
- /** the transform holds its rotation as four numbers, so euler and axis-angle work goes through here */
47
- const SCRATCH_Q_EULER = new Quaternion();
48
-
49
- const TWO_PI = Math.PI * 2;
50
- const LN2 = Math.log(2);
51
-
52
- /**
53
- * Build a posture-sized player capsule: a {@link CapsuleShape3D} of
54
- * `radius` and the appropriate cylinder height, wrapped in a
55
- * {@link TransformedShape3D} whose Y offset puts the capsule's bottom
56
- * exactly at the wrapped shape's local origin. The entity's
57
- * `transform.position` then represents the player's feet — and a
58
- * posture-driven shrink doesn't yank the feet up the way a centred
59
- * capsule would, nor dip them below the floor.
60
- *
61
- * The capsule's lowest point in its own local frame is at
62
- * `-(cylinderHeight/2 + radius) = -max(totalHeight/2, radius)`.
63
- * Offsetting the wrapper by the magnitude of that puts the bottom at
64
- * Y = 0:
65
- * - Stand (`H = 1.8`, `r = 0.34`): cylHeight = 1.12, offset = 0.9.
66
- * Bottom = -0.9 + 0.9 = 0. Top = +0.9 + 0.9 = 1.8.
67
- * - Crouch (`H = 0.8`, `r = 0.34`): cylHeight = 0.12, offset = 0.4.
68
- * Bottom = -0.4 + 0.4 = 0. Top = +0.4 + 0.4 = 0.8.
69
- * - Prone (`H = 0.4`, `r = 0.34`): cylHeight = 0 (capsule collapses
70
- * to a sphere of radius), offset = max(0.2, 0.34) = 0.34.
71
- * Bottom = -0.34 + 0.34 = 0. Top = +0.34 + 0.34 = 0.68. The
72
- * `totalHeight = 0.4` value is honoured for the offset budget
73
- * but the actual Y extent floors at `2·radius`.
74
- *
75
- * Picking `totalHeight/2` blindly (the obvious choice) would put the
76
- * Prone capsule's bottom at `0.2 - 0.34 = -0.14` — dipping below the
77
- * feet, and into any physics floor that's flush with feet level. On
78
- * a physics ground slab, every horizontal shape_cast from inside the
79
- * floor returns t = 0, `advance = max(0, t - SKIN) = 0`, and the
80
- * slide freezes in place — see SlideMotion.spec.js for the
81
- * regression test that pins this.
82
- *
83
- * @param {number} radius — capsule radius in metres
84
- * @param {number} totalHeight — desired full Y extent; ignored below
85
- * `2·radius` (the capsule's intrinsic minimum extent)
86
- * @returns {TransformedShape3D}
87
- */
88
- function makePostureCapsule(radius, totalHeight) {
89
- const cylinderHeight = Math.max(0, totalHeight - 2 * radius);
90
- const yOffset = Math.max(totalHeight / 2, radius);
91
- return TransformedShape3D.from_translation(
92
- CapsuleShape3D.from(radius, cylinderHeight),
93
- [0, yOffset, 0],
94
- );
95
- }
96
-
97
- /**
98
- * Per-entity runtime state the system maintains internally — too transient
99
- * even for {@link FirstPersonPlayerController}'s `state` member, because it
100
- * encodes input-edge bookkeeping and timer values the public surface should
101
- * never see directly.
102
- */
103
- class PerEntityRuntime {
104
- /**
105
- * Co-attached kinematic body. Set by {@link FirstPersonPlayerControllerSystem.link}
106
- * after asserting it's present. The controller writes Transform64.position
107
- * directly (existing motion logic); physics derives the body's velocity
108
- * from the per-step delta. Other physics systems (raycasts, contact
109
- * events) see the player through this body.
110
- * @type {RigidBody|null}
111
- */
112
- rigidBody = null;
113
-
114
- /**
115
- * Co-attached collider, cached at link. Same source the physics
116
- * narrowphase uses, so move-and-slide casts the player's
117
- * actual collision shape against the world.
118
- * @type {Collider|null}
119
- */
120
- collider = null;
121
-
122
- /**
123
- * Pre-built capsule colliders, one per posture. Cached at link
124
- * from `config.body.{height, crouchHeight, proneHeight, radius}`
125
- * so {@link _syncColliderShape} can swap the collider's shape on
126
- * a posture change with zero per-tick allocation. Hang reuses
127
- * Stand (the player's body is full-extent, just hanging below
128
- * the ledge — the rig animates the arms-up pose). Sentinel
129
- * `lastPosture = -1` forces a sync on the first tick after
130
- * link, so the initial shape always matches Stand.
131
- * @private
132
- * @type {TransformedShape3D|null}
133
- */
134
- colliderShapeStand = null;
135
-
136
- /** @private @type {TransformedShape3D|null} */
137
- colliderShapeCrouch = null;
138
-
139
- /** @private @type {TransformedShape3D|null} */
140
- colliderShapeProne = null;
141
-
142
- /** @private */
143
- lastPosture = -1;
144
-
145
- /** Eye pitch in radians, clamped to config.look limits. */
146
- eyePitch = 0;
147
-
148
- /** Body yaw in radians (around world up). */
149
- bodyYaw = 0;
150
-
151
- /** Yaw rate (rad/s) computed in look consumption — for evaluators. */
152
- yawRateRadPerSec = 0;
153
-
154
- // -- Render-rate look + position interpolation ---------------------
155
- // The camera DIRECTION (yaw/pitch) is consumed at RENDER rate (see the
156
- // controller's _consumeLook), so it tracks the mouse smoothly instead of
157
- // being aliased by the 60Hz sim sampling — consuming the accumulated
158
- // mouse delta once per fixed step beats against an unaligned mouse
159
- // report rate and makes a slow turn lurch (the reported snap). The body
160
- // POSITION still advances only at the fixed step, so it's blended
161
- // between the last two steps by the sub-step alpha to stay smooth on a
162
- // high-refresh display.
163
- /** Body yaw at the end of the previous fixed step — used to derive the
164
- * per-step turn RATE that drives look-lean, decoupled from per-frame
165
- * input jitter. */
166
- prevFixedYaw = 0;
167
-
168
- renderPrevPos = new Vector3();
169
- renderCurPos = new Vector3();
170
-
171
- /** When true, the next position snapshot collapses prev=cur (no blend)
172
- * — set on a body teleport (e.g. the ledge-grab hang snap) so the
173
- * camera doesn't glide across the jump. */
174
- renderSnap = false;
175
-
176
- /** Horizontal+vertical velocity. We integrate these inside the system
177
- * when no external physics layer is attached. */
178
- velocityX = 0;
179
-
180
- velocityY = 0;
181
- velocityZ = 0;
182
-
183
- /** Previous-tick jump intent — for rising/falling edge detection. */
184
- prevJumpHeld = false;
185
-
186
- /** Previous-tick crouch intent — for toggle-mode edge detection. */
187
- prevCrouchHeld = false;
188
-
189
- /** True while crouch toggle is latched on (used only in toggle mode). */
190
- crouchLatched = false;
191
-
192
- /** Remaining time in jump anticipation, or <= 0 if not anticipating. */
193
- anticipationRemaining = 0;
194
-
195
- /** Cached derived gravity (m/s^2) from peakHeight + timeToApex. */
196
- gravity = 9.81;
197
-
198
- /**
199
- * Cached jump PUSH-OFF FORCE (newtons) — the physical leg force that
200
- * reaches `peakHeight` at the reference mass, the quantity jump power is
201
- * expressed in. Derived at link via {@link jumpForceForHeight}; abilities
202
- * (e.g. the scramble wall-kick) launch off this same force.
203
- */
204
- jumpForce = 0;
205
-
206
- /** Cached derived jump impulse (m/s upward), post-mass-scaling. */
207
- jumpInitialVy = 5.0;
208
-
209
- /**
210
- * Cached mass scaling factors — computed once at link. See
211
- * {@link computeMassRatios}. Heavier ⇒ lower jumpV0Scale, lower
212
- * groundAccelScale, higher landingDipScale + exertionRiseScale.
213
- */
214
- massRatios = null;
215
-
216
- /** Spring for landing dip (under-damped → rings after impact). */
217
- landSpring = new Spring();
218
-
219
- /** Spring for FOV (critically damped). */
220
- fovSpring = new Spring(70);
221
-
222
- /** Spring for eye height (crouch transition). */
223
- eyeHeightSpring = new Spring(1.80);
224
-
225
- /** Spring for lean roll (radians) — banks into lateral acceleration. */
226
- leanSpring = new Spring();
227
-
228
- /**
229
- * Lean target this tick (radians). Always set; L2.f spring-steps
230
- * toward this value. Whoever owned motion this tick wrote it:
231
- * base writes the lat-accel + look-lean derived value at the end
232
- * of {@link _runBaseLocomotion}; abilities that want to override
233
- * (WallRun → tilt-into-wall, Slide/Mantle/LedgeGrab → zero) write
234
- * their own value in tick. Uniform channel — no null sentinel.
235
- */
236
- leanTargetRad = 0;
237
-
238
- /** Previous horizontal velocity — for lateral acceleration → lean. */
239
- prevVelocityX = 0;
240
-
241
- prevVelocityZ = 0;
242
-
243
- /** Previous-tick grounded for edge detection. */
244
- prevGrounded = true;
245
-
246
- /** Vertical speed at moment of last "leave ground". */
247
- takeoffVy = 0;
248
-
249
- /** Max vertical position since last takeoff — for jump apex detection. */
250
- peakAltitude = 0;
251
-
252
- /** Set true once a jump has been launched; cleared on land. */
253
- midJump = false;
254
-
255
- /** Apex already fired for this airborne segment? */
256
- apexFired = false;
257
-
258
- /** Stride phase from previous fixed step — for footstep edge detection. */
259
- prevStridePhase = 0;
260
-
261
- /** Breath phase from previous fixed step — for inhale/exhale edge detection. */
262
- prevBreathPhase = 0;
263
-
264
- /** Which foot fires next — flipped on each footstep signal. */
265
- nextFootSide = "R";
266
-
267
- /**
268
- * Which foot is currently bearing the body's weight (the foot that
269
- * most recently landed). Drives the lateral-bob direction: at R
270
- * midstance the COM is over the right foot, so the head shifts
271
- * laterally toward screen-right; at L midstance the opposite.
272
- * Coupled to the same signal the footstep emits, so anything that
273
- * listens to onFootStep.side will see the bob agree.
274
- * Initialized "L" so the very first footstep fires "R" and the
275
- * standingFoot updates to "R" — putting the head laterally right
276
- * during the first half-stride, as expected.
277
- */
278
- standingFoot = "L";
279
-
280
- /**
281
- * [0..1] How "backward" the player is currently moving. Derived in
282
- * fixedUpdate from velocity · screen-forward, normalized to sprint
283
- * speed. Drives the gait wobble amplifier on the L3 camera-composition
284
- * pass. Stored on runtime (rather than state) because it's a render-
285
- * side input — downstream observers should look at velocity directly.
286
- */
287
- backwardness = 0;
288
-
289
- /**
290
- * Smoothed bob amplitude envelope. Target = max(speedNormalized,
291
- * backwardness) when grounded, 0 airborne. Spring decay prevents
292
- * the whiplash where stopping motion would snap the bob to neutral.
293
- */
294
- bobIntensitySpring = new Spring();
295
-
296
- /**
297
- * Vertical impact spring — kicked downward at each footfall, decays
298
- * with a slight under-damped overshoot. Produces the impact-arrest +
299
- * leg-push curve. value units: meters (added directly to eyeLocal.y).
300
- */
301
- verticalImpactSpring = new Spring();
302
-
303
- /**
304
- * Sprint-posture spring — eye pitches forward as the player commits
305
- * to a sprint, returns to neutral when they slow. Value is in
306
- * radians; slower half-life than other springs so it feels like
307
- * a posture change rather than an input twitch. See cfg.posture.
308
- */
309
- sprintPostureSpring = new Spring();
310
-
311
- /**
312
- * Head-droop spring — additional forward pitch as exertion rises.
313
- * Sells fatigue subtly. Target tracks exertion-driven max droop
314
- * angle; spring lag keeps the transition slow and physical.
315
- */
316
- headDroopSpring = new Spring();
317
-
318
- /**
319
- * [0..1] sprintness — how much of the walk→sprint speed range the
320
- * body is currently in. Computed in fixedUpdate, read by L3 for FOV
321
- * and the sprint-posture pitch / forward-shift offset.
322
- */
323
- sprintness = 0;
324
-
325
- /**
326
- * Cached sin/cos of current body yaw — written once per fixedUpdate
327
- * after look intent is consumed, read by every downstream step
328
- * (locomotion, backwardness, lean look-rate, pose channels). Avoids
329
- * recomputing the trig 3+ times per tick.
330
- */
331
- sinYaw = 0;
332
-
333
- cosYaw = 1;
334
-
335
- /** Cached horizontal speed (m/s) for this tick — written in derived-state. */
336
- horizSpeed = 0;
337
-
338
- /** Cached stride frequency (Hz) for this tick — written in breath block, read by stride. */
339
- strideFreqHz = 0;
340
-
341
- /**
342
- * Additive accumulator for body-local eye-position offsets. The
343
- * system pushes its own contributions (bob, breath, landing,
344
- * sprint posture) each render frame; external systems can push
345
- * recoil/shake/knockback contributions via the same interface.
346
- */
347
- eyeOffsetStack = new EyeOffsetStack();
348
-
349
- /**
350
- * Spatial-query results populated by {@link FirstPersonSensorsSystem}
351
- * (when present). Abilities and the locomotion FSM read this.
352
- * Lives on runtime so other systems can populate it without
353
- * touching the controller component's public surface.
354
- */
355
- sensors = new FirstPersonSensors();
356
-
357
- /** Cached eye entity ID. -1 until link assigns it. */
358
- eyeEntity = -1;
359
-
360
- /**
361
- * One-shot hand-off flag: LedgeGrab sets it on a pull-up release so
362
- * Mantle (next tick) knows this is a ledge pull-up — it then allows a
363
- * too-thin top (auto-mantle still requires a standable one) and vaults
364
- * OVER instead of onto. Mantle consumes it in onActivate.
365
- */
366
- ledgePullUpRequest = false;
367
-
368
- /**
369
- * Seconds remaining in the post-release ledge re-grab refractory
370
- * window. Set by {@link LedgeGrab} on deactivation, aged down by dt
371
- * each fixed step, and read by LedgeGrab.canActivate — so a deliberate
372
- * dismount can fall clear of the grab window without the auto-catch
373
- * snapping the body straight back onto the same lip.
374
- */
375
- ledgeRegrabCooldown = 0;
376
- }
377
-
378
- /**
379
- * Drives a first-person camera + body from intent fields. See sibling
380
- * DESIGN.md for goals, architecture, and the five processing layers (L0..L4).
381
- *
382
- * - fixedUpdate runs L1 (locomotion), L2 (pose state), and L4 (events) so
383
- * the simulation remains deterministic.
384
- * - update runs L3 (camera composition) at render rate so the eye is never
385
- * smoother than the screen.
386
- *
387
- * The system itself integrates a simple flat-floor at y = `config.gravity.magnitude > 0
388
- * ? state.groundY : -Infinity` for the prototype. A real physics layer should
389
- * write `state.grounded`/`state.groundNormal` from outside instead; the
390
- * built-in resolver is just a convenience to keep the controller usable
391
- * without dependencies.
392
- *
393
- * @author Alex Goldring
394
- * @copyright Company Named Limited (c) 2026
395
- */
396
- export class FirstPersonPlayerControllerSystem extends System {
397
- // Dependencies kept to (controller, transform) so we can ASSERT on
398
- // RigidBody at link time and emit a clear error if missing. If
399
- // RigidBody were a hard dep, entities lacking one would silently
400
- // never link — the controller would appear inert with no
401
- // diagnostic. The assert below catches the missing-body case
402
- // explicitly.
403
- dependencies = [FirstPersonPlayerController, Transform64];
404
-
405
- components_used = [
406
- ResourceAccessSpecification.from(Transform64, ResourceAccessKind.Write),
407
- ResourceAccessSpecification.from(Camera, ResourceAccessKind.Write),
408
- ResourceAccessSpecification.from(RigidBody, ResourceAccessKind.Write),
409
- ];
410
-
411
- /**
412
- * Per-entity runtime, keyed by entity id.
413
- * @type {Map<number, PerEntityRuntime>}
414
- */
415
- runtime = new Map();
416
-
417
- /**
418
- * If true, the system clamps body y >= groundY and writes
419
- * state.grounded itself. Turn off when wiring a real physics layer.
420
- * @type {boolean}
421
- */
422
- useBuiltInFlatGround = true;
423
-
424
- /**
425
- * The flat-ground y for the built-in resolver. Ignored when
426
- * useBuiltInFlatGround is false.
427
- * @type {number}
428
- */
429
- groundY = 0;
430
-
431
- /**
432
- * Optional callback that returns the surface Y under the player
433
- * for ground resolution. Called each tick with the player's
434
- * current (x, y, z); returns the world-Y of the ground below,
435
- * or null if no ground is below (gap / void).
436
- *
437
- * Combines with `useBuiltInFlatGround`: the effective ground for
438
- * the tick is `max(this.groundY when enabled, resolver(...))`.
439
- * Set both off (`useBuiltInFlatGround=false`, `groundResolver=null`)
440
- * to defer to external physics entirely.
441
- *
442
- * Designed for prototypes / gyms that need elevated platforms
443
- * without a full physics layer. Production should wire a real
444
- * physics system instead.
445
- *
446
- * @type {((x:number, y:number, z:number) => number|null) | null}
447
- */
448
- groundResolver = null;
449
-
450
- /**
451
- * PhysicsSystem reference. Auto-acquired at startup; can be
452
- * overridden by the caller. When present, collision is resolved
453
- * by the {@link KinematicMover}; when null, the controller uses
454
- * the flat-ground integrator ({@link _moveFlatGround}) — useful
455
- * for spec setups that don't wire physics.
456
- * @type {PhysicsSystem|null}
457
- */
458
- physicsSystem = null;
459
-
460
- /**
461
- * The collision solver — the authoritative move resolver whenever
462
- * a {@link PhysicsSystem} is present (recover + unified
463
- * sweep-and-slide + ground-categorize; see DESIGN_COLLISION.md).
464
- * Lazily built on first use. With no physics the controller falls
465
- * back to a flat-ground integrator ({@link _moveFlatGround}).
466
- * @private
467
- * @type {KinematicMover|null}
468
- */
469
- _mover = null;
470
-
471
- /** @private Scratch velocity handed to the mover each tick. */
472
- _moverVelocity = new Vector3();
473
-
474
- /** @private Scratch for the posture-headroom up-cast. */
475
- _postureRay = new Ray3();
476
-
477
- /** @private @type {PhysicsSurfacePoint} */
478
- _postureHit = new PhysicsSurfacePoint();
479
-
480
- async startup(entityManager) {
481
- this.entityManager = entityManager;
482
- if (this.physicsSystem === null) {
483
- const ps = entityManager.getSystem(PhysicsSystem);
484
- if (ps !== null) this.physicsSystem = ps;
485
- }
486
- }
487
-
488
- /**
489
- * @param {FirstPersonPlayerController} controller
490
- * @param {Transform64} bodyTransform
491
- * @param {number} entity
492
- */
493
- link(controller, bodyTransform, entity) {
494
- const ecd = this.entityManager.dataset;
495
-
496
- // The controller assumes a kinematic-position RigidBody is co-
497
- // attached on this entity. The body is the spatial proxy used
498
- // for sensor raycasts and physics-side observers (other entities
499
- // raycasting against the player, dynamic bodies colliding with
500
- // the capsule, etc.). The controller writes Transform directly,
501
- // physics derives velocity from the per-step delta. If a body is
502
- // missing the controller could still drive the camera, but the
503
- // physics integration silently breaks — assert here so the
504
- // misconfiguration is caught at link time.
505
- const rigidBody = ecd.getComponent(entity, RigidBody);
506
- assert.ok(rigidBody !== undefined,
507
- "FirstPersonPlayerController entity must have a co-attached RigidBody "
508
- + "(kinematic capsule). See prototype_first_person_controller.js for setup.");
509
- assert.equal(rigidBody.kind, BodyKind.KinematicPosition,
510
- "FirstPersonPlayerController RigidBody must be BodyKind.KinematicPosition; "
511
- + "the controller owns the Transform and physics derives velocity.");
512
- // Collider is also required — the KinematicMover casts this shape
513
- // against the world (recover / sweep / ground probe). Asserted
514
- // here so a missing collider surfaces at link rather than
515
- // producing a null-deref at the first cast attempt.
516
- const collider = ecd.getComponent(entity, Collider);
517
- assert.ok(collider !== undefined,
518
- "FirstPersonPlayerController entity must have a co-attached Collider. "
519
- + "The controller's move-and-slide casts this shape to detect blockers.");
520
-
521
- const runtime = new PerEntityRuntime();
522
- runtime.rigidBody = rigidBody;
523
- runtime.collider = collider;
524
-
525
- // Pre-build one capsule per posture from cfg.body. Eye-height
526
- // doubles as collider-top by convention here — the prototype's
527
- // `buildPlayerEntity` uses the same approximation (`totalHeight =
528
- // bodyCfg.height`). The +Y offset puts the capsule bottom at
529
- // transform.position so the player's "feet" stay anchored across
530
- // posture changes; only the head drops/rises.
531
- const radius = controller.config.body.radius;
532
- runtime.colliderShapeStand = makePostureCapsule(radius, controller.config.body.height);
533
- runtime.colliderShapeCrouch = makePostureCapsule(radius, controller.config.body.crouchHeight);
534
- runtime.colliderShapeProne = makePostureCapsule(radius, controller.config.body.proneHeight);
535
- // Cache each posture capsule's ACTUAL top (bbox maxY) — not the
536
- // nominal config height. A capsule's height floors at 2·radius,
537
- // so e.g. a 0.4 m "prone" height actually yields a 0.68 m
538
- // capsule. The headroom up-cast sweeps by the difference of real
539
- // tops; using config heights would over-/under-estimate the grow
540
- // and clamp to the wrong posture (Prone instead of Crouch).
541
- const bbox = new Float64Array(6);
542
- runtime.colliderShapeStand.compute_bounding_box(bbox);
543
- runtime.standTop = bbox[4];
544
- runtime.colliderShapeCrouch.compute_bounding_box(bbox);
545
- runtime.crouchTop = bbox[4];
546
- runtime.colliderShapeProne.compute_bounding_box(bbox);
547
- runtime.proneTop = bbox[4];
548
- // Force a shape sync on the first tick: even though the caller
549
- // built a Stand-sized collider, we rebuild it from cfg here so a
550
- // post-link config tweak (e.g. crouchHeight changed for a unit
551
- // test) is reflected on the live collider without a relink.
552
- runtime.lastPosture = -1;
553
-
554
- this.runtime.set(entity, runtime);
555
-
556
- // Derive gravity + jump impulse from designer-friendly params, then
557
- // mass-scale the initial velocity (heavier ⇒ lower jump).
558
- runtime.massRatios = computeMassRatios(
559
- controller.config.body.mass,
560
- controller.config.body.referenceMass,
561
- controller.config.body.massCouplingStrength,
562
- );
563
- const derived = { gravity: 0, initialVelocity: 0 };
564
- computeJumpFromApex(controller.config.jump.peakHeight, controller.config.jump.timeToApex, derived);
565
- runtime.gravity = derived.gravity;
566
- // Express jump power as a physical push-off FORCE (newtons): the force the
567
- // legs deliver to reach peakHeight at the REFERENCE mass. Stored so other
568
- // moves (the scramble wall-kick) can launch off the SAME force.
569
- const bodyCfg = controller.config.body;
570
- runtime.jumpForce = jumpForceForHeight(
571
- bodyCfg.referenceMass, runtime.gravity, bodyCfg.jumpPushoffDistance, controller.config.jump.peakHeight,
572
- );
573
- // Force → launch velocity for the reference body (== √(2·g·peakHeight)),
574
- // then the mass-coupling dial (jumpV0Scale ∝ 1/√mass, modulated by
575
- // massCouplingStrength) scales it for the actual body. Identical result
576
- // to the prior `derived.initialVelocity · jumpV0Scale`, now routed
577
- // through the force so jump power has a single physical source.
578
- runtime.jumpInitialVy =
579
- launchVelocityForForce(runtime.jumpForce, bodyCfg.referenceMass, bodyCfg.jumpPushoffDistance)
580
- * runtime.massRatios.jumpV0Scale;
581
-
582
- // Seed yaw from the starting body rotation. `toEulerAnglesYXZ`
583
- // returns (pitch, yaw, roll) — we only care about y.
584
- SCRATCH_Q_EULER.set(
585
- bodyTransform.rotation_x,
586
- bodyTransform.rotation_y,
587
- bodyTransform.rotation_z,
588
- bodyTransform.rotation_w
589
- );
590
- SCRATCH_Q_EULER.toEulerAnglesYXZ(SCRATCH_V3_A);
591
- runtime.bodyYaw = SCRATCH_V3_A.y;
592
- runtime.eyePitch = 0;
593
-
594
- // Seed render-rate-look + position-interpolation state to the spawn pose.
595
- runtime.prevFixedYaw = runtime.bodyYaw;
596
- runtime.renderPrevPos.set(bodyTransform.translation_x, bodyTransform.translation_y, bodyTransform.translation_z);
597
- runtime.renderCurPos.set(bodyTransform.translation_x, bodyTransform.translation_y, bodyTransform.translation_z);
598
-
599
- // Initialize springs to standing-eye-height baseline
600
- runtime.eyeHeightSpring.settle(controller.config.body.height);
601
- runtime.fovSpring.settle(controller.config.fov.base);
602
- controller.state.eyeHeight = controller.config.body.height;
603
-
604
- // Create eye entity if one wasn't supplied
605
- if (controller.eyeEntity === -1 || !ecd.entityExists(controller.eyeEntity)) {
606
- const eye = new Entity();
607
-
608
- const eyeTransform = new Transform64();
609
- const baseEyePos = SCRATCH_V3_A.set(bodyTransform.translation_x, bodyTransform.translation_y, bodyTransform.translation_z);
610
- baseEyePos.y += controller.config.body.height;
611
- eyeTransform.setTranslation(baseEyePos.x, baseEyePos.y, baseEyePos.z);
612
-
613
- const camera = new Camera();
614
- camera.active.set(true);
615
- camera.fov.set(controller.config.fov.base);
616
- camera.clip_near = 0.05;
617
- camera.clip_far = 1000;
618
- camera.autoClip = true;
619
-
620
- eye.add(eyeTransform);
621
- eye.add(camera);
622
- eye.add(SerializationMetadata.Transient);
623
-
624
- eye.build(ecd);
625
-
626
- controller.eyeEntity = eye.id;
627
- }
628
-
629
- runtime.eyeEntity = controller.eyeEntity;
630
- }
631
-
632
- /**
633
- * @param {FirstPersonPlayerController} controller
634
- * @param {Transform64} bodyTransform
635
- * @param {number} entity
636
- */
637
- unlink(controller, bodyTransform, entity) {
638
- const ecd = this.entityManager.dataset;
639
-
640
- if (controller.eyeEntity !== -1 && ecd.entityExists(controller.eyeEntity)) {
641
- ecd.removeEntity(controller.eyeEntity);
642
- controller.eyeEntity = -1;
643
- }
644
-
645
- this.runtime.delete(entity);
646
- }
647
-
648
- /**
649
- * Look up the per-entity runtime for an entity that has this
650
- * controller. Used by cross-system code (sensors system, future
651
- * ability-driven systems) to reach internal state without leaking
652
- * it onto the controller component itself.
653
- *
654
- * @param {number} entity
655
- * @returns {PerEntityRuntime|undefined} undefined if entity is not linked
656
- */
657
- getRuntime(entity) {
658
- return this.runtime.get(entity);
659
- }
660
-
661
- /**
662
- * Deterministic simulation step — L1 + L2 + L4.
663
- * @param {number} dt
664
- */
665
- fixedUpdate(dt) {
666
- const ecd = this.entityManager.dataset;
667
- if (ecd === null) return;
668
-
669
- this._currentDt = dt;
670
- ecd.traverseComponents(FirstPersonPlayerController, this._tickEntity, this);
671
- }
672
-
673
- /**
674
- * Variable-rate camera composition — L3.
675
- * @param {number} dt
676
- */
677
- update(dt) {
678
- const ecd = this.entityManager.dataset;
679
- if (ecd === null) return;
680
-
681
- this._currentRenderDt = dt;
682
- ecd.traverseComponents(FirstPersonPlayerController, this._composeEye, this);
683
- }
684
-
685
- /**
686
- * @private
687
- * @param {FirstPersonPlayerController} controller
688
- * @param {number} entity
689
- */
690
- _tickEntity(controller, entity) {
691
- const ecd = this.entityManager.dataset;
692
- const runtime = this.runtime.get(entity);
693
- if (runtime === undefined) return;
694
-
695
- const dt = this._currentDt;
696
- const cfg = controller.config;
697
- const intent = controller.intent;
698
- const state = controller.state;
699
- const sig = controller.signals;
700
-
701
- const bodyTransform = ecd.getComponent(entity, Transform64);
702
- if (bodyTransform === undefined) return;
703
-
704
- // Decay the mastery score's EMA. Doing this once per tick keeps the
705
- // score's time-window characteristic stable regardless of how many
706
- // evaluators fire (they each *record* a sample, the decay
707
- // independently ages all samples).
708
- controller.mastery.tick(dt);
709
-
710
- // -- L1.a: Derive the per-step turn rate ------------------------
711
- // Look (yaw/pitch) is consumed at RENDER rate in {@link _consumeLook} so
712
- // the camera tracks the mouse without 60Hz aliasing — consuming the
713
- // accumulated mouse delta once per fixed step beats against an unaligned
714
- // mouse report rate and makes a slow turn lurch (the reported snap).
715
- // fixedUpdate reads the latest yaw and derives the per-fixed-step turn
716
- // RATE for look-lean + the turn evaluators, from the yaw change since the
717
- // previous step (shortest-arc across the ±π wrap) — smooth and decoupled
718
- // from per-frame input jitter.
719
- let yawStep = runtime.bodyYaw - runtime.prevFixedYaw;
720
- if (yawStep > Math.PI) yawStep -= TWO_PI;
721
- else if (yawStep < -Math.PI) yawStep += TWO_PI;
722
- runtime.yawRateRadPerSec = yawStep / Math.max(dt, 1e-4);
723
- runtime.prevFixedYaw = runtime.bodyYaw;
724
-
725
- // Write body yaw back to the transform (pure yaw, no pitch on body) —
726
- // the collision sweep + sensors use it. The camera uses runtime.bodyYaw
727
- // directly, which _consumeLook keeps current at render rate.
728
- SCRATCH_Q_EULER.fromAxisAngle(Vector3.up, runtime.bodyYaw);
729
- bodyTransform.setRotation(
730
- SCRATCH_Q_EULER.x, SCRATCH_Q_EULER.y, SCRATCH_Q_EULER.z, SCRATCH_Q_EULER.w
731
- );
732
- bodyTransform.updateMatrix();
733
-
734
- // -- Shared flags. Computed BEFORE the ability tick so abilities
735
- // can read them. `isCrouchActive` is deliberately computed
736
- // AFTER the ability tick because `_resolveCrouchHeld` mutates
737
- // `runtime.prevCrouchHeld` — abilities like Slide need to see
738
- // the previous-tick value to detect a rising edge on the
739
- // crouch press.
740
- const isSprintIntent = intent.sprint && intent.move.y > 0.5 && state.grounded;
741
- const isBackwardIntent = intent.move.y < 0;
742
- runtime.sinYaw = Math.sin(runtime.bodyYaw);
743
- runtime.cosYaw = Math.cos(runtime.bodyYaw);
744
- // L2 observers read sinYaw/cosYaw as locals — destructure once.
745
- const { sinYaw, cosYaw } = runtime;
746
-
747
- // Age the ledge re-grab refractory window (dt-driven). Decremented
748
- // BEFORE the ability layer so LedgeGrab.canActivate sees the current
749
- // value; it gates the auto-catch for a beat after any dismount.
750
- if (runtime.ledgeRegrabCooldown > 0) {
751
- runtime.ledgeRegrabCooldown = Math.max(0, runtime.ledgeRegrabCooldown - dt);
752
- }
753
-
754
- // -- Ability layer: at most one active ability owns motion. The
755
- // set returns true when no ability owned the tick, in which
756
- // case base L1.b-h runs below; false means an ability fully
757
- // handled this tick (it called the system's helpers for any
758
- // standard work it wanted to keep, e.g. gravity).
759
- const runBaseLocomotion = controller.abilities.tick(
760
- controller, runtime, bodyTransform, runtime.sensors, dt, this,
761
- );
762
-
763
- // The ledge-pull-up request is set and consumed WITHIN abilities.tick
764
- // (LedgeGrab release → Mantle activation, same call). Clear it here as
765
- // a safety net so a pull-up that didn't activate a mantle can't leak
766
- // the flag into a later auto-mantle (which would let it vault a thin
767
- // wall it should have refused).
768
- runtime.ledgePullUpRequest = false;
769
-
770
- // Now resolve crouch (updates prevCrouchHeld) — used by base and L2.
771
- // Headroom-aware: force-keeps the player crouched under an overhang.
772
- const isCrouchActive = this._resolveCrouchHeld(controller, runtime, bodyTransform);
773
-
774
- if (runBaseLocomotion) {
775
- this._runBaseLocomotion(
776
- controller, runtime, bodyTransform, dt,
777
- isCrouchActive, isSprintIntent, isBackwardIntent,
778
- );
779
- }
780
-
781
- // (everything below this line runs every tick — L2 observers don't
782
- // care who owned motion)
783
-
784
- // -- L2.a: speed / moveMode ------------------------------------
785
- // -- L2.a: speed / moveMode ------------------------------------
786
- const horizSpeed = Math.hypot(runtime.velocityX, runtime.velocityZ);
787
- runtime.horizSpeed = horizSpeed;
788
- state.speed = horizSpeed;
789
- state.speedNormalized = clamp(horizSpeed / Math.max(cfg.motion.sprintSpeed, 1e-3), 0, 1);
790
-
791
- // Backwardness: 0 = moving forward (or sideways), 1 = moving directly
792
- // backward at the back-pedal speed ceiling. Derived from the actual
793
- // velocity (not the intent) so external knockback or stuck states
794
- // also register as "moving backward" and the gait wobble reflects it.
795
- //
796
- // Reference speed is the *achievable* backward max — walkSpeed ×
797
- // backwardSpeedFactor — NOT the sprint speed. Backward can never
798
- // reach sprint, so normalizing against sprint would cap backwardness
799
- // at ~0.3 and the wobble multipliers below would barely apply.
800
- const screenFwdVel = runtime.velocityX * sinYaw + runtime.velocityZ * cosYaw;
801
- const maxBackwardSpeed = Math.max(cfg.motion.walkSpeed * cfg.motion.backwardSpeedFactor, 1e-3);
802
- runtime.backwardness = clamp(-screenFwdVel / maxBackwardSpeed, 0, 1);
803
-
804
- // Locomotion mode is the *intent-driven* horizontal mode. Airborne
805
- // state is tracked separately on pose.actionState — they're
806
- // orthogonal facets (you can be Sprint+Airborne after a jump).
807
- const prevLocomotionMode = state.locomotionMode;
808
- if (isCrouchActive) {
809
- state.locomotionMode = FirstPersonLocomotionMode.Crouch;
810
- } else if (isSprintIntent && horizSpeed > 0.1) {
811
- state.locomotionMode = FirstPersonLocomotionMode.Sprint;
812
- } else if (horizSpeed > 0.1) {
813
- state.locomotionMode = FirstPersonLocomotionMode.Walk;
814
- } else {
815
- state.locomotionMode = FirstPersonLocomotionMode.Idle;
816
- }
817
-
818
- if (state.locomotionMode === FirstPersonLocomotionMode.Sprint
819
- && prevLocomotionMode !== FirstPersonLocomotionMode.Sprint) {
820
- sig.onSprintStart.send0();
821
- } else if (prevLocomotionMode === FirstPersonLocomotionMode.Sprint
822
- && state.locomotionMode !== FirstPersonLocomotionMode.Sprint) {
823
- sig.onSprintStop.send0();
824
- }
825
-
826
- // -- L2.b: Exertion --------------------------------------------
827
- // Heavier bodies tire faster — sprint rise scales with massRatios.exertionRiseScale.
828
- const exertionRise = isSprintIntent
829
- ? cfg.exertion.sprintRiseRate * runtime.massRatios.exertionRiseScale
830
- : 0;
831
- const exertionFall = exertionRise > 0 ? 0 : cfg.exertion.idleDecayRate;
832
- state.exertion = clamp(state.exertion + (exertionRise - exertionFall) * dt, 0, 1);
833
-
834
- // -- L2.c: Breath ----------------------------------------------
835
- // breathRate and breathAmplitude lag exertion through separate
836
- // exponential decays. Rate hangs around longer than amplitude.
837
- const metabolicRate = lerp(cfg.breath.rateRestHz, cfg.breath.rateMaxHz, state.exertion);
838
- const targetAmp = lerp(cfg.breath.amplitudeRestM, cfg.breath.amplitudeMaxM, state.exertion);
839
-
840
- // Locomotor-respiratory coupling — see math/computeLRCBreathRate.
841
- // The pure function is unit-tested; this site just provides inputs.
842
- //
843
- // Gait is gated on a "feet strike the ground" posture (Stand /
844
- // Crouch). Prone (slide) and Hang (ledge-grab) have no stride —
845
- // the body's feet are not making contact in a walking pattern,
846
- // so stride frequency drops to zero and downstream gait
847
- // signals (footsteps, bob intensity) go quiet.
848
- const feetStriking = state.posture === FirstPersonPosture.Stand
849
- || state.posture === FirstPersonPosture.Crouch;
850
- const strideFreqHz = feetStriking && state.grounded && horizSpeed > cfg.bob.minStepSpeed
851
- ? cfg.bob.stepFreqAtWalk * Math.pow(
852
- Math.max(horizSpeed, 1e-3) / Math.max(cfg.motion.walkSpeed, 1e-3),
853
- cfg.bob.stepFreqExp,
854
- )
855
- : 0;
856
- const targetRate = computeLRCBreathRate(
857
- metabolicRate,
858
- strideFreqHz,
859
- state.exertion,
860
- cfg.breath.locomotorCouplingMax,
861
- cfg.breath.couplingMinStrideFreqHz,
862
- );
863
- state.breathRateHz = exponentialApproach(state.breathRateHz, targetRate, cfg.exertion.rateDecayHalfLife, dt);
864
- state.breathAmplitudeM = exponentialApproach(state.breathAmplitudeM, targetAmp, cfg.exertion.ampDecayHalfLife, dt);
865
-
866
- runtime.prevBreathPhase = state.breathPhase;
867
- state.breathPhase += state.breathRateHz * dt;
868
- state.breathPhase -= Math.floor(state.breathPhase); // wrap [0,1)
869
-
870
- // Breath edge detection — inhale at 0.25, exhale at 0.75
871
- if (phaseCrossed(runtime.prevBreathPhase, state.breathPhase, 0.25)) {
872
- sig.onBreathIn.send1({ amplitude: state.breathAmplitudeM, rateHz: state.breathRateHz });
873
- }
874
- if (phaseCrossed(runtime.prevBreathPhase, state.breathPhase, 0.75)) {
875
- sig.onBreathOut.send1({ amplitude: state.breathAmplitudeM, rateHz: state.breathRateHz });
876
- }
877
-
878
- // -- L2.d: Stride ----------------------------------------------
879
- // strideFreqHz computed above in the breath block; reused here.
880
- runtime.prevStridePhase = state.stridePhase;
881
- if (strideFreqHz > 0) {
882
- // 1 full stride cycle = 2 footfalls; phase advances at freq/2 of cycle
883
- state.stridePhase += (strideFreqHz * 0.5) * dt;
884
- state.stridePhase -= Math.floor(state.stridePhase);
885
- }
886
- // Footstep on phase wraparound past 0 (R) or past 0.5 (L). Same
887
- // posture gate as stride advance — feet must be striking.
888
- if (feetStriking && state.grounded && horizSpeed > cfg.bob.minStepSpeed) {
889
- const fireFootstep = () => {
890
- state.stepCount++;
891
- const side = runtime.nextFootSide;
892
- runtime.nextFootSide = side === "R" ? "L" : "R";
893
- // The foot that just fired is now the one bearing weight
894
- // through the upcoming half-stride. Drives lateral-bob sign.
895
- runtime.standingFoot = side;
896
- sig.onFootStep.send1({ side, speed: horizSpeed, surfaceTag: state.surfaceTag });
897
- // Kick the vertical impact spring DOWNWARD. The kick magnitude
898
- // is the per-step desired peak dip × impactKickMultiplier; the
899
- // multiplier is empirical (depends on impact spring params) so
900
- // that "verticalAmpAtWalk" still corresponds approximately to
901
- // the visible peak dip depth. Scaled by bobIntensity so a
902
- // mid-deceleration footstep doesn't deliver a full-strength
903
- // impulse.
904
- const massBoost = (cfg.body.mass - 80) * cfg.bob.ampMassScale;
905
- const ampVMult = 1 + (cfg.bob.backwardVerticalAmpFactor - 1) * runtime.backwardness;
906
- const peakDip = (cfg.bob.verticalAmpAtWalk + massBoost) * runtime.bobIntensitySpring.value * ampVMult;
907
- runtime.verticalImpactSpring.kick(-peakDip * cfg.bob.impactKickMultiplier);
908
- };
909
- if (phaseCrossed(runtime.prevStridePhase, state.stridePhase, 0)) {
910
- fireFootstep();
911
- }
912
- if (phaseCrossed(runtime.prevStridePhase, state.stridePhase, 0.5)) {
913
- fireFootstep();
914
- }
915
- }
916
-
917
- // -- L2.d.bob-intensity & impact -------------------------------
918
- // Smoothed bob amplitude envelope: when the player starts/stops
919
- // moving the visible bob fades in/out rather than cutting on/off.
920
- // Target = the "natural" amp scale (max of speed and backwardness)
921
- // while grounded, zero while airborne so the bob disappears mid-jump.
922
- const naturalBobIntensity = Math.max(state.speedNormalized, runtime.backwardness);
923
- // Bob fades to zero whenever feet aren't striking (airborne, or
924
- // Prone/Hang posture). The verticalImpactSpring (separate
925
- // channel) still carries any entry/landing kicks through to the
926
- // camera, but no recurring step bob.
927
- const targetBobIntensity = (state.grounded && feetStriking) ? naturalBobIntensity : 0;
928
- runtime.bobIntensitySpring.stepTo(targetBobIntensity, cfg.bob.intensityHalfLife, 1.0, dt);
929
-
930
- // Vertical impact spring — damped decay toward 0, with the under-
931
- // damped overshoot that produces the recovery + leg-push curve.
932
- runtime.verticalImpactSpring.stepTo(0, cfg.bob.impactSpringHalfLife, cfg.bob.impactSpringZeta, dt);
933
-
934
- // Sprint posture — head pitches forward as commitment to sprint
935
- // builds. Driven by "sprintness" — how much of the gap between
936
- // walk and sprint speed the player is *currently* in (0..1). The
937
- // pitch target is multiplied by sprintness, then critically damped.
938
- // Only applies while grounded — pitching into airborne motion looks weird.
939
- const sprintness = clamp(
940
- (state.speed - cfg.motion.walkSpeed)
941
- / Math.max(cfg.motion.sprintSpeed - cfg.motion.walkSpeed, 1e-3),
942
- 0, 1,
943
- );
944
- const targetSprintPitch = state.grounded
945
- ? cfg.posture.sprintForwardPitchDeg * DEG_TO_RAD * sprintness
946
- : 0;
947
- runtime.sprintPostureSpring.stepTo(
948
- targetSprintPitch,
949
- cfg.posture.sprintForwardPitchHalfLife,
950
- 1.0, dt,
951
- );
952
- runtime.sprintness = sprintness;
953
-
954
- // Head droop — exertion drives a subtle additional forward pitch.
955
- // Combines with sprintPostureSpring (sprint = head down to commit)
956
- // so a fatigued sprinter has BOTH effects layered.
957
- const targetDroopRad = cfg.exertion.headDroopAtMaxDeg * DEG_TO_RAD * state.exertion;
958
- runtime.headDroopSpring.stepTo(targetDroopRad, cfg.exertion.headDroopHalfLife, 1.0, dt);
959
-
960
- // -- L2.e: Posture → eye height --------------------------------
961
- // Posture is set by whichever layer owned motion this tick: base
962
- // writes Stand / Crouch from isCrouchActive (see end of
963
- // _runBaseLocomotion); active abilities write Prone (Slide) or
964
- // Hang (LedgeGrab) in their tick. Mapping is one switch — adding
965
- // a new posture is one enum value + one case.
966
- let targetEyeH;
967
- switch (state.posture) {
968
- case FirstPersonPosture.Prone: targetEyeH = cfg.body.proneHeight; break;
969
- case FirstPersonPosture.Crouch: targetEyeH = cfg.body.crouchHeight; break;
970
- case FirstPersonPosture.Hang: targetEyeH = cfg.body.height; break;
971
- case FirstPersonPosture.Stand:
972
- default: targetEyeH = cfg.body.height; break;
973
- }
974
- const crouchHalfLife = cfg.crouch.transitionTime / 4; // halfLife is ~quarter of full transition
975
- runtime.eyeHeightSpring.stepTo(targetEyeH, crouchHalfLife, 1.0, dt);
976
- state.eyeHeight = runtime.eyeHeightSpring.value;
977
-
978
- if (isCrouchActive !== state.crouchActive) {
979
- state.crouchActive = isCrouchActive;
980
- if (isCrouchActive) {
981
- sig.onCrouchEnter.send0();
982
- // Impulse: dropping into a crouch grips the knees. Small
983
- // bump — we don't want crouch-spamming to instantly tire.
984
- state.exertion = clamp(
985
- state.exertion + cfg.exertion.crouchEnterRise * runtime.massRatios.exertionRiseScale,
986
- 0, 1,
987
- );
988
- } else {
989
- sig.onCrouchExit.send0();
990
- }
991
- }
992
-
993
- // -- L2.f: Lean spring → camera roll ---------------------------
994
- // The TARGET for this tick was written by whichever layer owned
995
- // motion: base writes the lat-accel + look-lean derived value at
996
- // the end of _runBaseLocomotion; abilities override (WallRun
997
- // tilts toward the wall; Slide / LedgeGrab / Mantle force zero).
998
- // L2.f is now a flat spring-step + commit — no branching, no
999
- // null sentinel.
1000
- runtime.prevVelocityX = runtime.velocityX;
1001
- runtime.prevVelocityZ = runtime.velocityZ;
1002
- runtime.leanSpring.stepTo(runtime.leanTargetRad, cfg.lean.spring.halfLife, cfg.lean.spring.zeta, dt);
1003
- state.leanRollRad = runtime.leanSpring.value;
1004
-
1005
- // -- L2.g: Land spring decay (drives the landing recovery dip) -
1006
- // Target is 0; under-damped (cfg zeta < 1) so it rings.
1007
- runtime.landSpring.stepTo(0, cfg.landing.recovery.spring.halfLife, cfg.landing.recovery.spring.zeta, dt);
1008
-
1009
- // -- L2.h: Publish pose channels --------------------------------
1010
- this._publishPose(controller, runtime, bodyTransform);
1011
-
1012
- // -- L2.h.1: Say that the body moved ----------------------------
1013
- // The yaw write above and the mover's write into the translation both land straight on the
1014
- // component, and a Transform64 announces nothing on its own. One event here, at the point
1015
- // where the pose is final, covers every writer in the tick and covers them once - which is
1016
- // what wakes the mesh placement, the audio emitter and anything else that follows a player.
1017
- t64_announce_change(ecd, entity, bodyTransform);
1018
-
1019
- // -- L2.i: Sync collider shape to posture -----------------------
1020
- // All posture-writers (base locomotion + any active ability)
1021
- // have run for this tick. Swap the collider's shape to the
1022
- // pre-built capsule matching the final posture so downstream
1023
- // physics queries (move-and-slide cast, sensors, overlap from
1024
- // outside) see the right volume. No-op when posture is
1025
- // unchanged.
1026
- this._syncColliderShape(runtime, state.posture);
1027
- }
1028
-
1029
- /**
1030
- * @private
1031
- * @param {FirstPersonPlayerController} controller
1032
- * @param {PerEntityRuntime} runtime
1033
- * @returns {boolean}
1034
- */
1035
- /**
1036
- * Swap {@link Collider.shape} to the pre-built capsule that matches
1037
- * the player's current posture. Cheap — just a reference swap when
1038
- * the posture changed, no-op otherwise. The pre-built shapes live
1039
- * on the runtime (see {@link PerEntityRuntime.colliderShapeStand}
1040
- * etc.) so this method allocates nothing per tick.
1041
- *
1042
- * Hang posture reuses Stand: the player's body is full-extent,
1043
- * hanging below the ledge — the rig handles the arms-up animation,
1044
- * but the collision volume is unchanged. If a game ever wants a
1045
- * narrower hang silhouette (e.g. wedging into a chimney) it can
1046
- * add a `colliderShapeHang` and route here.
1047
- *
1048
- * @private
1049
- */
1050
- _syncColliderShape(runtime, posture) {
1051
- if (posture === runtime.lastPosture) return;
1052
- let next;
1053
- if (posture === FirstPersonPosture.Crouch) {
1054
- next = runtime.colliderShapeCrouch;
1055
- } else if (posture === FirstPersonPosture.Prone) {
1056
- next = runtime.colliderShapeProne;
1057
- } else {
1058
- // Stand and Hang share the full-extent capsule.
1059
- next = runtime.colliderShapeStand;
1060
- }
1061
- runtime.collider.shape = next;
1062
- runtime.lastPosture = posture;
1063
- }
1064
-
1065
- _resolveCrouchHeld(controller, runtime, bodyTransform) {
1066
- const cfg = controller.config;
1067
- const intent = controller.intent;
1068
- const state = controller.state;
1069
-
1070
- let wantsCrouch;
1071
- if (cfg.crouch.mode === "toggle") {
1072
- // Edge: rising press flips the latch
1073
- if (intent.crouch && !runtime.prevCrouchHeld) {
1074
- runtime.crouchLatched = !runtime.crouchLatched;
1075
- }
1076
- runtime.prevCrouchHeld = intent.crouch;
1077
- wantsCrouch = runtime.crouchLatched;
1078
- } else {
1079
- // "hold" mode
1080
- runtime.prevCrouchHeld = intent.crouch;
1081
- wantsCrouch = intent.crouch;
1082
- }
1083
-
1084
- // Headroom override — block UN-crouching under an overhang. A player who
1085
- // is ALREADY low (crouched or prone) and wants to stand is force-kept
1086
- // crouched while the Stand collider won't fit: letting it grow into the
1087
- // overhang would have the next tick's depenetration shove the player out
1088
- // the bottom, through the floor. `crouchBlocked` records this for HUD /
1089
- // camera.
1090
- //
1091
- // Crucially this fires ONLY when already low. A STANDING player who walks
1092
- // under a low ceiling is NEVER auto-pushed down into a crouch — their
1093
- // full-height capsule simply can't fit, so the mover blocks their forward
1094
- // motion instead. "If I can't clear it, I don't move forward." Dropping
1095
- // posture is the player's choice (press crouch), not the ceiling's.
1096
- const alreadyLow = runtime.lastPosture === FirstPersonPosture.Crouch
1097
- || runtime.lastPosture === FirstPersonPosture.Prone;
1098
- if (!wantsCrouch && alreadyLow) {
1099
- const growDelta = runtime.standTop - runtime.crouchTop;
1100
- if (!this._hasHeadroomToGrow(runtime, bodyTransform, runtime.colliderShapeCrouch, growDelta)) {
1101
- state.crouchBlocked = true;
1102
- return true;
1103
- }
1104
- }
1105
- state.crouchBlocked = false;
1106
- return wantsCrouch;
1107
- }
1108
-
1109
- /**
1110
- * Headroom probe for a posture change that GROWS the collider.
1111
- * Sweeps `fromShape` (the shorter posture's capsule) straight up by
1112
- * `growDelta` (the height the collider would gain). A hit means an
1113
- * overhang occupies the space the taller posture needs, so the grow
1114
- * is blocked. Returns true (clear) when no physics is wired or the
1115
- * grow is non-positive.
1116
- *
1117
- * Why an up-cast rather than an overlap-test of the taller shape:
1118
- * overlap would false-positive on a wall *beside* the player. The
1119
- * up-cast sweeps parallel to (and, per the mover's skin clearance,
1120
- * clear of) lateral walls so they aren't hit; the floor is below
1121
- * the rising probe; the player's own body is filtered out. Only
1122
- * genuine overhead geometry blocks the grow. Relies on the player
1123
- * resting at `floor+skin` (the mover's stick-to-ground), so the
1124
- * probe's bottom starts clear of the floor.
1125
- *
1126
- * @private
1127
- */
1128
- _hasHeadroomToGrow(runtime, bodyTransform, fromShape, growDelta) {
1129
- if (this.physicsSystem === null || growDelta <= 1e-4) return true;
1130
- const ownCollider = runtime.collider;
1131
- const filter = (_e, c) => c !== ownCollider;
1132
- const ray = this._postureRay;
1133
- ray.setOrigin(bodyTransform.translation_x, bodyTransform.translation_y, bodyTransform.translation_z);
1134
- ray.setDirection(0, 1, 0);
1135
- ray.tMax = growDelta;
1136
- return !this.physicsSystem.shapeCast(ray, fromShape, bodyTransform.rotation, this._postureHit, filter);
1137
- }
1138
-
1139
- /**
1140
- * Jump finite-state-machine: button-edge detection, buffer + coyote
1141
- * grace, anticipation timer, impulse on completion. Variable-height
1142
- * cut is captured here as a `state.isVariableJumpCut` flag that the
1143
- * gravity step in `_integrateVerticalAndResolveGround` consumes.
1144
- *
1145
- * @private
1146
- * @param {FirstPersonPlayerController} controller
1147
- * @param {PerEntityRuntime} runtime
1148
- * @param {Transform64} bodyTransform
1149
- * @param {number} dt
1150
- */
1151
- _advanceJumpFsm(controller, runtime, bodyTransform, dt) {
1152
- const cfg = controller.config;
1153
- const intent = controller.intent;
1154
- const state = controller.state;
1155
- const sig = controller.signals;
1156
-
1157
- const jumpPressedEdge = intent.jump && !runtime.prevJumpHeld;
1158
- const jumpReleasedEdge = !intent.jump && runtime.prevJumpHeld;
1159
- runtime.prevJumpHeld = intent.jump;
1160
-
1161
- if (jumpPressedEdge) {
1162
- state.jumpBufferRemaining = cfg.jump.bufferTime;
1163
- }
1164
- state.jumpBufferRemaining = Math.max(0, state.jumpBufferRemaining - dt);
1165
-
1166
- const canJumpNow =
1167
- (state.grounded || state.timeSinceGrounded < cfg.jump.coyoteTime)
1168
- && state.jumpBufferRemaining > 0
1169
- && !state.inJumpAnticipation
1170
- && !runtime.midJump;
1171
-
1172
- if (canJumpNow) {
1173
- // Begin anticipation — squash; impulse fires after duration elapses
1174
- state.inJumpAnticipation = true;
1175
- runtime.anticipationRemaining = cfg.jump.anticipation.duration;
1176
- state.jumpBufferRemaining = 0; // claimed
1177
- }
1178
-
1179
- // Variable-height cut: only valid during ascent, post-launch.
1180
- if (jumpReleasedEdge && runtime.midJump && runtime.velocityY > 0) {
1181
- state.isVariableJumpCut = true;
1182
- }
1183
-
1184
- // Anticipation timer; impulse on completion.
1185
- //
1186
- // Anticipation completes regardless of grounded state. The reason
1187
- // we DON'T cancel on `!grounded`: the canonical coyote-jump path
1188
- // depends on it. The player walks off a ledge (grounded → false),
1189
- // presses jump within the coyote window, canJumpNow accepts on
1190
- // the coyote branch and starts anticipation. If we cancelled
1191
- // anticipation here on !grounded, the impulse would never fire
1192
- // and "coyote time" would be silently dead — the FSM's own next-
1193
- // statement contradicting the canJumpNow gate three lines up.
1194
- //
1195
- // The same logic handles the rug-pull case (player on a moving
1196
- // platform that slides out mid-anticipation): the player
1197
- // committed to the jump, they get the jump. A future
1198
- // knockback / stagger system can explicitly clear
1199
- // inJumpAnticipation if it wants to override that commitment.
1200
- if (state.inJumpAnticipation) {
1201
- runtime.anticipationRemaining -= dt;
1202
- if (runtime.anticipationRemaining <= 0) {
1203
- // Mastery: gather a multiplier from all evaluators
1204
- // registered for JumpImpulse. Default (no evaluators)
1205
- // returns 1.0 → unchanged behaviour.
1206
- const masteryMul = controller.mastery.evaluate(
1207
- DecisionPoint.JumpImpulse, controller, runtime,
1208
- );
1209
- runtime.velocityY = runtime.jumpInitialVy * masteryMul;
1210
- runtime.midJump = true;
1211
- runtime.apexFired = false;
1212
- runtime.peakAltitude = bodyTransform.translation_y;
1213
- state.inJumpAnticipation = false;
1214
- state.isVariableJumpCut = false;
1215
- state.isAscending = true;
1216
- state.exertion = clamp(
1217
- state.exertion + cfg.exertion.jumpRise * runtime.massRatios.exertionRiseScale,
1218
- 0, 1,
1219
- );
1220
-
1221
- sig.onJumpStart.send1({ peakHeight: cfg.jump.peakHeight });
1222
- // NOTE: onLeaveGround is fired by the motor (_onLeaveGround)
1223
- // when the move actually breaks ground contact this same tick —
1224
- // with reason "jump" since midJump is now true. Firing it here
1225
- // too double-sent the signal on every grounded jump (and a
1226
- // spurious second "jump" leave on a coyote jump, which already
1227
- // left ground on the walk-off). Let the motor own it.
1228
- }
1229
- }
1230
- }
1231
-
1232
- /**
1233
- * The vertical + collision phase of one fixed step: apply gravity
1234
- * (the motor), resolve the move against the world, then detect the
1235
- * jump apex. Collision resolution is the {@link KinematicMover} when
1236
- * a physics layer is present; otherwise a lightweight flat-ground
1237
- * integrator for headless / unit-test scenes with no collidable
1238
- * geometry. Land / leave-ground events flow identically from either.
1239
- *
1240
- * @private
1241
- * @param {FirstPersonPlayerController} controller
1242
- * @param {PerEntityRuntime} runtime
1243
- * @param {Transform64} bodyTransform
1244
- * @param {number} dt
1245
- */
1246
- _integrateVerticalAndResolveGround(controller, runtime, bodyTransform, dt) {
1247
- this._applyGravity(controller, runtime, dt);
1248
- this._resolveMotion(controller, runtime, bodyTransform, dt);
1249
- this._detectJumpApex(controller, runtime, bodyTransform);
1250
- }
1251
-
1252
- /**
1253
- * Resolve the current `runtime.velocity*` against the world — the
1254
- * physics-backed {@link KinematicMover} (recover + sweep-and-slide +
1255
- * ground-categorize) when a PhysicsSystem is present, else the no-physics
1256
- * flat-ground fallback. Gravity is NOT applied here: the standard path
1257
- * applies it in {@link _applyGravity} just before, and abilities with a
1258
- * non-standard vertical model (WallRun's reduced gravity) apply their own
1259
- * and then call this directly — so motion routes through one motor for
1260
- * everyone (sweep-and-slide, anti-tunnel, land/leave events) rather than
1261
- * a bespoke `position._add` + hand-rolled ground-catch.
1262
- * @private
1263
- */
1264
- _resolveMotion(controller, runtime, bodyTransform, dt) {
1265
- if (this.physicsSystem !== null) {
1266
- this._moveViaMover(controller, runtime, bodyTransform, dt);
1267
- } else {
1268
- this._moveFlatGround(controller, runtime, bodyTransform, dt);
1269
- }
1270
- }
1271
-
1272
- /**
1273
- * Motor: integrate gravity into `velocityY` with the fall / variable-
1274
- * cut multipliers. The mover never invents motion, so gravity lives
1275
- * here (callable by abilities that want standard gravity too).
1276
- * @private
1277
- */
1278
- _applyGravity(controller, runtime, dt) {
1279
- const cfg = controller.config;
1280
- const state = controller.state;
1281
- let gMag = runtime.gravity;
1282
- if (runtime.velocityY <= 0) {
1283
- gMag *= cfg.jump.fallGravityMult;
1284
- state.isAscending = false;
1285
- } else if (state.isVariableJumpCut) {
1286
- gMag *= cfg.jump.cutGravityMult;
1287
- }
1288
- runtime.velocityY -= gMag * dt;
1289
- }
1290
-
1291
- /**
1292
- * Resolve the move against the world via {@link KinematicMover}
1293
- * (recover + unified sweep-and-slide + ground-categorize), then map
1294
- * the result back to controller state and land / leave events.
1295
- * @private
1296
- */
1297
- _moveViaMover(controller, runtime, bodyTransform, dt) {
1298
- const state = controller.state;
1299
-
1300
- if (this._mover === null) {
1301
- this._mover = new KinematicMover(this.physicsSystem, this.entityManager.dataset);
1302
- }
1303
-
1304
- // Fall speed captured BEFORE the move — the mover clips velocityY
1305
- // to ~0 on contact, so the land event needs the pre-move value.
1306
- const impactVy = -runtime.velocityY;
1307
-
1308
- const v = this._moverVelocity;
1309
- v.set(runtime.velocityX, runtime.velocityY, runtime.velocityZ);
1310
-
1311
- const ownCollider = runtime.collider;
1312
- const filter = (_e, c) => c !== ownCollider;
1313
-
1314
- const result = this._mover.move(
1315
- bodyTransform.translation,
1316
- bodyTransform.rotation,
1317
- runtime.collider.shape,
1318
- v, dt, filter,
1319
- );
1320
-
1321
- // Read back corrected horizontal velocity. Vertical follows the
1322
- // control policy: zero on ground (the slope-climb the mover
1323
- // produced is a position effect, not accumulated speed), keep the
1324
- // mover-clipped value in the air (captures ceiling bonks).
1325
- runtime.velocityX = v.x;
1326
- runtime.velocityZ = v.z;
1327
- runtime.velocityY = result.grounded ? 0 : v.y;
1328
-
1329
- const wasGrounded = state.grounded;
1330
- if (result.grounded) {
1331
- if (!wasGrounded) this._onLand(controller, runtime, impactVy);
1332
- state.grounded = true;
1333
- state.groundNormal.copy(result.groundNormal);
1334
- state.verticalSpeed = 0;
1335
- state.airborneTime = 0;
1336
- state.timeSinceGrounded = 0;
1337
- } else {
1338
- if (wasGrounded) this._onLeaveGround(controller, runtime, bodyTransform);
1339
- state.grounded = false;
1340
- state.verticalSpeed = runtime.velocityY;
1341
- state.airborneTime += dt;
1342
- state.timeSinceGrounded += dt;
1343
- state.fallDistance += Math.max(0, -runtime.velocityY * dt);
1344
- }
1345
- }
1346
-
1347
- /**
1348
- * No-physics fallback: integrate the velocity directly (there's no
1349
- * collidable geometry to sweep against) and resolve the floor from
1350
- * the built-in flat ground and/or the host `groundResolver`. For
1351
- * headless scenes and control-layer unit tests. With both ground
1352
- * sources off, an external physics layer is expected to own
1353
- * `state.grounded`; we only advance timers.
1354
- * @private
1355
- */
1356
- _moveFlatGround(controller, runtime, bodyTransform, dt) {
1357
- const state = controller.state;
1358
-
1359
- // translation lands in the matrix as it is written, so no updateMatrix is owed here
1360
- bodyTransform.setTranslation(
1361
- bodyTransform.translation_x + runtime.velocityX * dt,
1362
- bodyTransform.translation_y + runtime.velocityY * dt,
1363
- bodyTransform.translation_z + runtime.velocityZ * dt
1364
- );
1365
-
1366
- if (this.useBuiltInFlatGround || this.groundResolver !== null) {
1367
- // Effective ground = max(built-in flat ground, resolver).
1368
- let testY = this.useBuiltInFlatGround ? this.groundY : Number.NEGATIVE_INFINITY;
1369
- if (this.groundResolver !== null) {
1370
- const resolved = this.groundResolver(
1371
- bodyTransform.translation_x, bodyTransform.translation_y, bodyTransform.translation_z,
1372
- );
1373
- if (resolved !== null && resolved > testY) testY = resolved;
1374
- }
1375
- const haveGround = testY !== Number.NEGATIVE_INFINITY;
1376
- if (haveGround && bodyTransform.translation_y <= testY) {
1377
- bodyTransform.setTranslation(bodyTransform.translation_x, testY, bodyTransform.translation_z);
1378
- if (!state.grounded) this._onLand(controller, runtime, -runtime.velocityY);
1379
- state.grounded = true;
1380
- state.groundNormal.set(0, 1, 0);
1381
- state.verticalSpeed = 0;
1382
- runtime.velocityY = 0;
1383
- state.airborneTime = 0;
1384
- state.timeSinceGrounded = 0;
1385
- } else {
1386
- if (state.grounded) this._onLeaveGround(controller, runtime, bodyTransform);
1387
- state.grounded = false;
1388
- state.verticalSpeed = runtime.velocityY;
1389
- state.airborneTime += dt;
1390
- state.timeSinceGrounded += dt;
1391
- state.fallDistance += Math.max(0, -runtime.velocityY * dt);
1392
- }
1393
- } else {
1394
- // External physics maintains state.grounded; just track timers.
1395
- if (state.grounded) {
1396
- state.timeSinceGrounded = 0;
1397
- state.airborneTime = 0;
1398
- } else {
1399
- state.timeSinceGrounded += dt;
1400
- state.airborneTime += dt;
1401
- }
1402
- }
1403
- }
1404
-
1405
- /**
1406
- * Landing transition: dip / impact springs, exertion impulse, flag
1407
- * resets, and the `onLand` signal (fired LAST so handlers see fully-
1408
- * reacted state). Shared by both move paths. `impactVy` is the
1409
- * downward speed at touchdown (positive).
1410
- * @private
1411
- */
1412
- _onLand(controller, runtime, impactVy) {
1413
- const cfg = controller.config;
1414
- const state = controller.state;
1415
- const kind = impactVy >= cfg.landing.hardThreshold ? "hard" : "soft";
1416
-
1417
- const massScaledDip = impactVy * cfg.landing.recovery.dipPerVy
1418
- * runtime.massRatios.landingDipScale;
1419
- const dip = clamp(massScaledDip, 0, cfg.landing.recovery.dipMax);
1420
- runtime.landSpring.settle(-dip);
1421
-
1422
- const landImpulse = clamp(
1423
- impactVy * cfg.exertion.landImpulsePerVy * runtime.massRatios.exertionRiseScale,
1424
- 0, cfg.exertion.landImpulseMax,
1425
- );
1426
- state.exertion = clamp(state.exertion + landImpulse, 0, 1);
1427
-
1428
- runtime.midJump = false;
1429
- state.isAscending = false;
1430
- state.isVariableJumpCut = false;
1431
- state.fallDistance = 0;
1432
-
1433
- controller.signals.onLand.send1({ verticalSpeed: impactVy, kind });
1434
- }
1435
-
1436
- /**
1437
- * Leave-ground transition: `onLeaveGround` signal + takeoff bookkeeping.
1438
- * Shared by both move paths.
1439
- * @private
1440
- */
1441
- _onLeaveGround(controller, runtime, bodyTransform) {
1442
- controller.signals.onLeaveGround.send1({ reason: runtime.midJump ? "jump" : "fall" });
1443
- runtime.takeoffVy = runtime.velocityY;
1444
- runtime.peakAltitude = bodyTransform.translation_y;
1445
- }
1446
-
1447
- /**
1448
- * Fire `onJumpApex` once, when an in-progress jump stops rising.
1449
- * @private
1450
- */
1451
- _detectJumpApex(controller, runtime, bodyTransform) {
1452
- if (runtime.midJump && !runtime.apexFired) {
1453
- if (bodyTransform.translation_y > runtime.peakAltitude) {
1454
- runtime.peakAltitude = bodyTransform.translation_y;
1455
- } else if (runtime.velocityY <= 0) {
1456
- controller.signals.onJumpApex.send0();
1457
- runtime.apexFired = true;
1458
- }
1459
- }
1460
- }
1461
-
1462
- /**
1463
- * Run the base (no-ability) L1 locomotion phases: speed selection,
1464
- * desired-velocity computation, accel/decel, jump FSM, gravity, body
1465
- * integration, ground resolution. Only invoked when no ability owns
1466
- * the tick (see {@link AbilitySet.tick}).
1467
- *
1468
- * @private
1469
- * @param {FirstPersonPlayerController} controller
1470
- * @param {PerEntityRuntime} runtime
1471
- * @param {Transform64} bodyTransform
1472
- * @param {number} dt
1473
- * @param {boolean} isCrouchActive
1474
- * @param {boolean} isSprintIntent
1475
- * @param {boolean} isBackwardIntent
1476
- */
1477
- _runBaseLocomotion(controller, runtime, bodyTransform, dt,
1478
- isCrouchActive, isSprintIntent, isBackwardIntent) {
1479
- const cfg = controller.config;
1480
- const intent = controller.intent;
1481
- const state = controller.state;
1482
-
1483
- // -- L1.b: Speed selection ------------------------------------
1484
- let targetSpeed;
1485
- if (isCrouchActive) {
1486
- targetSpeed = cfg.motion.crouchSpeed;
1487
- } else if (isSprintIntent) {
1488
- targetSpeed = cfg.motion.sprintSpeed;
1489
- } else {
1490
- targetSpeed = cfg.motion.walkSpeed;
1491
- }
1492
- if (isBackwardIntent) {
1493
- targetSpeed *= cfg.motion.backwardSpeedFactor;
1494
- }
1495
-
1496
- // Airborne momentum floor — preserve whatever horizontal speed
1497
- // the player carried into the jump. Without this, a sprint
1498
- // jump (9 m/s) decays toward walkSpeed (4.5 m/s) at
1499
- // airAccel = 14 m/s², losing all sprint momentum in ~0.32 s —
1500
- // well before the apex of a `peakHeight = 1.8 m` jump arc. The
1501
- // air-control band (Mirror's Edge, Titanfall, modern CoD) and
1502
- // the long-jump biomechanics literature both say the same
1503
- // thing: there's no thrust source in flight, so horizontal
1504
- // velocity is conserved across the arc and air "control" is
1505
- // for steering (direction) — not for changing speed magnitude.
1506
- // Raising the target to the current speed makes `stepTowards`
1507
- // a no-op when the player keeps pressing forward, while
1508
- // releasing the stick still lets `airAccel` decelerate to
1509
- // `walkSpeed` (the user CAN bleed off speed, just not have it
1510
- // bled off for them).
1511
- if (!state.grounded) {
1512
- const horizSpeed = Math.hypot(runtime.velocityX, runtime.velocityZ);
1513
- if (horizSpeed > targetSpeed) targetSpeed = horizSpeed;
1514
- }
1515
-
1516
- // -- L1.c: Move intent → desired horizontal velocity ----------
1517
- // screen_forward(θ) = ( sin θ, 0, cos θ )
1518
- // screen_right (θ) = (-cos θ, 0, sin θ )
1519
- const { sinYaw, cosYaw } = runtime;
1520
- const mvX = intent.move.x;
1521
- const mvY = intent.move.y;
1522
- const mvMag = Math.hypot(mvX, mvY);
1523
- const nmvX = mvMag > 1 ? mvX / mvMag : mvX;
1524
- const nmvY = mvMag > 1 ? mvY / mvMag : mvY;
1525
- const desiredVx = sinYaw * nmvY + -cosYaw * nmvX;
1526
- const desiredVz = cosYaw * nmvY + sinYaw * nmvX;
1527
- const desiredHorizontalVx = desiredVx * targetSpeed;
1528
- const desiredHorizontalVz = desiredVz * targetSpeed;
1529
-
1530
- // -- L1.d: Accel/decel toward desired velocity ----------------
1531
- //
1532
- // Three regimes — air control, grounded decel-to-stop, grounded
1533
- // accel-to-target — each with its own model:
1534
- //
1535
- // • Air control: constant-rate `stepTowards`. No ground
1536
- // reaction force in flight; air control is a steering
1537
- // budget, not a thrust curve. Constant accel matches the
1538
- // player mental model of "fixed mid-air authority".
1539
- //
1540
- // • Grounded decel (no intent): constant-rate `stepTowards`
1541
- // toward zero. Friction is approximately constant for a
1542
- // biped on level ground — Coulomb friction. Faster than
1543
- // accel because the body's own resistance + active
1544
- // decel-foot-plants combine into a sharper deceleration.
1545
- //
1546
- // • Grounded accel (intent active): mono-exponential
1547
- // approach (Hill 1927; Furusawa-Hill 1928). dv/dt is
1548
- // proportional to (v_target − v), so accel is highest at
1549
- // low speed and tapers as v approaches v_target. Matches
1550
- // human sprint biomechanics — modern sprint-profiling
1551
- // work (Morin & Samozino 2016) fits this same mono-exp
1552
- // curve to empirical force-plate data.
1553
- //
1554
- // The mass + mastery + backward scalars compose multiplicatively
1555
- // on the EFFECTIVE half-life (heavier ⇒ longer half-life ⇒
1556
- // slower ramp; mastery accel-bonus ⇒ shorter half-life ⇒
1557
- // faster ramp). See FirstPersonPlayerControllerConfig.js's
1558
- // `groundAccelHalfLife` doc for the literature and the
1559
- // SprintAcceleration.spec.js for the model assertions.
1560
- const intentLen = Math.hypot(nmvX, nmvY);
1561
- if (!state.grounded) {
1562
- const maxStep = cfg.motion.airAccel * dt;
1563
- runtime.velocityX = stepTowards(runtime.velocityX, desiredHorizontalVx, maxStep);
1564
- runtime.velocityZ = stepTowards(runtime.velocityZ, desiredHorizontalVz, maxStep);
1565
- } else if (intentLen < 1e-4) {
1566
- let decel = cfg.motion.groundDecel * runtime.massRatios.groundAccelScale;
1567
- decel *= controller.mastery.evaluate(DecisionPoint.GroundAccel, controller, runtime);
1568
- const maxStep = decel * dt;
1569
- runtime.velocityX = stepTowards(runtime.velocityX, 0, maxStep);
1570
- runtime.velocityZ = stepTowards(runtime.velocityZ, 0, maxStep);
1571
- } else {
1572
- // Mono-exponential approach. Scale half-life by the
1573
- // inverse of the accel scalars so that "more accel" (large
1574
- // groundAccelScale, mastery > 1.0) translates to a shorter
1575
- // half-life (faster ramp). Backward intent slows things
1576
- // down — backwardAccelFactor < 1 ⇒ longer half-life.
1577
- let halfLife = cfg.motion.groundAccelHalfLife
1578
- / runtime.massRatios.groundAccelScale
1579
- / controller.mastery.evaluate(DecisionPoint.GroundAccel, controller, runtime);
1580
- if (isBackwardIntent) halfLife /= cfg.motion.backwardAccelFactor;
1581
- runtime.velocityX = exponentialApproach(runtime.velocityX, desiredHorizontalVx, halfLife, dt);
1582
- runtime.velocityZ = exponentialApproach(runtime.velocityZ, desiredHorizontalVz, halfLife, dt);
1583
- }
1584
-
1585
- // -- L1.e/f/g/h: jump FSM + vertical integration --------------
1586
- this._advanceJumpFsm(controller, runtime, bodyTransform, dt);
1587
- this._integrateVerticalAndResolveGround(controller, runtime, bodyTransform, dt);
1588
-
1589
- // -- Publish posture for L2 consumers (eye height, gait gating).
1590
- // Base owns posture when no ability is active: Crouch if the
1591
- // crouch intent is resolved active, otherwise Stand. Abilities
1592
- // that need a different posture (slide → Prone, ledge-grab →
1593
- // Hang) set state.posture themselves in their tick.
1594
- let posture = isCrouchActive
1595
- ? FirstPersonPosture.Crouch
1596
- : FirstPersonPosture.Stand;
1597
- // Headroom floor — block UN-proning under a very low overhang. Only an
1598
- // ALREADY-prone body (e.g. one that slid into a crawl-height tunnel) is
1599
- // force-kept prone while the Crouch collider can't fit above it. A player
1600
- // merely crouching by intent is NOT auto-dropped to prone when they meet
1601
- // a lower overhang — same rule as above: the mover blocks their forward
1602
- // motion instead of the ceiling shoving them down. Uses the post-move
1603
- // position, so it reflects where the body actually ended up this tick.
1604
- if (posture === FirstPersonPosture.Crouch
1605
- && runtime.lastPosture === FirstPersonPosture.Prone) {
1606
- const proneGrow = runtime.crouchTop - runtime.proneTop;
1607
- if (!this._hasHeadroomToGrow(runtime, bodyTransform, runtime.colliderShapeProne, proneGrow)) {
1608
- posture = FirstPersonPosture.Prone;
1609
- }
1610
- }
1611
- controller.state.posture = posture;
1612
-
1613
- // -- Publish lean target for L2.f. Base writes the natural
1614
- // (lat-accel + look-lean) value; abilities override in their
1615
- // own tick. L2.f spring-steps toward whatever's here.
1616
- runtime.leanTargetRad = this._computeNaturalLeanTarget(controller, runtime, dt);
1617
- }
1618
-
1619
- /**
1620
- * Compute the natural camera lean for this tick: lat-accel-driven
1621
- * roll into a turn, plus a yaw-rate look-lean contribution, both
1622
- * clamped. The result is the target the lean spring chases each
1623
- * tick when no ability has opinions.
1624
- *
1625
- * Pure-ish helper — reads `controller`, `runtime`, `dt`; returns a
1626
- * number. Extracted so both base and any future ability that wants
1627
- * to compose its lean on top of the natural value can call it.
1628
- *
1629
- * @private
1630
- * @param {FirstPersonPlayerController} controller
1631
- * @param {PerEntityRuntime} runtime
1632
- * @param {number} dt
1633
- * @returns {number} target roll in radians
1634
- */
1635
- _computeNaturalLeanTarget(controller, runtime, dt) {
1636
- const cfg = controller.config;
1637
- const state = controller.state;
1638
- if (!cfg.lean.enabled) return 0;
1639
-
1640
- const sinYaw = runtime.sinYaw;
1641
- const cosYaw = runtime.cosYaw;
1642
-
1643
- // Lateral acceleration projected onto screen-right.
1644
- // accel_world = (vel - prevVel) / dt; screen_right = (-cos θ, 0, sin θ).
1645
- const accWorldX = (runtime.velocityX - runtime.prevVelocityX) / Math.max(dt, 1e-4);
1646
- const accWorldZ = (runtime.velocityZ - runtime.prevVelocityZ) / Math.max(dt, 1e-4);
1647
- const latAccel = accWorldX * (-cosYaw) + accWorldZ * sinYaw;
1648
- const normalized = clamp(latAccel / 9.81, -2, 2);
1649
- //
1650
- // Sign convention for the roll (the eye composes the rotation
1651
- // as qYaw * qPitch * qRoll, where qRoll is around (0,0,1)).
1652
- // After the engine's camera-invert pipeline:
1653
- // φ > 0 → camera-up tilts toward screen-right (−X) → HEAD TILTS RIGHT
1654
- // φ < 0 → camera-up tilts toward screen-left (+X) → HEAD TILTS LEFT
1655
- //
1656
- // For the "bank into the turn" feel (Apex / Titanfall / Mirror's
1657
- // Edge): accelerating right (latAccel > 0) should tilt the head
1658
- // RIGHT, i.e. positive φ. So leanTargetRad has the SAME sign
1659
- // as latAccel.
1660
- let leanTargetRad = normalized * cfg.lean.maxRollDeg * DEG_TO_RAD;
1661
-
1662
- // Look-lean: yaw-rate-driven banking. runtime.yawRateRadPerSec
1663
- // was cached at L1.a — negative is the "turn right" convention.
1664
- // For "bank into the turn": turning right → head tilts right →
1665
- // positive engine roll. So lookLean = -yawRate * scale matches
1666
- // sign.
1667
- //
1668
- // Crouched players are in a low, stable, low-momentum stance —
1669
- // banking the head from a mouse turn reads as unmotivated. We
1670
- // scale the contribution down (default to 0) while crouched.
1671
- // Lat-accel lean is left alone: its magnitude naturally tracks
1672
- // the (lower) crouch acceleration, so it stays motivated.
1673
- if (cfg.lean.lookLeanEnabled) {
1674
- const yawRate = clamp(
1675
- runtime.yawRateRadPerSec,
1676
- -cfg.lean.lookLeanYawRateClamp,
1677
- cfg.lean.lookLeanYawRateClamp,
1678
- );
1679
- const crouchFactor = state.crouchActive ? cfg.lean.crouchLookLeanFactor : 1.0;
1680
- leanTargetRad += -yawRate * cfg.lean.lookLeanDegPerRadPerSec * DEG_TO_RAD * crouchFactor;
1681
- }
1682
-
1683
- // Final clamp on the sum: cap the combined target to ±2 ×
1684
- // maxRollDeg (matches the latAccel normalized clamp range) so
1685
- // even simultaneous max-strafe-accel + max-yaw-flick produces a
1686
- // sane upper bound.
1687
- const maxTotal = cfg.lean.maxRollDeg * DEG_TO_RAD * 2;
1688
- return clamp(leanTargetRad, -maxTotal, maxTotal);
1689
- }
1690
-
1691
- /**
1692
- * Snapshot the per-tick "what is the body doing" information into the
1693
- * pose channels for downstream consumption (skeleton, sound, AI).
1694
- * Read-only with respect to controller state — this is purely a publish
1695
- * step.
1696
- *
1697
- * @private
1698
- * @param {FirstPersonPlayerController} controller
1699
- * @param {PerEntityRuntime} runtime
1700
- * @param {Transform64} bodyTransform
1701
- */
1702
- _publishPose(controller, runtime, bodyTransform) {
1703
- const cfg = controller.config;
1704
- const state = controller.state;
1705
- const pose = controller.pose;
1706
-
1707
- pose.rootPosition.set(bodyTransform.translation_x, bodyTransform.translation_y, bodyTransform.translation_z);
1708
- pose.rootYawRad = runtime.bodyYaw;
1709
- pose.headYawRad = runtime.bodyYaw;
1710
- pose.headPitchRad = runtime.eyePitch;
1711
- pose.headRollRad = state.leanRollRad;
1712
- pose.locomotionPhase = state.stridePhase;
1713
- pose.locomotionSpeed = runtime.horizSpeed;
1714
- // Strafe component: project velocity onto screen-right (-cos θ, 0, sin θ).
1715
- // Positive = moving to the player's right.
1716
- pose.locomotionStrafe = (runtime.velocityX * (-runtime.cosYaw) + runtime.velocityZ * runtime.sinYaw)
1717
- / Math.max(cfg.motion.sprintSpeed, 1e-3);
1718
- pose.actionState =
1719
- state.inJumpAnticipation ? FirstPersonActionState.Anticipating
1720
- : !state.grounded ? FirstPersonActionState.Airborne
1721
- : (Math.abs(runtime.landSpring.value) > 0.01 ? FirstPersonActionState.Landing
1722
- : FirstPersonActionState.Grounded);
1723
- pose.locomotionMode = state.locomotionMode;
1724
- const crouchSpan = Math.max(cfg.body.height - cfg.body.crouchHeight, 1e-3);
1725
- pose.crouchAmount = clamp((cfg.body.height - state.eyeHeight) / crouchSpan, 0, 1);
1726
-
1727
- // Posture channel for downstream animation: which body shape +
1728
- // how far the body is into it from the standing neutral.
1729
- //
1730
- // `posture` is the enum (Stand / Crouch / Prone / Hang) — picks
1731
- // the animation track. `postureAmount` is the [0..1] blend
1732
- // weight from standing toward that posture, derived from the
1733
- // eye-height spring so the value transitions smoothly across
1734
- // changes (matches the visible camera motion).
1735
- pose.posture = state.posture;
1736
- let postureTargetH;
1737
- switch (state.posture) {
1738
- case FirstPersonPosture.Prone: postureTargetH = cfg.body.proneHeight; break;
1739
- case FirstPersonPosture.Crouch: postureTargetH = cfg.body.crouchHeight; break;
1740
- case FirstPersonPosture.Hang: postureTargetH = cfg.body.height; break;
1741
- case FirstPersonPosture.Stand:
1742
- default: postureTargetH = cfg.body.height; break;
1743
- }
1744
- const postureSpan = Math.max(cfg.body.height - postureTargetH, 1e-3);
1745
- pose.postureAmount = clamp((cfg.body.height - state.eyeHeight) / postureSpan, 0, 1);
1746
-
1747
- pose.aimPitch = runtime.eyePitch;
1748
-
1749
- this._assertTickInvariants(controller, runtime, bodyTransform);
1750
- this._snapshotRenderPose(runtime, bodyTransform);
1751
- }
1752
-
1753
- /**
1754
- * Record this fixed step's authoritative body position as the latest
1755
- * render-interpolation endpoint, shifting the old latest to "previous".
1756
- * {@link _composeEye} blends prev→cur by the sub-step alpha so the camera
1757
- * position is smooth at any refresh rate. (Yaw/pitch aren't snapshotted —
1758
- * they're render-rate.) On a teleport (`renderSnap`), prev and cur collapse
1759
- * to the new position so the camera jumps rather than gliding across it.
1760
- * @private
1761
- */
1762
- _snapshotRenderPose(runtime, bodyTransform) {
1763
- const p = bodyTransform;
1764
- if (runtime.renderSnap) {
1765
- runtime.renderSnap = false;
1766
- runtime.renderPrevPos.set(p.translation_x, p.translation_y, p.translation_z);
1767
- runtime.renderCurPos.set(p.translation_x, p.translation_y, p.translation_z);
1768
- return;
1769
- }
1770
- runtime.renderPrevPos.copy(runtime.renderCurPos);
1771
- runtime.renderCurPos.set(p.translation_x, p.translation_y, p.translation_z);
1772
- }
1773
-
1774
- /**
1775
- * Dev-only invariant guard (meep asserts compile out of prod, so this is
1776
- * free there). Catches the class of corruption that surfaces as an
1777
- * unexplained camera "snap": a NaN/Infinity leaking into the yaw/pitch the
1778
- * eye composes from, or into the body's velocity / position. Runs once at
1779
- * the END of every fixed step, so a bad value is caught the tick it appears
1780
- * — naming the layer that produced it — instead of manifesting as a camera
1781
- * jump frames (or seconds) later when something finally reads it.
1782
- * @private
1783
- */
1784
- _assertTickInvariants(controller, runtime, bodyTransform) {
1785
- const p = bodyTransform;
1786
- assert.ok(Number.isFinite(runtime.bodyYaw),
1787
- "FP invariant: runtime.bodyYaw is non-finite (camera yaw corrupted)");
1788
- assert.ok(Number.isFinite(runtime.eyePitch),
1789
- "FP invariant: runtime.eyePitch is non-finite (camera pitch corrupted)");
1790
- assert.ok(Number.isFinite(controller.state.leanRollRad),
1791
- "FP invariant: state.leanRollRad is non-finite (camera roll corrupted)");
1792
- assert.ok(
1793
- Number.isFinite(runtime.velocityX)
1794
- && Number.isFinite(runtime.velocityY)
1795
- && Number.isFinite(runtime.velocityZ),
1796
- "FP invariant: runtime.velocity is non-finite",
1797
- );
1798
- assert.ok(
1799
- Number.isFinite(p.translation_x) && Number.isFinite(p.translation_y) && Number.isFinite(p.translation_z),
1800
- "FP invariant: body position is non-finite",
1801
- );
1802
- }
1803
-
1804
- /**
1805
- * Consume the accumulated mouse-look delta into body yaw + eye pitch at
1806
- * RENDER rate (once per render frame, from {@link _composeEye}). This is the
1807
- * fix for the slow-turn camera snap: sampling `intent.look` at the 60Hz
1808
- * fixed step aliases an unaligned mouse report rate into an uneven turn,
1809
- * whereas consuming it every render frame tracks the mouse as smoothly as it
1810
- * arrives. `intent.look` is zeroed after consuming so the same delta isn't
1811
- * re-applied next frame.
1812
- *
1813
- * Conventions (raw mouse delta source — movementX/Y positive moving
1814
- * right/down): look.x > 0 ("mouse right") → turn right (the yaw sign is
1815
- * negated because the engine is left-handed with +Z forward, so a +Y
1816
- * rotation reads as a LEFT turn through the camera); look.y > 0 → look down,
1817
- * flipped by invertY.
1818
- * @private
1819
- */
1820
- _consumeLook(controller, runtime) {
1821
- const intent = controller.intent;
1822
- const cfg = controller.config;
1823
- let yawDelta = -intent.look.x;
1824
- const pitchSign = cfg.look.invertY ? -1 : 1;
1825
- let pitchDelta = intent.look.y * pitchSign;
1826
- intent.look.set(0, 0);
1827
-
1828
- // Reject input SPIKES. A single render frame's look delta beyond a sane
1829
- // bound is a corrupt browser/driver `movementX` (a high-res mouse under
1830
- // OS pointer acceleration, or a pointer-lock burst), not a real turn —
1831
- // applying it snaps the camera 20–100°. Drop the spiking axis; legit
1832
- // turns (even fast flicks) stay well under the bound at render rate.
1833
- const maxDelta = cfg.look.maxFrameDeltaRad;
1834
- if (Math.abs(yawDelta) > maxDelta) yawDelta = 0;
1835
- if (Math.abs(pitchDelta) > maxDelta) pitchDelta = 0;
1836
-
1837
- runtime.bodyYaw += yawDelta;
1838
- if (runtime.bodyYaw > Math.PI) runtime.bodyYaw -= TWO_PI;
1839
- else if (runtime.bodyYaw < -Math.PI) runtime.bodyYaw += TWO_PI;
1840
-
1841
- runtime.eyePitch = clamp(
1842
- runtime.eyePitch + pitchDelta,
1843
- cfg.look.pitchMinDeg * DEG_TO_RAD,
1844
- cfg.look.pitchMaxDeg * DEG_TO_RAD,
1845
- );
1846
- }
1847
-
1848
- /**
1849
- * Compose the eye transform from body + state-driven offsets.
1850
- * @private
1851
- * @param {FirstPersonPlayerController} controller
1852
- * @param {number} entity
1853
- */
1854
- _composeEye(controller, entity) {
1855
- const ecd = this.entityManager.dataset;
1856
- const runtime = this.runtime.get(entity);
1857
- if (runtime === undefined) return;
1858
-
1859
- // Consume mouse-look at RENDER rate so the camera direction tracks the
1860
- // mouse smoothly, decoupled from the aliased 60Hz sim sampling.
1861
- this._consumeLook(controller, runtime);
1862
-
1863
- const dt = this._currentRenderDt;
1864
- const cfg = controller.config;
1865
- const state = controller.state;
1866
-
1867
- const bodyTransform = ecd.getComponent(entity, Transform64);
1868
- if (bodyTransform === undefined) return;
1869
-
1870
- if (controller.eyeEntity === -1) return;
1871
- const eyeTransform = ecd.getComponent(controller.eyeEntity, Transform64);
1872
- const camera = ecd.getComponent(controller.eyeEntity, Camera);
1873
- if (eyeTransform === undefined || camera === undefined) return;
1874
-
1875
- // -- Body-local eye offset, composed via the additive stack ----
1876
- // The base (0, eyeHeight, 0) is the standing/crouched neutral; each
1877
- // additional contribution (bob, breath, landing, anticipation,
1878
- // sprint posture) goes through the stack so external systems can
1879
- // push their own contributions on the same channel.
1880
- const stack = runtime.eyeOffsetStack;
1881
- stack.clear();
1882
- stack.push("eyeHeight", 0, state.eyeHeight, 0);
1883
-
1884
- // Bob — gated on grounded only (the impact spring decays naturally
1885
- // even at rest, so the bob fade-out is smooth; lateral amp uses the
1886
- // bob-intensity envelope which spring-decays after stopping).
1887
- if (state.grounded) {
1888
- const phase = state.stridePhase * TWO_PI;
1889
- const massBoost = (cfg.body.mass - 80) * cfg.bob.ampMassScale;
1890
- const intensity = runtime.bobIntensitySpring.value;
1891
-
1892
- // Back-pedal amp boost — lateral grows more than vertical because
1893
- // backward gait has worse side-to-side balance than vertical compression.
1894
- // Exertion adds a smaller boost on top: tired = wobbly gait.
1895
- const ampLMult = 1 + (cfg.bob.backwardLateralAmpFactor - 1) * runtime.backwardness;
1896
- const exertionBoost = 1 + cfg.exertion.bobLateralBoostAtMax * state.exertion;
1897
- const ampL = (cfg.bob.lateralAmpAtWalk + massBoost) * intensity * ampLMult * exertionBoost;
1898
-
1899
- // Vertical: read directly from the impact spring (footfall kicks,
1900
- // under-damped recovery → trough + leg-push overshoot).
1901
- stack.push("bob.impact", 0, runtime.verticalImpactSpring.value, 0);
1902
-
1903
- // Lateral: head shifts toward the foot bearing weight. Polarity
1904
- // sourced from runtime.standingFoot — the same signal the
1905
- // footstep emits — so bob direction and footstep side agree.
1906
- // |sin(phase)| is the non-negative "midstance envelope".
1907
- const lateralPolarity = runtime.standingFoot === "R" ? -1 : 1;
1908
- stack.push("bob.lateral", ampL * lateralPolarity * Math.abs(Math.sin(phase)), 0, 0);
1909
- }
1910
-
1911
- // Breath — sine + tiny noise riding the rate spring.
1912
- const breathOffset = -state.breathAmplitudeM
1913
- * Math.sin(state.breathPhase * TWO_PI)
1914
- * (1 + cfg.breath.noiseAmount * (Math.sin(state.breathPhase * 13.7) * 0.5));
1915
- stack.push("breath", 0, breathOffset, 0);
1916
-
1917
- // Landing spring dip (under-damped — overshoots once on recovery).
1918
- stack.push("landing", 0, runtime.landSpring.value, 0);
1919
-
1920
- // Jump anticipation dip (eased ramp during the squash window).
1921
- if (state.inJumpAnticipation) {
1922
- const t = 1 - clamp(runtime.anticipationRemaining / Math.max(cfg.jump.anticipation.duration, 1e-3), 0, 1);
1923
- const eased = t * (2 - t); // ease-out quad
1924
- stack.push("anticipation", 0, -cfg.jump.anticipation.dipAmount * eased, 0);
1925
- }
1926
-
1927
- // Sprint posture: head leans slightly forward as commitment builds.
1928
- // Pitch part is in the rotation block below; the +Z position shift
1929
- // sells "head leading the hips" (Mirror's Edge), tied to the same
1930
- // spring envelope so they move together.
1931
- const sprintPitch = runtime.sprintPostureSpring.value;
1932
- const sprintShiftFraction =
1933
- cfg.posture.sprintForwardPitchDeg > 0
1934
- ? sprintPitch / (cfg.posture.sprintForwardPitchDeg * DEG_TO_RAD)
1935
- : 0;
1936
- stack.push("posture.sprintShift", 0, 0, cfg.posture.sprintForwardShiftM * sprintShiftFraction);
1937
-
1938
- // Body POSITION advances only at the fixed step → blend the last two
1939
- // steps by the engine's sub-step alpha (the same fraction the
1940
- // InterpolationSystem uses for physics bodies) so it's smooth on a high-
1941
- // refresh display. Yaw + pitch are already render-rate (consumed in
1942
- // _consumeLook), so they're used directly — no blend, no aliasing.
1943
- const alpha = this.entityManager.getFixedStepAlpha();
1944
- const iPosX = lerp(runtime.renderPrevPos.x, runtime.renderCurPos.x, alpha);
1945
- const iPosY = lerp(runtime.renderPrevPos.y, runtime.renderCurPos.y, alpha);
1946
- const iPosZ = lerp(runtime.renderPrevPos.z, runtime.renderCurPos.z, alpha);
1947
- const qYaw0 = SCRATCH_Q_A.fromAxisAngle(Vector3.up, runtime.bodyYaw);
1948
-
1949
- // Transform body-local accumulated offset into world space (about the
1950
- // render-rate yaw, to match the interpolated base position).
1951
- const worldOffset = SCRATCH_V3_B.copy(stack.offset);
1952
- worldOffset.applyQuaternion(qYaw0);
1953
-
1954
- eyeTransform.setTranslation(
1955
- iPosX + worldOffset.x,
1956
- iPosY + worldOffset.y,
1957
- iPosZ + worldOffset.z
1958
- );
1959
-
1960
- // -- Eye rotation: body yaw × eye pitch × roll -------------------
1961
- // Bob roll mixes in for a subtle head sway (in phase with lateral bob).
1962
- // Breath pitch is a small extra nod 90° out of phase with vertical
1963
- // breath; merged into the main pitch so we don't pay an extra quat
1964
- // multiply and the composition stays trivially correct.
1965
- let rollTotal = state.leanRollRad;
1966
- if (state.grounded) {
1967
- // Roll: head tilts toward the standing foot, in phase with the
1968
- // lateral sway. Polarity sourced from runtime.standingFoot for
1969
- // consistency with the lateral bob. Positive engine roll = head
1970
- // tilts RIGHT (camera-invert convention), so R-foot midstance =
1971
- // positive roll, L-foot midstance = negative roll.
1972
- const phase = state.stridePhase * TWO_PI;
1973
- const rollBackMult = 1 + (cfg.bob.backwardRollFactor - 1) * runtime.backwardness;
1974
- const ampRoll = cfg.bob.rollAtWalkDeg * DEG_TO_RAD * runtime.bobIntensitySpring.value * rollBackMult;
1975
- const rollPolarity = runtime.standingFoot === "R" ? 1 : -1;
1976
- const rollEnvelope = Math.abs(Math.sin(phase));
1977
- const bobRollSigned = ampRoll * rollPolarity * rollEnvelope;
1978
-
1979
- // Lean × bob coupling: excursions in the lean direction get
1980
- // amplified, opposite excursions attenuated. Lean is normalized
1981
- // against maxRollDeg so the coupling magnitude stays bounded
1982
- // regardless of how aggressively lean is configured.
1983
- const maxLeanRad = Math.max(cfg.lean.maxRollDeg * DEG_TO_RAD, 1e-6);
1984
- const leanFraction = clamp(state.leanRollRad / maxLeanRad, -1, 1);
1985
- // sign(bobRollSigned) matches lean? amplify; else attenuate.
1986
- const sameSign = (bobRollSigned * leanFraction) >= 0;
1987
- const couplingMag = cfg.bob.leanCouplingFactor * Math.abs(leanFraction);
1988
- const couplingScale = sameSign ? (1 + couplingMag) : (1 - couplingMag);
1989
- rollTotal += bobRollSigned * couplingScale;
1990
- }
1991
-
1992
- const breathPitch = lerp(cfg.breath.pitchAmpRestDeg, cfg.breath.pitchAmpMaxDeg, state.exertion)
1993
- * DEG_TO_RAD
1994
- * Math.cos(state.breathPhase * TWO_PI);
1995
- // Combined pitch contributions: player input + breath nod + sprint
1996
- // commitment + fatigue droop. All in the same "positive = look-down"
1997
- // convention so they sum cleanly.
1998
- const pitchTotal = runtime.eyePitch
1999
- + breathPitch
2000
- + runtime.sprintPostureSpring.value
2001
- + runtime.headDroopSpring.value;
2002
-
2003
- // composition: yaw * pitch * roll
2004
- // pitch around world X — yaw applied after, so effective axis is camera-local right
2005
- // roll around world Z — yaw and pitch applied after, so effective axis is camera-local forward
2006
- const qYaw = qYaw0; // SCRATCH_Q_A, already the render-rate yaw
2007
- const qPitch = SCRATCH_Q_B.fromAxisAngle(Vector3.right, pitchTotal);
2008
- const qRoll = SCRATCH_Q_C.fromAxisAngle(Vector3.forward, rollTotal);
2009
-
2010
- // composed in a scratch: a transform keeps its rotation as four numbers and has no
2011
- // quaternion product of its own
2012
- SCRATCH_Q_EULER.multiplyQuaternions(qYaw, qPitch);
2013
- SCRATCH_Q_EULER.multiply(qRoll);
2014
-
2015
- eyeTransform.setRotation(
2016
- SCRATCH_Q_EULER.x, SCRATCH_Q_EULER.y, SCRATCH_Q_EULER.z, SCRATCH_Q_EULER.w
2017
- );
2018
-
2019
- eyeTransform.updateMatrix();
2020
-
2021
- // Dev-only camera-output guard (compiled out of prod). The composed eye
2022
- // direction is exactly what the player sees; if a spring (head-droop,
2023
- // breath nod) or lean ever drove pitch/roll to NaN, the camera would
2024
- // snap to garbage. Assert it here, at the source of the visible jump.
2025
- assert.ok(
2026
- Number.isFinite(eyeTransform.rotation_x) && Number.isFinite(eyeTransform.rotation_y) && Number.isFinite(eyeTransform.rotation_z) && Number.isFinite(eyeTransform.rotation_w),
2027
- "FP invariant: composed eye rotation is non-finite (camera direction corrupted)",
2028
- );
2029
-
2030
- // -- FOV ---------------------------------------------------------
2031
- let fovTarget = cfg.fov.base;
2032
- if (cfg.fov.sprintAdd !== 0) {
2033
- fovTarget += cfg.fov.sprintAdd * runtime.sprintness;
2034
- }
2035
- if (state.crouchActive) fovTarget += cfg.fov.crouchAdd;
2036
-
2037
- runtime.fovSpring.stepTo(fovTarget, cfg.fov.smoothHalfLife, 1.0, dt);
2038
- camera.fov.set(runtime.fovSpring.value);
2039
- }
2040
- }
2041
-
2042
- // ---------------------------------------------------------------------------
2043
- // helpers
2044
- // ---------------------------------------------------------------------------
2045
-
2046
- /**
2047
- * Exponential approach with half-life parameterization.
2048
- * @param {number} current
2049
- * @param {number} target
2050
- * @param {number} halfLife
2051
- * @param {number} dt
2052
- * @returns {number}
2053
- */
2054
- function exponentialApproach(current, target, halfLife, dt) {
2055
- if (halfLife <= 0) return target;
2056
- const alpha = 1 - Math.exp(-LN2 * dt / halfLife);
2057
- return current + (target - current) * alpha;
2058
- }
2059
-
2060
- /**
2061
- * Detect that phase value crossed a boundary in [0,1) between two ticks.
2062
- * Handles the wraparound case where phase jumps from e.g. 0.95 to 0.05.
2063
- *
2064
- * @param {number} prev previous phase in [0,1)
2065
- * @param {number} next current phase in [0,1)
2066
- * @param {number} boundary in [0,1)
2067
- * @returns {boolean}
2068
- */
2069
- function phaseCrossed(prev, next, boundary) {
2070
- if (next >= prev) {
2071
- // no wrap
2072
- return prev < boundary && next >= boundary;
2073
- } else {
2074
- // wrapped past 1.0
2075
- return prev < boundary || next >= boundary;
2076
- }
2077
- }
2078
-
1
+ import { assert } from "../../../core/assert.js";
2
+ import { Ray3 } from "../../../core/geom/3d/ray/Ray3.js";
3
+ import { CapsuleShape3D } from "../../../core/geom/3d/shape/CapsuleShape3D.js";
4
+ import { TransformedShape3D } from "../../../core/geom/3d/shape/TransformedShape3D.js";
5
+ import Quaternion from "../../../core/geom/Quaternion.js";
6
+ import Vector3 from "../../../core/geom/Vector3.js";
7
+ import { clamp } from "../../../core/math/clamp.js";
8
+ import { DEG_TO_RAD } from "../../../core/math/DEG_TO_RAD.js";
9
+ import { lerp } from "../../../core/math/lerp.js";
10
+ import { ResourceAccessKind } from "../../../core/model/ResourceAccessKind.js";
11
+ import { ResourceAccessSpecification } from "../../../core/model/ResourceAccessSpecification.js";
12
+ import { SerializationMetadata } from "../../ecs/components/SerializationMetadata.js";
13
+ import Entity from "../../ecs/Entity.js";
14
+ import { System } from "../../ecs/System.js";
15
+ import { Transform64 } from "../../ecs/transform/Transform64.js";
16
+ import { Camera } from "../../graphics/ecs/camera/Camera.js";
17
+ import { BodyKind } from "../../physics/ecs/BodyKind.js";
18
+ import { Collider } from "../../physics/ecs/Collider.js";
19
+ import { PhysicsSystem } from "../../physics/ecs/PhysicsSystem.js";
20
+ import { RigidBody } from "../../physics/ecs/RigidBody.js";
21
+ import { PhysicsSurfacePoint } from "../../physics/queries/PhysicsSurfacePoint.js";
22
+ import { KinematicMover } from "./collision/KinematicMover.js";
23
+ import { EyeOffsetStack } from "./composer/EyeOffsetStack.js";
24
+ import { FirstPersonPlayerController } from "./FirstPersonPlayerController.js";
25
+ import { DecisionPoint } from "./mastery/DecisionPoint.js";
26
+ import { computeJumpFromApex } from "./math/computeJumpFromApex.js";
27
+ import { computeLRCBreathRate } from "./math/computeLRCBreathRate.js";
28
+ import { computeMassRatios } from "./math/computeMassRatios.js";
29
+ import { jumpForceForHeight, launchVelocityForForce } from "./math/jumpDynamics.js";
30
+ import { Spring } from "./math/Spring.js";
31
+ import { stepTowards } from "./math/stepTowards.js";
32
+ import { FirstPersonActionState, FirstPersonLocomotionMode } from "./pose/FirstPersonPose.js";
33
+ import { FirstPersonPosture } from "./pose/FirstPersonPosture.js";
34
+ import { FirstPersonSensors } from "./sensors/FirstPersonSensors.js";
35
+ import { t64_announce_change } from "../../ecs/transform/t64_announce_change.js";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Scratch allocations — reused per frame to avoid GC pressure
39
+ // ---------------------------------------------------------------------------
40
+ const SCRATCH_V3_A = new Vector3();
41
+ const SCRATCH_V3_B = new Vector3();
42
+ const SCRATCH_V3_C = new Vector3();
43
+ const SCRATCH_Q_A = new Quaternion();
44
+ const SCRATCH_Q_B = new Quaternion();
45
+ const SCRATCH_Q_C = new Quaternion();
46
+ /** the transform holds its rotation as four numbers, so euler and axis-angle work goes through here */
47
+ const SCRATCH_Q_EULER = new Quaternion();
48
+
49
+ const TWO_PI = Math.PI * 2;
50
+ const LN2 = Math.log(2);
51
+
52
+ /**
53
+ * Build a posture-sized player capsule: a {@link CapsuleShape3D} of
54
+ * `radius` and the appropriate cylinder height, wrapped in a
55
+ * {@link TransformedShape3D} whose Y offset puts the capsule's bottom
56
+ * exactly at the wrapped shape's local origin. The entity's
57
+ * `transform.position` then represents the player's feet — and a
58
+ * posture-driven shrink doesn't yank the feet up the way a centred
59
+ * capsule would, nor dip them below the floor.
60
+ *
61
+ * The capsule's lowest point in its own local frame is at
62
+ * `-(cylinderHeight/2 + radius) = -max(totalHeight/2, radius)`.
63
+ * Offsetting the wrapper by the magnitude of that puts the bottom at
64
+ * Y = 0:
65
+ * - Stand (`H = 1.8`, `r = 0.34`): cylHeight = 1.12, offset = 0.9.
66
+ * Bottom = -0.9 + 0.9 = 0. Top = +0.9 + 0.9 = 1.8.
67
+ * - Crouch (`H = 0.8`, `r = 0.34`): cylHeight = 0.12, offset = 0.4.
68
+ * Bottom = -0.4 + 0.4 = 0. Top = +0.4 + 0.4 = 0.8.
69
+ * - Prone (`H = 0.4`, `r = 0.34`): cylHeight = 0 (capsule collapses
70
+ * to a sphere of radius), offset = max(0.2, 0.34) = 0.34.
71
+ * Bottom = -0.34 + 0.34 = 0. Top = +0.34 + 0.34 = 0.68. The
72
+ * `totalHeight = 0.4` value is honoured for the offset budget
73
+ * but the actual Y extent floors at `2·radius`.
74
+ *
75
+ * Picking `totalHeight/2` blindly (the obvious choice) would put the
76
+ * Prone capsule's bottom at `0.2 - 0.34 = -0.14` — dipping below the
77
+ * feet, and into any physics floor that's flush with feet level. On
78
+ * a physics ground slab, every horizontal shape_cast from inside the
79
+ * floor returns t = 0, `advance = max(0, t - SKIN) = 0`, and the
80
+ * slide freezes in place — see SlideMotion.spec.js for the
81
+ * regression test that pins this.
82
+ *
83
+ * @param {number} radius — capsule radius in metres
84
+ * @param {number} totalHeight — desired full Y extent; ignored below
85
+ * `2·radius` (the capsule's intrinsic minimum extent)
86
+ * @returns {TransformedShape3D}
87
+ */
88
+ function makePostureCapsule(radius, totalHeight) {
89
+ const cylinderHeight = Math.max(0, totalHeight - 2 * radius);
90
+ const yOffset = Math.max(totalHeight / 2, radius);
91
+ return TransformedShape3D.from_translation(
92
+ CapsuleShape3D.from(radius, cylinderHeight),
93
+ [0, yOffset, 0],
94
+ );
95
+ }
96
+
97
+ /**
98
+ * Per-entity runtime state the system maintains internally — too transient
99
+ * even for {@link FirstPersonPlayerController}'s `state` member, because it
100
+ * encodes input-edge bookkeeping and timer values the public surface should
101
+ * never see directly.
102
+ */
103
+ class PerEntityRuntime {
104
+ /**
105
+ * Co-attached kinematic body. Set by {@link FirstPersonPlayerControllerSystem.link}
106
+ * after asserting it's present. The controller writes Transform64.position
107
+ * directly (existing motion logic); physics derives the body's velocity
108
+ * from the per-step delta. Other physics systems (raycasts, contact
109
+ * events) see the player through this body.
110
+ * @type {RigidBody|null}
111
+ */
112
+ rigidBody = null;
113
+
114
+ /**
115
+ * Co-attached collider, cached at link. Same source the physics
116
+ * narrowphase uses, so move-and-slide casts the player's
117
+ * actual collision shape against the world.
118
+ * @type {Collider|null}
119
+ */
120
+ collider = null;
121
+
122
+ /**
123
+ * Pre-built capsule colliders, one per posture. Cached at link
124
+ * from `config.body.{height, crouchHeight, proneHeight, radius}`
125
+ * so {@link _syncColliderShape} can swap the collider's shape on
126
+ * a posture change with zero per-tick allocation. Hang reuses
127
+ * Stand (the player's body is full-extent, just hanging below
128
+ * the ledge — the rig animates the arms-up pose). Sentinel
129
+ * `lastPosture = -1` forces a sync on the first tick after
130
+ * link, so the initial shape always matches Stand.
131
+ * @private
132
+ * @type {TransformedShape3D|null}
133
+ */
134
+ colliderShapeStand = null;
135
+
136
+ /** @private @type {TransformedShape3D|null} */
137
+ colliderShapeCrouch = null;
138
+
139
+ /** @private @type {TransformedShape3D|null} */
140
+ colliderShapeProne = null;
141
+
142
+ /** @private */
143
+ lastPosture = -1;
144
+
145
+ /** Eye pitch in radians, clamped to config.look limits. */
146
+ eyePitch = 0;
147
+
148
+ /** Body yaw in radians (around world up). */
149
+ bodyYaw = 0;
150
+
151
+ /** Yaw rate (rad/s) computed in look consumption — for evaluators. */
152
+ yawRateRadPerSec = 0;
153
+
154
+ // -- Render-rate look + position interpolation ---------------------
155
+ // The camera DIRECTION (yaw/pitch) is consumed at RENDER rate (see the
156
+ // controller's _consumeLook), so it tracks the mouse smoothly instead of
157
+ // being aliased by the 60Hz sim sampling — consuming the accumulated
158
+ // mouse delta once per fixed step beats against an unaligned mouse
159
+ // report rate and makes a slow turn lurch (the reported snap). The body
160
+ // POSITION still advances only at the fixed step, so it's blended
161
+ // between the last two steps by the sub-step alpha to stay smooth on a
162
+ // high-refresh display.
163
+ /** Body yaw at the end of the previous fixed step — used to derive the
164
+ * per-step turn RATE that drives look-lean, decoupled from per-frame
165
+ * input jitter. */
166
+ prevFixedYaw = 0;
167
+
168
+ renderPrevPos = new Vector3();
169
+ renderCurPos = new Vector3();
170
+
171
+ /** When true, the next position snapshot collapses prev=cur (no blend)
172
+ * — set on a body teleport (e.g. the ledge-grab hang snap) so the
173
+ * camera doesn't glide across the jump. */
174
+ renderSnap = false;
175
+
176
+ /** Horizontal+vertical velocity. We integrate these inside the system
177
+ * when no external physics layer is attached. */
178
+ velocityX = 0;
179
+
180
+ velocityY = 0;
181
+ velocityZ = 0;
182
+
183
+ /** Previous-tick jump intent — for rising/falling edge detection. */
184
+ prevJumpHeld = false;
185
+
186
+ /** Previous-tick crouch intent — for toggle-mode edge detection. */
187
+ prevCrouchHeld = false;
188
+
189
+ /** True while crouch toggle is latched on (used only in toggle mode). */
190
+ crouchLatched = false;
191
+
192
+ /** Remaining time in jump anticipation, or <= 0 if not anticipating. */
193
+ anticipationRemaining = 0;
194
+
195
+ /** Cached derived gravity (m/s^2) from peakHeight + timeToApex. */
196
+ gravity = 9.81;
197
+
198
+ /**
199
+ * Cached jump PUSH-OFF FORCE (newtons) — the physical leg force that
200
+ * reaches `peakHeight` at the reference mass, the quantity jump power is
201
+ * expressed in. Derived at link via {@link jumpForceForHeight}; abilities
202
+ * (e.g. the scramble wall-kick) launch off this same force.
203
+ */
204
+ jumpForce = 0;
205
+
206
+ /** Cached derived jump impulse (m/s upward), post-mass-scaling. */
207
+ jumpInitialVy = 5.0;
208
+
209
+ /**
210
+ * Cached mass scaling factors — computed once at link. See
211
+ * {@link computeMassRatios}. Heavier ⇒ lower jumpV0Scale, lower
212
+ * groundAccelScale, higher landingDipScale + exertionRiseScale.
213
+ */
214
+ massRatios = null;
215
+
216
+ /** Spring for landing dip (under-damped → rings after impact). */
217
+ landSpring = new Spring();
218
+
219
+ /** Spring for FOV (critically damped). */
220
+ fovSpring = new Spring(70);
221
+
222
+ /** Spring for eye height (crouch transition). */
223
+ eyeHeightSpring = new Spring(1.80);
224
+
225
+ /** Spring for lean roll (radians) — banks into lateral acceleration. */
226
+ leanSpring = new Spring();
227
+
228
+ /**
229
+ * Lean target this tick (radians). Always set; L2.f spring-steps
230
+ * toward this value. Whoever owned motion this tick wrote it:
231
+ * base writes the lat-accel + look-lean derived value at the end
232
+ * of {@link _runBaseLocomotion}; abilities that want to override
233
+ * (WallRun → tilt-into-wall, Slide/Mantle/LedgeGrab → zero) write
234
+ * their own value in tick. Uniform channel — no null sentinel.
235
+ */
236
+ leanTargetRad = 0;
237
+
238
+ /** Previous horizontal velocity — for lateral acceleration → lean. */
239
+ prevVelocityX = 0;
240
+
241
+ prevVelocityZ = 0;
242
+
243
+ /** Previous-tick grounded for edge detection. */
244
+ prevGrounded = true;
245
+
246
+ /** Vertical speed at moment of last "leave ground". */
247
+ takeoffVy = 0;
248
+
249
+ /** Max vertical position since last takeoff — for jump apex detection. */
250
+ peakAltitude = 0;
251
+
252
+ /** Set true once a jump has been launched; cleared on land. */
253
+ midJump = false;
254
+
255
+ /** Apex already fired for this airborne segment? */
256
+ apexFired = false;
257
+
258
+ /** Stride phase from previous fixed step — for footstep edge detection. */
259
+ prevStridePhase = 0;
260
+
261
+ /** Breath phase from previous fixed step — for inhale/exhale edge detection. */
262
+ prevBreathPhase = 0;
263
+
264
+ /** Which foot fires next — flipped on each footstep signal. */
265
+ nextFootSide = "R";
266
+
267
+ /**
268
+ * Which foot is currently bearing the body's weight (the foot that
269
+ * most recently landed). Drives the lateral-bob direction: at R
270
+ * midstance the COM is over the right foot, so the head shifts
271
+ * laterally toward screen-right; at L midstance the opposite.
272
+ * Coupled to the same signal the footstep emits, so anything that
273
+ * listens to onFootStep.side will see the bob agree.
274
+ * Initialized "L" so the very first footstep fires "R" and the
275
+ * standingFoot updates to "R" — putting the head laterally right
276
+ * during the first half-stride, as expected.
277
+ */
278
+ standingFoot = "L";
279
+
280
+ /**
281
+ * [0..1] How "backward" the player is currently moving. Derived in
282
+ * fixedUpdate from velocity · screen-forward, normalized to sprint
283
+ * speed. Drives the gait wobble amplifier on the L3 camera-composition
284
+ * pass. Stored on runtime (rather than state) because it's a render-
285
+ * side input — downstream observers should look at velocity directly.
286
+ */
287
+ backwardness = 0;
288
+
289
+ /**
290
+ * Smoothed bob amplitude envelope. Target = max(speedNormalized,
291
+ * backwardness) when grounded, 0 airborne. Spring decay prevents
292
+ * the whiplash where stopping motion would snap the bob to neutral.
293
+ */
294
+ bobIntensitySpring = new Spring();
295
+
296
+ /**
297
+ * Vertical impact spring — kicked downward at each footfall, decays
298
+ * with a slight under-damped overshoot. Produces the impact-arrest +
299
+ * leg-push curve. value units: meters (added directly to eyeLocal.y).
300
+ */
301
+ verticalImpactSpring = new Spring();
302
+
303
+ /**
304
+ * Sprint-posture spring — eye pitches forward as the player commits
305
+ * to a sprint, returns to neutral when they slow. Value is in
306
+ * radians; slower half-life than other springs so it feels like
307
+ * a posture change rather than an input twitch. See cfg.posture.
308
+ */
309
+ sprintPostureSpring = new Spring();
310
+
311
+ /**
312
+ * Head-droop spring — additional forward pitch as exertion rises.
313
+ * Sells fatigue subtly. Target tracks exertion-driven max droop
314
+ * angle; spring lag keeps the transition slow and physical.
315
+ */
316
+ headDroopSpring = new Spring();
317
+
318
+ /**
319
+ * [0..1] sprintness — how much of the walk→sprint speed range the
320
+ * body is currently in. Computed in fixedUpdate, read by L3 for FOV
321
+ * and the sprint-posture pitch / forward-shift offset.
322
+ */
323
+ sprintness = 0;
324
+
325
+ /**
326
+ * Cached sin/cos of current body yaw — written once per fixedUpdate
327
+ * after look intent is consumed, read by every downstream step
328
+ * (locomotion, backwardness, lean look-rate, pose channels). Avoids
329
+ * recomputing the trig 3+ times per tick.
330
+ */
331
+ sinYaw = 0;
332
+
333
+ cosYaw = 1;
334
+
335
+ /** Cached horizontal speed (m/s) for this tick — written in derived-state. */
336
+ horizSpeed = 0;
337
+
338
+ /** Cached stride frequency (Hz) for this tick — written in breath block, read by stride. */
339
+ strideFreqHz = 0;
340
+
341
+ /**
342
+ * Additive accumulator for body-local eye-position offsets. The
343
+ * system pushes its own contributions (bob, breath, landing,
344
+ * sprint posture) each render frame; external systems can push
345
+ * recoil/shake/knockback contributions via the same interface.
346
+ */
347
+ eyeOffsetStack = new EyeOffsetStack();
348
+
349
+ /**
350
+ * Spatial-query results populated by {@link FirstPersonSensorsSystem}
351
+ * (when present). Abilities and the locomotion FSM read this.
352
+ * Lives on runtime so other systems can populate it without
353
+ * touching the controller component's public surface.
354
+ */
355
+ sensors = new FirstPersonSensors();
356
+
357
+ /** Cached eye entity ID. -1 until link assigns it. */
358
+ eyeEntity = -1;
359
+
360
+ /**
361
+ * One-shot hand-off flag: LedgeGrab sets it on a pull-up release so
362
+ * Mantle (next tick) knows this is a ledge pull-up — it then allows a
363
+ * too-thin top (auto-mantle still requires a standable one) and vaults
364
+ * OVER instead of onto. Mantle consumes it in onActivate.
365
+ */
366
+ ledgePullUpRequest = false;
367
+
368
+ /**
369
+ * Seconds remaining in the post-release ledge re-grab refractory
370
+ * window. Set by {@link LedgeGrab} on deactivation, aged down by dt
371
+ * each fixed step, and read by LedgeGrab.canActivate — so a deliberate
372
+ * dismount can fall clear of the grab window without the auto-catch
373
+ * snapping the body straight back onto the same lip.
374
+ */
375
+ ledgeRegrabCooldown = 0;
376
+ }
377
+
378
+ /**
379
+ * Drives a first-person camera + body from intent fields. See sibling
380
+ * DESIGN.md for goals, architecture, and the five processing layers (L0..L4).
381
+ *
382
+ * - fixedUpdate runs L1 (locomotion), L2 (pose state), and L4 (events) so
383
+ * the simulation remains deterministic.
384
+ * - update runs L3 (camera composition) at render rate so the eye is never
385
+ * smoother than the screen.
386
+ *
387
+ * The system itself integrates a simple flat-floor at y = `config.gravity.magnitude > 0
388
+ * ? state.groundY : -Infinity` for the prototype. A real physics layer should
389
+ * write `state.grounded`/`state.groundNormal` from outside instead; the
390
+ * built-in resolver is just a convenience to keep the controller usable
391
+ * without dependencies.
392
+ *
393
+ * @author Alex Goldring
394
+ * @copyright Company Named Limited (c) 2026
395
+ */
396
+ export class FirstPersonPlayerControllerSystem extends System {
397
+ // Dependencies kept to (controller, transform) so we can ASSERT on
398
+ // RigidBody at link time and emit a clear error if missing. If
399
+ // RigidBody were a hard dep, entities lacking one would silently
400
+ // never link — the controller would appear inert with no
401
+ // diagnostic. The assert below catches the missing-body case
402
+ // explicitly.
403
+ dependencies = [FirstPersonPlayerController, Transform64];
404
+
405
+ components_used = [
406
+ ResourceAccessSpecification.from(Transform64, ResourceAccessKind.Write),
407
+ ResourceAccessSpecification.from(Camera, ResourceAccessKind.Write),
408
+ ResourceAccessSpecification.from(RigidBody, ResourceAccessKind.Write),
409
+ ];
410
+
411
+ /**
412
+ * Per-entity runtime, keyed by entity id.
413
+ * @type {Map<number, PerEntityRuntime>}
414
+ */
415
+ runtime = new Map();
416
+
417
+ /**
418
+ * If true, the system clamps body y >= groundY and writes
419
+ * state.grounded itself. Turn off when wiring a real physics layer.
420
+ * @type {boolean}
421
+ */
422
+ useBuiltInFlatGround = true;
423
+
424
+ /**
425
+ * The flat-ground y for the built-in resolver. Ignored when
426
+ * useBuiltInFlatGround is false.
427
+ * @type {number}
428
+ */
429
+ groundY = 0;
430
+
431
+ /**
432
+ * Optional callback that returns the surface Y under the player
433
+ * for ground resolution. Called each tick with the player's
434
+ * current (x, y, z); returns the world-Y of the ground below,
435
+ * or null if no ground is below (gap / void).
436
+ *
437
+ * Combines with `useBuiltInFlatGround`: the effective ground for
438
+ * the tick is `max(this.groundY when enabled, resolver(...))`.
439
+ * Set both off (`useBuiltInFlatGround=false`, `groundResolver=null`)
440
+ * to defer to external physics entirely.
441
+ *
442
+ * Designed for prototypes / gyms that need elevated platforms
443
+ * without a full physics layer. Production should wire a real
444
+ * physics system instead.
445
+ *
446
+ * @type {((x:number, y:number, z:number) => number|null) | null}
447
+ */
448
+ groundResolver = null;
449
+
450
+ /**
451
+ * PhysicsSystem reference. Auto-acquired at startup; can be
452
+ * overridden by the caller. When present, collision is resolved
453
+ * by the {@link KinematicMover}; when null, the controller uses
454
+ * the flat-ground integrator ({@link _moveFlatGround}) — useful
455
+ * for spec setups that don't wire physics.
456
+ * @type {PhysicsSystem|null}
457
+ */
458
+ physicsSystem = null;
459
+
460
+ /**
461
+ * The collision solver — the authoritative move resolver whenever
462
+ * a {@link PhysicsSystem} is present (recover + unified
463
+ * sweep-and-slide + ground-categorize; see DESIGN_COLLISION.md).
464
+ * Lazily built on first use. With no physics the controller falls
465
+ * back to a flat-ground integrator ({@link _moveFlatGround}).
466
+ * @private
467
+ * @type {KinematicMover|null}
468
+ */
469
+ _mover = null;
470
+
471
+ /** @private Scratch velocity handed to the mover each tick. */
472
+ _moverVelocity = new Vector3();
473
+
474
+ /** @private Scratch for the posture-headroom up-cast. */
475
+ _postureRay = new Ray3();
476
+
477
+ /** @private @type {PhysicsSurfacePoint} */
478
+ _postureHit = new PhysicsSurfacePoint();
479
+
480
+ async startup(entityManager) {
481
+ this.entityManager = entityManager;
482
+ if (this.physicsSystem === null) {
483
+ const ps = entityManager.getSystem(PhysicsSystem);
484
+ if (ps !== null) this.physicsSystem = ps;
485
+ }
486
+ }
487
+
488
+ /**
489
+ * @param {FirstPersonPlayerController} controller
490
+ * @param {Transform64} bodyTransform
491
+ * @param {number} entity
492
+ */
493
+ link(controller, bodyTransform, entity) {
494
+ const ecd = this.entityManager.dataset;
495
+
496
+ // The controller assumes a kinematic-position RigidBody is co-
497
+ // attached on this entity. The body is the spatial proxy used
498
+ // for sensor raycasts and physics-side observers (other entities
499
+ // raycasting against the player, dynamic bodies colliding with
500
+ // the capsule, etc.). The controller writes Transform directly,
501
+ // physics derives velocity from the per-step delta. If a body is
502
+ // missing the controller could still drive the camera, but the
503
+ // physics integration silently breaks — assert here so the
504
+ // misconfiguration is caught at link time.
505
+ const rigidBody = ecd.getComponent(entity, RigidBody);
506
+ assert.ok(rigidBody !== undefined,
507
+ "FirstPersonPlayerController entity must have a co-attached RigidBody "
508
+ + "(kinematic capsule). See prototype_first_person_controller.js for setup.");
509
+ assert.equal(rigidBody.kind, BodyKind.KinematicPosition,
510
+ "FirstPersonPlayerController RigidBody must be BodyKind.KinematicPosition; "
511
+ + "the controller owns the Transform and physics derives velocity.");
512
+ // Collider is also required — the KinematicMover casts this shape
513
+ // against the world (recover / sweep / ground probe). Asserted
514
+ // here so a missing collider surfaces at link rather than
515
+ // producing a null-deref at the first cast attempt.
516
+ const collider = ecd.getComponent(entity, Collider);
517
+ assert.ok(collider !== undefined,
518
+ "FirstPersonPlayerController entity must have a co-attached Collider. "
519
+ + "The controller's move-and-slide casts this shape to detect blockers.");
520
+
521
+ const runtime = new PerEntityRuntime();
522
+ runtime.rigidBody = rigidBody;
523
+ runtime.collider = collider;
524
+
525
+ // Pre-build one capsule per posture from cfg.body. Eye-height
526
+ // doubles as collider-top by convention here — the prototype's
527
+ // `buildPlayerEntity` uses the same approximation (`totalHeight =
528
+ // bodyCfg.height`). The +Y offset puts the capsule bottom at
529
+ // transform.position so the player's "feet" stay anchored across
530
+ // posture changes; only the head drops/rises.
531
+ const radius = controller.config.body.radius;
532
+ runtime.colliderShapeStand = makePostureCapsule(radius, controller.config.body.height);
533
+ runtime.colliderShapeCrouch = makePostureCapsule(radius, controller.config.body.crouchHeight);
534
+ runtime.colliderShapeProne = makePostureCapsule(radius, controller.config.body.proneHeight);
535
+ // Cache each posture capsule's ACTUAL top (bbox maxY) — not the
536
+ // nominal config height. A capsule's height floors at 2·radius,
537
+ // so e.g. a 0.4 m "prone" height actually yields a 0.68 m
538
+ // capsule. The headroom up-cast sweeps by the difference of real
539
+ // tops; using config heights would over-/under-estimate the grow
540
+ // and clamp to the wrong posture (Prone instead of Crouch).
541
+ const bbox = new Float64Array(6);
542
+ runtime.colliderShapeStand.compute_bounding_box(bbox);
543
+ runtime.standTop = bbox[4];
544
+ runtime.colliderShapeCrouch.compute_bounding_box(bbox);
545
+ runtime.crouchTop = bbox[4];
546
+ runtime.colliderShapeProne.compute_bounding_box(bbox);
547
+ runtime.proneTop = bbox[4];
548
+ // Force a shape sync on the first tick: even though the caller
549
+ // built a Stand-sized collider, we rebuild it from cfg here so a
550
+ // post-link config tweak (e.g. crouchHeight changed for a unit
551
+ // test) is reflected on the live collider without a relink.
552
+ runtime.lastPosture = -1;
553
+
554
+ this.runtime.set(entity, runtime);
555
+
556
+ // Derive gravity + jump impulse from designer-friendly params, then
557
+ // mass-scale the initial velocity (heavier ⇒ lower jump).
558
+ runtime.massRatios = computeMassRatios(
559
+ controller.config.body.mass,
560
+ controller.config.body.referenceMass,
561
+ controller.config.body.massCouplingStrength,
562
+ );
563
+ const derived = { gravity: 0, initialVelocity: 0 };
564
+ computeJumpFromApex(controller.config.jump.peakHeight, controller.config.jump.timeToApex, derived);
565
+ runtime.gravity = derived.gravity;
566
+ // Express jump power as a physical push-off FORCE (newtons): the force the
567
+ // legs deliver to reach peakHeight at the REFERENCE mass. Stored so other
568
+ // moves (the scramble wall-kick) can launch off the SAME force.
569
+ const bodyCfg = controller.config.body;
570
+ runtime.jumpForce = jumpForceForHeight(
571
+ bodyCfg.referenceMass, runtime.gravity, bodyCfg.jumpPushoffDistance, controller.config.jump.peakHeight,
572
+ );
573
+ // Force → launch velocity for the reference body (== √(2·g·peakHeight)),
574
+ // then the mass-coupling dial (jumpV0Scale ∝ 1/√mass, modulated by
575
+ // massCouplingStrength) scales it for the actual body. Identical result
576
+ // to the prior `derived.initialVelocity · jumpV0Scale`, now routed
577
+ // through the force so jump power has a single physical source.
578
+ runtime.jumpInitialVy =
579
+ launchVelocityForForce(runtime.jumpForce, bodyCfg.referenceMass, bodyCfg.jumpPushoffDistance)
580
+ * runtime.massRatios.jumpV0Scale;
581
+
582
+ // Seed yaw from the starting body rotation. `toEulerAnglesYXZ`
583
+ // returns (pitch, yaw, roll) — we only care about y.
584
+ SCRATCH_Q_EULER.set(
585
+ bodyTransform.rotation_x,
586
+ bodyTransform.rotation_y,
587
+ bodyTransform.rotation_z,
588
+ bodyTransform.rotation_w
589
+ );
590
+ SCRATCH_Q_EULER.toEulerAnglesYXZ(SCRATCH_V3_A);
591
+ runtime.bodyYaw = SCRATCH_V3_A.y;
592
+ runtime.eyePitch = 0;
593
+
594
+ // Seed render-rate-look + position-interpolation state to the spawn pose.
595
+ runtime.prevFixedYaw = runtime.bodyYaw;
596
+ runtime.renderPrevPos.set(bodyTransform.translation_x, bodyTransform.translation_y, bodyTransform.translation_z);
597
+ runtime.renderCurPos.set(bodyTransform.translation_x, bodyTransform.translation_y, bodyTransform.translation_z);
598
+
599
+ // Initialize springs to standing-eye-height baseline
600
+ runtime.eyeHeightSpring.settle(controller.config.body.height);
601
+ runtime.fovSpring.settle(controller.config.fov.base);
602
+ controller.state.eyeHeight = controller.config.body.height;
603
+
604
+ // Create eye entity if one wasn't supplied
605
+ if (controller.eyeEntity === -1 || !ecd.entityExists(controller.eyeEntity)) {
606
+ const eye = new Entity();
607
+
608
+ const eyeTransform = new Transform64();
609
+ const baseEyePos = SCRATCH_V3_A.set(bodyTransform.translation_x, bodyTransform.translation_y, bodyTransform.translation_z);
610
+ baseEyePos.y += controller.config.body.height;
611
+ eyeTransform.setTranslation(baseEyePos.x, baseEyePos.y, baseEyePos.z);
612
+
613
+ const camera = new Camera();
614
+ camera.active.set(true);
615
+ camera.fov.set(controller.config.fov.base);
616
+ camera.clip_near = 0.05;
617
+ camera.clip_far = 1000;
618
+ camera.autoClip = true;
619
+
620
+ eye.add(eyeTransform);
621
+ eye.add(camera);
622
+ eye.add(SerializationMetadata.Transient);
623
+
624
+ eye.build(ecd);
625
+
626
+ controller.eyeEntity = eye.id;
627
+ }
628
+
629
+ runtime.eyeEntity = controller.eyeEntity;
630
+ }
631
+
632
+ /**
633
+ * @param {FirstPersonPlayerController} controller
634
+ * @param {Transform64} bodyTransform
635
+ * @param {number} entity
636
+ */
637
+ unlink(controller, bodyTransform, entity) {
638
+ const ecd = this.entityManager.dataset;
639
+
640
+ if (controller.eyeEntity !== -1 && ecd.entityExists(controller.eyeEntity)) {
641
+ ecd.removeEntity(controller.eyeEntity);
642
+ controller.eyeEntity = -1;
643
+ }
644
+
645
+ this.runtime.delete(entity);
646
+ }
647
+
648
+ /**
649
+ * Look up the per-entity runtime for an entity that has this
650
+ * controller. Used by cross-system code (sensors system, future
651
+ * ability-driven systems) to reach internal state without leaking
652
+ * it onto the controller component itself.
653
+ *
654
+ * @param {number} entity
655
+ * @returns {PerEntityRuntime|undefined} undefined if entity is not linked
656
+ */
657
+ getRuntime(entity) {
658
+ return this.runtime.get(entity);
659
+ }
660
+
661
+ /**
662
+ * Deterministic simulation step — L1 + L2 + L4.
663
+ * @param {number} dt
664
+ */
665
+ fixedUpdate(dt) {
666
+ const ecd = this.entityManager.dataset;
667
+ if (ecd === null) return;
668
+
669
+ this._currentDt = dt;
670
+ ecd.traverseComponents(FirstPersonPlayerController, this._tickEntity, this);
671
+ }
672
+
673
+ /**
674
+ * Variable-rate camera composition — L3.
675
+ * @param {number} dt
676
+ */
677
+ update(dt) {
678
+ const ecd = this.entityManager.dataset;
679
+ if (ecd === null) return;
680
+
681
+ this._currentRenderDt = dt;
682
+ ecd.traverseComponents(FirstPersonPlayerController, this._composeEye, this);
683
+ }
684
+
685
+ /**
686
+ * @private
687
+ * @param {FirstPersonPlayerController} controller
688
+ * @param {number} entity
689
+ */
690
+ _tickEntity(controller, entity) {
691
+ const ecd = this.entityManager.dataset;
692
+ const runtime = this.runtime.get(entity);
693
+ if (runtime === undefined) return;
694
+
695
+ const dt = this._currentDt;
696
+ const cfg = controller.config;
697
+ const intent = controller.intent;
698
+ const state = controller.state;
699
+ const sig = controller.signals;
700
+
701
+ const bodyTransform = ecd.getComponent(entity, Transform64);
702
+ if (bodyTransform === undefined) return;
703
+
704
+ // Decay the mastery score's EMA. Doing this once per tick keeps the
705
+ // score's time-window characteristic stable regardless of how many
706
+ // evaluators fire (they each *record* a sample, the decay
707
+ // independently ages all samples).
708
+ controller.mastery.tick(dt);
709
+
710
+ // -- L1.a: Derive the per-step turn rate ------------------------
711
+ // Look (yaw/pitch) is consumed at RENDER rate in {@link _consumeLook} so
712
+ // the camera tracks the mouse without 60Hz aliasing — consuming the
713
+ // accumulated mouse delta once per fixed step beats against an unaligned
714
+ // mouse report rate and makes a slow turn lurch (the reported snap).
715
+ // fixedUpdate reads the latest yaw and derives the per-fixed-step turn
716
+ // RATE for look-lean + the turn evaluators, from the yaw change since the
717
+ // previous step (shortest-arc across the ±π wrap) — smooth and decoupled
718
+ // from per-frame input jitter.
719
+ let yawStep = runtime.bodyYaw - runtime.prevFixedYaw;
720
+ if (yawStep > Math.PI) yawStep -= TWO_PI;
721
+ else if (yawStep < -Math.PI) yawStep += TWO_PI;
722
+ runtime.yawRateRadPerSec = yawStep / Math.max(dt, 1e-4);
723
+ runtime.prevFixedYaw = runtime.bodyYaw;
724
+
725
+ // Write body yaw back to the transform (pure yaw, no pitch on body) —
726
+ // the collision sweep + sensors use it. The camera uses runtime.bodyYaw
727
+ // directly, which _consumeLook keeps current at render rate.
728
+ SCRATCH_Q_EULER.fromAxisAngle(Vector3.up, runtime.bodyYaw);
729
+ bodyTransform.setRotation(
730
+ SCRATCH_Q_EULER.x, SCRATCH_Q_EULER.y, SCRATCH_Q_EULER.z, SCRATCH_Q_EULER.w
731
+ );
732
+ bodyTransform.updateMatrix();
733
+
734
+ // -- Shared flags. Computed BEFORE the ability tick so abilities
735
+ // can read them. `isCrouchActive` is deliberately computed
736
+ // AFTER the ability tick because `_resolveCrouchHeld` mutates
737
+ // `runtime.prevCrouchHeld` — abilities like Slide need to see
738
+ // the previous-tick value to detect a rising edge on the
739
+ // crouch press.
740
+ const isSprintIntent = intent.sprint && intent.move.y > 0.5 && state.grounded;
741
+ const isBackwardIntent = intent.move.y < 0;
742
+ runtime.sinYaw = Math.sin(runtime.bodyYaw);
743
+ runtime.cosYaw = Math.cos(runtime.bodyYaw);
744
+ // L2 observers read sinYaw/cosYaw as locals — destructure once.
745
+ const { sinYaw, cosYaw } = runtime;
746
+
747
+ // Age the ledge re-grab refractory window (dt-driven). Decremented
748
+ // BEFORE the ability layer so LedgeGrab.canActivate sees the current
749
+ // value; it gates the auto-catch for a beat after any dismount.
750
+ if (runtime.ledgeRegrabCooldown > 0) {
751
+ runtime.ledgeRegrabCooldown = Math.max(0, runtime.ledgeRegrabCooldown - dt);
752
+ }
753
+
754
+ // -- Ability layer: at most one active ability owns motion. The
755
+ // set returns true when no ability owned the tick, in which
756
+ // case base L1.b-h runs below; false means an ability fully
757
+ // handled this tick (it called the system's helpers for any
758
+ // standard work it wanted to keep, e.g. gravity).
759
+ const runBaseLocomotion = controller.abilities.tick(
760
+ controller, runtime, bodyTransform, runtime.sensors, dt, this,
761
+ );
762
+
763
+ // The ledge-pull-up request is set and consumed WITHIN abilities.tick
764
+ // (LedgeGrab release → Mantle activation, same call). Clear it here as
765
+ // a safety net so a pull-up that didn't activate a mantle can't leak
766
+ // the flag into a later auto-mantle (which would let it vault a thin
767
+ // wall it should have refused).
768
+ runtime.ledgePullUpRequest = false;
769
+
770
+ // Now resolve crouch (updates prevCrouchHeld) — used by base and L2.
771
+ // Headroom-aware: force-keeps the player crouched under an overhang.
772
+ const isCrouchActive = this._resolveCrouchHeld(controller, runtime, bodyTransform);
773
+
774
+ if (runBaseLocomotion) {
775
+ this._runBaseLocomotion(
776
+ controller, runtime, bodyTransform, dt,
777
+ isCrouchActive, isSprintIntent, isBackwardIntent,
778
+ );
779
+ }
780
+
781
+ // (everything below this line runs every tick — L2 observers don't
782
+ // care who owned motion)
783
+
784
+ // -- L2.a: speed / moveMode ------------------------------------
785
+ // -- L2.a: speed / moveMode ------------------------------------
786
+ const horizSpeed = Math.hypot(runtime.velocityX, runtime.velocityZ);
787
+ runtime.horizSpeed = horizSpeed;
788
+ state.speed = horizSpeed;
789
+ state.speedNormalized = clamp(horizSpeed / Math.max(cfg.motion.sprintSpeed, 1e-3), 0, 1);
790
+
791
+ // Backwardness: 0 = moving forward (or sideways), 1 = moving directly
792
+ // backward at the back-pedal speed ceiling. Derived from the actual
793
+ // velocity (not the intent) so external knockback or stuck states
794
+ // also register as "moving backward" and the gait wobble reflects it.
795
+ //
796
+ // Reference speed is the *achievable* backward max — walkSpeed ×
797
+ // backwardSpeedFactor — NOT the sprint speed. Backward can never
798
+ // reach sprint, so normalizing against sprint would cap backwardness
799
+ // at ~0.3 and the wobble multipliers below would barely apply.
800
+ const screenFwdVel = runtime.velocityX * sinYaw + runtime.velocityZ * cosYaw;
801
+ const maxBackwardSpeed = Math.max(cfg.motion.walkSpeed * cfg.motion.backwardSpeedFactor, 1e-3);
802
+ runtime.backwardness = clamp(-screenFwdVel / maxBackwardSpeed, 0, 1);
803
+
804
+ // Locomotion mode is the *intent-driven* horizontal mode. Airborne
805
+ // state is tracked separately on pose.actionState — they're
806
+ // orthogonal facets (you can be Sprint+Airborne after a jump).
807
+ const prevLocomotionMode = state.locomotionMode;
808
+ if (isCrouchActive) {
809
+ state.locomotionMode = FirstPersonLocomotionMode.Crouch;
810
+ } else if (isSprintIntent && horizSpeed > 0.1) {
811
+ state.locomotionMode = FirstPersonLocomotionMode.Sprint;
812
+ } else if (horizSpeed > 0.1) {
813
+ state.locomotionMode = FirstPersonLocomotionMode.Walk;
814
+ } else {
815
+ state.locomotionMode = FirstPersonLocomotionMode.Idle;
816
+ }
817
+
818
+ if (state.locomotionMode === FirstPersonLocomotionMode.Sprint
819
+ && prevLocomotionMode !== FirstPersonLocomotionMode.Sprint) {
820
+ sig.onSprintStart.send0();
821
+ } else if (prevLocomotionMode === FirstPersonLocomotionMode.Sprint
822
+ && state.locomotionMode !== FirstPersonLocomotionMode.Sprint) {
823
+ sig.onSprintStop.send0();
824
+ }
825
+
826
+ // -- L2.b: Exertion --------------------------------------------
827
+ // Heavier bodies tire faster — sprint rise scales with massRatios.exertionRiseScale.
828
+ const exertionRise = isSprintIntent
829
+ ? cfg.exertion.sprintRiseRate * runtime.massRatios.exertionRiseScale
830
+ : 0;
831
+ const exertionFall = exertionRise > 0 ? 0 : cfg.exertion.idleDecayRate;
832
+ state.exertion = clamp(state.exertion + (exertionRise - exertionFall) * dt, 0, 1);
833
+
834
+ // -- L2.c: Breath ----------------------------------------------
835
+ // breathRate and breathAmplitude lag exertion through separate
836
+ // exponential decays. Rate hangs around longer than amplitude.
837
+ const metabolicRate = lerp(cfg.breath.rateRestHz, cfg.breath.rateMaxHz, state.exertion);
838
+ const targetAmp = lerp(cfg.breath.amplitudeRestM, cfg.breath.amplitudeMaxM, state.exertion);
839
+
840
+ // Locomotor-respiratory coupling — see math/computeLRCBreathRate.
841
+ // The pure function is unit-tested; this site just provides inputs.
842
+ //
843
+ // Gait is gated on a "feet strike the ground" posture (Stand /
844
+ // Crouch). Prone (slide) and Hang (ledge-grab) have no stride —
845
+ // the body's feet are not making contact in a walking pattern,
846
+ // so stride frequency drops to zero and downstream gait
847
+ // signals (footsteps, bob intensity) go quiet.
848
+ const feetStriking = state.posture === FirstPersonPosture.Stand
849
+ || state.posture === FirstPersonPosture.Crouch;
850
+ const strideFreqHz = feetStriking && state.grounded && horizSpeed > cfg.bob.minStepSpeed
851
+ ? cfg.bob.stepFreqAtWalk * Math.pow(
852
+ Math.max(horizSpeed, 1e-3) / Math.max(cfg.motion.walkSpeed, 1e-3),
853
+ cfg.bob.stepFreqExp,
854
+ )
855
+ : 0;
856
+ const targetRate = computeLRCBreathRate(
857
+ metabolicRate,
858
+ strideFreqHz,
859
+ state.exertion,
860
+ cfg.breath.locomotorCouplingMax,
861
+ cfg.breath.couplingMinStrideFreqHz,
862
+ );
863
+ state.breathRateHz = exponentialApproach(state.breathRateHz, targetRate, cfg.exertion.rateDecayHalfLife, dt);
864
+ state.breathAmplitudeM = exponentialApproach(state.breathAmplitudeM, targetAmp, cfg.exertion.ampDecayHalfLife, dt);
865
+
866
+ runtime.prevBreathPhase = state.breathPhase;
867
+ state.breathPhase += state.breathRateHz * dt;
868
+ state.breathPhase -= Math.floor(state.breathPhase); // wrap [0,1)
869
+
870
+ // Breath edge detection — inhale at 0.25, exhale at 0.75
871
+ if (phaseCrossed(runtime.prevBreathPhase, state.breathPhase, 0.25)) {
872
+ sig.onBreathIn.send1({ amplitude: state.breathAmplitudeM, rateHz: state.breathRateHz });
873
+ }
874
+ if (phaseCrossed(runtime.prevBreathPhase, state.breathPhase, 0.75)) {
875
+ sig.onBreathOut.send1({ amplitude: state.breathAmplitudeM, rateHz: state.breathRateHz });
876
+ }
877
+
878
+ // -- L2.d: Stride ----------------------------------------------
879
+ // strideFreqHz computed above in the breath block; reused here.
880
+ runtime.prevStridePhase = state.stridePhase;
881
+ if (strideFreqHz > 0) {
882
+ // 1 full stride cycle = 2 footfalls; phase advances at freq/2 of cycle
883
+ state.stridePhase += (strideFreqHz * 0.5) * dt;
884
+ state.stridePhase -= Math.floor(state.stridePhase);
885
+ }
886
+ // Footstep on phase wraparound past 0 (R) or past 0.5 (L). Same
887
+ // posture gate as stride advance — feet must be striking.
888
+ if (feetStriking && state.grounded && horizSpeed > cfg.bob.minStepSpeed) {
889
+ const fireFootstep = () => {
890
+ state.stepCount++;
891
+ const side = runtime.nextFootSide;
892
+ runtime.nextFootSide = side === "R" ? "L" : "R";
893
+ // The foot that just fired is now the one bearing weight
894
+ // through the upcoming half-stride. Drives lateral-bob sign.
895
+ runtime.standingFoot = side;
896
+ sig.onFootStep.send1({ side, speed: horizSpeed, surfaceTag: state.surfaceTag });
897
+ // Kick the vertical impact spring DOWNWARD. The kick magnitude
898
+ // is the per-step desired peak dip × impactKickMultiplier; the
899
+ // multiplier is empirical (depends on impact spring params) so
900
+ // that "verticalAmpAtWalk" still corresponds approximately to
901
+ // the visible peak dip depth. Scaled by bobIntensity so a
902
+ // mid-deceleration footstep doesn't deliver a full-strength
903
+ // impulse.
904
+ const massBoost = (cfg.body.mass - 80) * cfg.bob.ampMassScale;
905
+ const ampVMult = 1 + (cfg.bob.backwardVerticalAmpFactor - 1) * runtime.backwardness;
906
+ const peakDip = (cfg.bob.verticalAmpAtWalk + massBoost) * runtime.bobIntensitySpring.value * ampVMult;
907
+ runtime.verticalImpactSpring.kick(-peakDip * cfg.bob.impactKickMultiplier);
908
+ };
909
+ if (phaseCrossed(runtime.prevStridePhase, state.stridePhase, 0)) {
910
+ fireFootstep();
911
+ }
912
+ if (phaseCrossed(runtime.prevStridePhase, state.stridePhase, 0.5)) {
913
+ fireFootstep();
914
+ }
915
+ }
916
+
917
+ // -- L2.d.bob-intensity & impact -------------------------------
918
+ // Smoothed bob amplitude envelope: when the player starts/stops
919
+ // moving the visible bob fades in/out rather than cutting on/off.
920
+ // Target = the "natural" amp scale (max of speed and backwardness)
921
+ // while grounded, zero while airborne so the bob disappears mid-jump.
922
+ const naturalBobIntensity = Math.max(state.speedNormalized, runtime.backwardness);
923
+ // Bob fades to zero whenever feet aren't striking (airborne, or
924
+ // Prone/Hang posture). The verticalImpactSpring (separate
925
+ // channel) still carries any entry/landing kicks through to the
926
+ // camera, but no recurring step bob.
927
+ const targetBobIntensity = (state.grounded && feetStriking) ? naturalBobIntensity : 0;
928
+ runtime.bobIntensitySpring.stepTo(targetBobIntensity, cfg.bob.intensityHalfLife, 1.0, dt);
929
+
930
+ // Vertical impact spring — damped decay toward 0, with the under-
931
+ // damped overshoot that produces the recovery + leg-push curve.
932
+ runtime.verticalImpactSpring.stepTo(0, cfg.bob.impactSpringHalfLife, cfg.bob.impactSpringZeta, dt);
933
+
934
+ // Sprint posture — head pitches forward as commitment to sprint
935
+ // builds. Driven by "sprintness" — how much of the gap between
936
+ // walk and sprint speed the player is *currently* in (0..1). The
937
+ // pitch target is multiplied by sprintness, then critically damped.
938
+ // Only applies while grounded — pitching into airborne motion looks weird.
939
+ const sprintness = clamp(
940
+ (state.speed - cfg.motion.walkSpeed)
941
+ / Math.max(cfg.motion.sprintSpeed - cfg.motion.walkSpeed, 1e-3),
942
+ 0, 1,
943
+ );
944
+ const targetSprintPitch = state.grounded
945
+ ? cfg.posture.sprintForwardPitchDeg * DEG_TO_RAD * sprintness
946
+ : 0;
947
+ runtime.sprintPostureSpring.stepTo(
948
+ targetSprintPitch,
949
+ cfg.posture.sprintForwardPitchHalfLife,
950
+ 1.0, dt,
951
+ );
952
+ runtime.sprintness = sprintness;
953
+
954
+ // Head droop — exertion drives a subtle additional forward pitch.
955
+ // Combines with sprintPostureSpring (sprint = head down to commit)
956
+ // so a fatigued sprinter has BOTH effects layered.
957
+ const targetDroopRad = cfg.exertion.headDroopAtMaxDeg * DEG_TO_RAD * state.exertion;
958
+ runtime.headDroopSpring.stepTo(targetDroopRad, cfg.exertion.headDroopHalfLife, 1.0, dt);
959
+
960
+ // -- L2.e: Posture → eye height --------------------------------
961
+ // Posture is set by whichever layer owned motion this tick: base
962
+ // writes Stand / Crouch from isCrouchActive (see end of
963
+ // _runBaseLocomotion); active abilities write Prone (Slide) or
964
+ // Hang (LedgeGrab) in their tick. Mapping is one switch — adding
965
+ // a new posture is one enum value + one case.
966
+ let targetEyeH;
967
+ switch (state.posture) {
968
+ case FirstPersonPosture.Prone: targetEyeH = cfg.body.proneHeight; break;
969
+ case FirstPersonPosture.Crouch: targetEyeH = cfg.body.crouchHeight; break;
970
+ case FirstPersonPosture.Hang: targetEyeH = cfg.body.height; break;
971
+ case FirstPersonPosture.Stand:
972
+ default: targetEyeH = cfg.body.height; break;
973
+ }
974
+ const crouchHalfLife = cfg.crouch.transitionTime / 4; // halfLife is ~quarter of full transition
975
+ runtime.eyeHeightSpring.stepTo(targetEyeH, crouchHalfLife, 1.0, dt);
976
+ state.eyeHeight = runtime.eyeHeightSpring.value;
977
+
978
+ if (isCrouchActive !== state.crouchActive) {
979
+ state.crouchActive = isCrouchActive;
980
+ if (isCrouchActive) {
981
+ sig.onCrouchEnter.send0();
982
+ // Impulse: dropping into a crouch grips the knees. Small
983
+ // bump — we don't want crouch-spamming to instantly tire.
984
+ state.exertion = clamp(
985
+ state.exertion + cfg.exertion.crouchEnterRise * runtime.massRatios.exertionRiseScale,
986
+ 0, 1,
987
+ );
988
+ } else {
989
+ sig.onCrouchExit.send0();
990
+ }
991
+ }
992
+
993
+ // -- L2.f: Lean spring → camera roll ---------------------------
994
+ // The TARGET for this tick was written by whichever layer owned
995
+ // motion: base writes the lat-accel + look-lean derived value at
996
+ // the end of _runBaseLocomotion; abilities override (WallRun
997
+ // tilts toward the wall; Slide / LedgeGrab / Mantle force zero).
998
+ // L2.f is now a flat spring-step + commit — no branching, no
999
+ // null sentinel.
1000
+ runtime.prevVelocityX = runtime.velocityX;
1001
+ runtime.prevVelocityZ = runtime.velocityZ;
1002
+ runtime.leanSpring.stepTo(runtime.leanTargetRad, cfg.lean.spring.halfLife, cfg.lean.spring.zeta, dt);
1003
+ state.leanRollRad = runtime.leanSpring.value;
1004
+
1005
+ // -- L2.g: Land spring decay (drives the landing recovery dip) -
1006
+ // Target is 0; under-damped (cfg zeta < 1) so it rings.
1007
+ runtime.landSpring.stepTo(0, cfg.landing.recovery.spring.halfLife, cfg.landing.recovery.spring.zeta, dt);
1008
+
1009
+ // -- L2.h: Publish pose channels --------------------------------
1010
+ this._publishPose(controller, runtime, bodyTransform);
1011
+
1012
+ // -- L2.h.1: Say that the body moved ----------------------------
1013
+ // The yaw write above and the mover's write into the translation both land straight on the
1014
+ // component, and a Transform64 announces nothing on its own. One event here, at the point
1015
+ // where the pose is final, covers every writer in the tick and covers them once - which is
1016
+ // what wakes the mesh placement, the audio emitter and anything else that follows a player.
1017
+ t64_announce_change(ecd, entity);
1018
+
1019
+ // -- L2.i: Sync collider shape to posture -----------------------
1020
+ // All posture-writers (base locomotion + any active ability)
1021
+ // have run for this tick. Swap the collider's shape to the
1022
+ // pre-built capsule matching the final posture so downstream
1023
+ // physics queries (move-and-slide cast, sensors, overlap from
1024
+ // outside) see the right volume. No-op when posture is
1025
+ // unchanged.
1026
+ this._syncColliderShape(runtime, state.posture);
1027
+ }
1028
+
1029
+ /**
1030
+ * @private
1031
+ * @param {FirstPersonPlayerController} controller
1032
+ * @param {PerEntityRuntime} runtime
1033
+ * @returns {boolean}
1034
+ */
1035
+ /**
1036
+ * Swap {@link Collider.shape} to the pre-built capsule that matches
1037
+ * the player's current posture. Cheap — just a reference swap when
1038
+ * the posture changed, no-op otherwise. The pre-built shapes live
1039
+ * on the runtime (see {@link PerEntityRuntime.colliderShapeStand}
1040
+ * etc.) so this method allocates nothing per tick.
1041
+ *
1042
+ * Hang posture reuses Stand: the player's body is full-extent,
1043
+ * hanging below the ledge — the rig handles the arms-up animation,
1044
+ * but the collision volume is unchanged. If a game ever wants a
1045
+ * narrower hang silhouette (e.g. wedging into a chimney) it can
1046
+ * add a `colliderShapeHang` and route here.
1047
+ *
1048
+ * @private
1049
+ */
1050
+ _syncColliderShape(runtime, posture) {
1051
+ if (posture === runtime.lastPosture) return;
1052
+ let next;
1053
+ if (posture === FirstPersonPosture.Crouch) {
1054
+ next = runtime.colliderShapeCrouch;
1055
+ } else if (posture === FirstPersonPosture.Prone) {
1056
+ next = runtime.colliderShapeProne;
1057
+ } else {
1058
+ // Stand and Hang share the full-extent capsule.
1059
+ next = runtime.colliderShapeStand;
1060
+ }
1061
+ runtime.collider.shape = next;
1062
+ runtime.lastPosture = posture;
1063
+ }
1064
+
1065
+ _resolveCrouchHeld(controller, runtime, bodyTransform) {
1066
+ const cfg = controller.config;
1067
+ const intent = controller.intent;
1068
+ const state = controller.state;
1069
+
1070
+ let wantsCrouch;
1071
+ if (cfg.crouch.mode === "toggle") {
1072
+ // Edge: rising press flips the latch
1073
+ if (intent.crouch && !runtime.prevCrouchHeld) {
1074
+ runtime.crouchLatched = !runtime.crouchLatched;
1075
+ }
1076
+ runtime.prevCrouchHeld = intent.crouch;
1077
+ wantsCrouch = runtime.crouchLatched;
1078
+ } else {
1079
+ // "hold" mode
1080
+ runtime.prevCrouchHeld = intent.crouch;
1081
+ wantsCrouch = intent.crouch;
1082
+ }
1083
+
1084
+ // Headroom override — block UN-crouching under an overhang. A player who
1085
+ // is ALREADY low (crouched or prone) and wants to stand is force-kept
1086
+ // crouched while the Stand collider won't fit: letting it grow into the
1087
+ // overhang would have the next tick's depenetration shove the player out
1088
+ // the bottom, through the floor. `crouchBlocked` records this for HUD /
1089
+ // camera.
1090
+ //
1091
+ // Crucially this fires ONLY when already low. A STANDING player who walks
1092
+ // under a low ceiling is NEVER auto-pushed down into a crouch — their
1093
+ // full-height capsule simply can't fit, so the mover blocks their forward
1094
+ // motion instead. "If I can't clear it, I don't move forward." Dropping
1095
+ // posture is the player's choice (press crouch), not the ceiling's.
1096
+ const alreadyLow = runtime.lastPosture === FirstPersonPosture.Crouch
1097
+ || runtime.lastPosture === FirstPersonPosture.Prone;
1098
+ if (!wantsCrouch && alreadyLow) {
1099
+ const growDelta = runtime.standTop - runtime.crouchTop;
1100
+ if (!this._hasHeadroomToGrow(runtime, bodyTransform, runtime.colliderShapeCrouch, growDelta)) {
1101
+ state.crouchBlocked = true;
1102
+ return true;
1103
+ }
1104
+ }
1105
+ state.crouchBlocked = false;
1106
+ return wantsCrouch;
1107
+ }
1108
+
1109
+ /**
1110
+ * Headroom probe for a posture change that GROWS the collider.
1111
+ * Sweeps `fromShape` (the shorter posture's capsule) straight up by
1112
+ * `growDelta` (the height the collider would gain). A hit means an
1113
+ * overhang occupies the space the taller posture needs, so the grow
1114
+ * is blocked. Returns true (clear) when no physics is wired or the
1115
+ * grow is non-positive.
1116
+ *
1117
+ * Why an up-cast rather than an overlap-test of the taller shape:
1118
+ * overlap would false-positive on a wall *beside* the player. The
1119
+ * up-cast sweeps parallel to (and, per the mover's skin clearance,
1120
+ * clear of) lateral walls so they aren't hit; the floor is below
1121
+ * the rising probe; the player's own body is filtered out. Only
1122
+ * genuine overhead geometry blocks the grow. Relies on the player
1123
+ * resting at `floor+skin` (the mover's stick-to-ground), so the
1124
+ * probe's bottom starts clear of the floor.
1125
+ *
1126
+ * @private
1127
+ */
1128
+ _hasHeadroomToGrow(runtime, bodyTransform, fromShape, growDelta) {
1129
+ if (this.physicsSystem === null || growDelta <= 1e-4) return true;
1130
+ const ownCollider = runtime.collider;
1131
+ const filter = (_e, c) => c !== ownCollider;
1132
+ const ray = this._postureRay;
1133
+ ray.setOrigin(bodyTransform.translation_x, bodyTransform.translation_y, bodyTransform.translation_z);
1134
+ ray.setDirection(0, 1, 0);
1135
+ ray.tMax = growDelta;
1136
+ return !this.physicsSystem.shapeCast(ray, fromShape, bodyTransform.rotation, this._postureHit, filter);
1137
+ }
1138
+
1139
+ /**
1140
+ * Jump finite-state-machine: button-edge detection, buffer + coyote
1141
+ * grace, anticipation timer, impulse on completion. Variable-height
1142
+ * cut is captured here as a `state.isVariableJumpCut` flag that the
1143
+ * gravity step in `_integrateVerticalAndResolveGround` consumes.
1144
+ *
1145
+ * @private
1146
+ * @param {FirstPersonPlayerController} controller
1147
+ * @param {PerEntityRuntime} runtime
1148
+ * @param {Transform64} bodyTransform
1149
+ * @param {number} dt
1150
+ */
1151
+ _advanceJumpFsm(controller, runtime, bodyTransform, dt) {
1152
+ const cfg = controller.config;
1153
+ const intent = controller.intent;
1154
+ const state = controller.state;
1155
+ const sig = controller.signals;
1156
+
1157
+ const jumpPressedEdge = intent.jump && !runtime.prevJumpHeld;
1158
+ const jumpReleasedEdge = !intent.jump && runtime.prevJumpHeld;
1159
+ runtime.prevJumpHeld = intent.jump;
1160
+
1161
+ if (jumpPressedEdge) {
1162
+ state.jumpBufferRemaining = cfg.jump.bufferTime;
1163
+ }
1164
+ state.jumpBufferRemaining = Math.max(0, state.jumpBufferRemaining - dt);
1165
+
1166
+ const canJumpNow =
1167
+ (state.grounded || state.timeSinceGrounded < cfg.jump.coyoteTime)
1168
+ && state.jumpBufferRemaining > 0
1169
+ && !state.inJumpAnticipation
1170
+ && !runtime.midJump;
1171
+
1172
+ if (canJumpNow) {
1173
+ // Begin anticipation — squash; impulse fires after duration elapses
1174
+ state.inJumpAnticipation = true;
1175
+ runtime.anticipationRemaining = cfg.jump.anticipation.duration;
1176
+ state.jumpBufferRemaining = 0; // claimed
1177
+ }
1178
+
1179
+ // Variable-height cut: only valid during ascent, post-launch.
1180
+ if (jumpReleasedEdge && runtime.midJump && runtime.velocityY > 0) {
1181
+ state.isVariableJumpCut = true;
1182
+ }
1183
+
1184
+ // Anticipation timer; impulse on completion.
1185
+ //
1186
+ // Anticipation completes regardless of grounded state. The reason
1187
+ // we DON'T cancel on `!grounded`: the canonical coyote-jump path
1188
+ // depends on it. The player walks off a ledge (grounded → false),
1189
+ // presses jump within the coyote window, canJumpNow accepts on
1190
+ // the coyote branch and starts anticipation. If we cancelled
1191
+ // anticipation here on !grounded, the impulse would never fire
1192
+ // and "coyote time" would be silently dead — the FSM's own next-
1193
+ // statement contradicting the canJumpNow gate three lines up.
1194
+ //
1195
+ // The same logic handles the rug-pull case (player on a moving
1196
+ // platform that slides out mid-anticipation): the player
1197
+ // committed to the jump, they get the jump. A future
1198
+ // knockback / stagger system can explicitly clear
1199
+ // inJumpAnticipation if it wants to override that commitment.
1200
+ if (state.inJumpAnticipation) {
1201
+ runtime.anticipationRemaining -= dt;
1202
+ if (runtime.anticipationRemaining <= 0) {
1203
+ // Mastery: gather a multiplier from all evaluators
1204
+ // registered for JumpImpulse. Default (no evaluators)
1205
+ // returns 1.0 → unchanged behaviour.
1206
+ const masteryMul = controller.mastery.evaluate(
1207
+ DecisionPoint.JumpImpulse, controller, runtime,
1208
+ );
1209
+ runtime.velocityY = runtime.jumpInitialVy * masteryMul;
1210
+ runtime.midJump = true;
1211
+ runtime.apexFired = false;
1212
+ runtime.peakAltitude = bodyTransform.translation_y;
1213
+ state.inJumpAnticipation = false;
1214
+ state.isVariableJumpCut = false;
1215
+ state.isAscending = true;
1216
+ state.exertion = clamp(
1217
+ state.exertion + cfg.exertion.jumpRise * runtime.massRatios.exertionRiseScale,
1218
+ 0, 1,
1219
+ );
1220
+
1221
+ sig.onJumpStart.send1({ peakHeight: cfg.jump.peakHeight });
1222
+ // NOTE: onLeaveGround is fired by the motor (_onLeaveGround)
1223
+ // when the move actually breaks ground contact this same tick —
1224
+ // with reason "jump" since midJump is now true. Firing it here
1225
+ // too double-sent the signal on every grounded jump (and a
1226
+ // spurious second "jump" leave on a coyote jump, which already
1227
+ // left ground on the walk-off). Let the motor own it.
1228
+ }
1229
+ }
1230
+ }
1231
+
1232
+ /**
1233
+ * The vertical + collision phase of one fixed step: apply gravity
1234
+ * (the motor), resolve the move against the world, then detect the
1235
+ * jump apex. Collision resolution is the {@link KinematicMover} when
1236
+ * a physics layer is present; otherwise a lightweight flat-ground
1237
+ * integrator for headless / unit-test scenes with no collidable
1238
+ * geometry. Land / leave-ground events flow identically from either.
1239
+ *
1240
+ * @private
1241
+ * @param {FirstPersonPlayerController} controller
1242
+ * @param {PerEntityRuntime} runtime
1243
+ * @param {Transform64} bodyTransform
1244
+ * @param {number} dt
1245
+ */
1246
+ _integrateVerticalAndResolveGround(controller, runtime, bodyTransform, dt) {
1247
+ this._applyGravity(controller, runtime, dt);
1248
+ this._resolveMotion(controller, runtime, bodyTransform, dt);
1249
+ this._detectJumpApex(controller, runtime, bodyTransform);
1250
+ }
1251
+
1252
+ /**
1253
+ * Resolve the current `runtime.velocity*` against the world — the
1254
+ * physics-backed {@link KinematicMover} (recover + sweep-and-slide +
1255
+ * ground-categorize) when a PhysicsSystem is present, else the no-physics
1256
+ * flat-ground fallback. Gravity is NOT applied here: the standard path
1257
+ * applies it in {@link _applyGravity} just before, and abilities with a
1258
+ * non-standard vertical model (WallRun's reduced gravity) apply their own
1259
+ * and then call this directly — so motion routes through one motor for
1260
+ * everyone (sweep-and-slide, anti-tunnel, land/leave events) rather than
1261
+ * a bespoke `position._add` + hand-rolled ground-catch.
1262
+ * @private
1263
+ */
1264
+ _resolveMotion(controller, runtime, bodyTransform, dt) {
1265
+ if (this.physicsSystem !== null) {
1266
+ this._moveViaMover(controller, runtime, bodyTransform, dt);
1267
+ } else {
1268
+ this._moveFlatGround(controller, runtime, bodyTransform, dt);
1269
+ }
1270
+ }
1271
+
1272
+ /**
1273
+ * Motor: integrate gravity into `velocityY` with the fall / variable-
1274
+ * cut multipliers. The mover never invents motion, so gravity lives
1275
+ * here (callable by abilities that want standard gravity too).
1276
+ * @private
1277
+ */
1278
+ _applyGravity(controller, runtime, dt) {
1279
+ const cfg = controller.config;
1280
+ const state = controller.state;
1281
+ let gMag = runtime.gravity;
1282
+ if (runtime.velocityY <= 0) {
1283
+ gMag *= cfg.jump.fallGravityMult;
1284
+ state.isAscending = false;
1285
+ } else if (state.isVariableJumpCut) {
1286
+ gMag *= cfg.jump.cutGravityMult;
1287
+ }
1288
+ runtime.velocityY -= gMag * dt;
1289
+ }
1290
+
1291
+ /**
1292
+ * Resolve the move against the world via {@link KinematicMover}
1293
+ * (recover + unified sweep-and-slide + ground-categorize), then map
1294
+ * the result back to controller state and land / leave events.
1295
+ * @private
1296
+ */
1297
+ _moveViaMover(controller, runtime, bodyTransform, dt) {
1298
+ const state = controller.state;
1299
+
1300
+ if (this._mover === null) {
1301
+ this._mover = new KinematicMover(this.physicsSystem, this.entityManager.dataset);
1302
+ }
1303
+
1304
+ // Fall speed captured BEFORE the move — the mover clips velocityY
1305
+ // to ~0 on contact, so the land event needs the pre-move value.
1306
+ const impactVy = -runtime.velocityY;
1307
+
1308
+ const v = this._moverVelocity;
1309
+ v.set(runtime.velocityX, runtime.velocityY, runtime.velocityZ);
1310
+
1311
+ const ownCollider = runtime.collider;
1312
+ const filter = (_e, c) => c !== ownCollider;
1313
+
1314
+ const result = this._mover.move(
1315
+ bodyTransform.translation,
1316
+ bodyTransform.rotation,
1317
+ runtime.collider.shape,
1318
+ v, dt, filter,
1319
+ );
1320
+
1321
+ // Read back corrected horizontal velocity. Vertical follows the
1322
+ // control policy: zero on ground (the slope-climb the mover
1323
+ // produced is a position effect, not accumulated speed), keep the
1324
+ // mover-clipped value in the air (captures ceiling bonks).
1325
+ runtime.velocityX = v.x;
1326
+ runtime.velocityZ = v.z;
1327
+ runtime.velocityY = result.grounded ? 0 : v.y;
1328
+
1329
+ const wasGrounded = state.grounded;
1330
+ if (result.grounded) {
1331
+ if (!wasGrounded) this._onLand(controller, runtime, impactVy);
1332
+ state.grounded = true;
1333
+ state.groundNormal.copy(result.groundNormal);
1334
+ state.verticalSpeed = 0;
1335
+ state.airborneTime = 0;
1336
+ state.timeSinceGrounded = 0;
1337
+ } else {
1338
+ if (wasGrounded) this._onLeaveGround(controller, runtime, bodyTransform);
1339
+ state.grounded = false;
1340
+ state.verticalSpeed = runtime.velocityY;
1341
+ state.airborneTime += dt;
1342
+ state.timeSinceGrounded += dt;
1343
+ state.fallDistance += Math.max(0, -runtime.velocityY * dt);
1344
+ }
1345
+ }
1346
+
1347
+ /**
1348
+ * No-physics fallback: integrate the velocity directly (there's no
1349
+ * collidable geometry to sweep against) and resolve the floor from
1350
+ * the built-in flat ground and/or the host `groundResolver`. For
1351
+ * headless scenes and control-layer unit tests. With both ground
1352
+ * sources off, an external physics layer is expected to own
1353
+ * `state.grounded`; we only advance timers.
1354
+ * @private
1355
+ */
1356
+ _moveFlatGround(controller, runtime, bodyTransform, dt) {
1357
+ const state = controller.state;
1358
+
1359
+ // translation lands in the matrix as it is written, so no updateMatrix is owed here
1360
+ bodyTransform.setTranslation(
1361
+ bodyTransform.translation_x + runtime.velocityX * dt,
1362
+ bodyTransform.translation_y + runtime.velocityY * dt,
1363
+ bodyTransform.translation_z + runtime.velocityZ * dt
1364
+ );
1365
+
1366
+ if (this.useBuiltInFlatGround || this.groundResolver !== null) {
1367
+ // Effective ground = max(built-in flat ground, resolver).
1368
+ let testY = this.useBuiltInFlatGround ? this.groundY : Number.NEGATIVE_INFINITY;
1369
+ if (this.groundResolver !== null) {
1370
+ const resolved = this.groundResolver(
1371
+ bodyTransform.translation_x, bodyTransform.translation_y, bodyTransform.translation_z,
1372
+ );
1373
+ if (resolved !== null && resolved > testY) testY = resolved;
1374
+ }
1375
+ const haveGround = testY !== Number.NEGATIVE_INFINITY;
1376
+ if (haveGround && bodyTransform.translation_y <= testY) {
1377
+ bodyTransform.setTranslation(bodyTransform.translation_x, testY, bodyTransform.translation_z);
1378
+ if (!state.grounded) this._onLand(controller, runtime, -runtime.velocityY);
1379
+ state.grounded = true;
1380
+ state.groundNormal.set(0, 1, 0);
1381
+ state.verticalSpeed = 0;
1382
+ runtime.velocityY = 0;
1383
+ state.airborneTime = 0;
1384
+ state.timeSinceGrounded = 0;
1385
+ } else {
1386
+ if (state.grounded) this._onLeaveGround(controller, runtime, bodyTransform);
1387
+ state.grounded = false;
1388
+ state.verticalSpeed = runtime.velocityY;
1389
+ state.airborneTime += dt;
1390
+ state.timeSinceGrounded += dt;
1391
+ state.fallDistance += Math.max(0, -runtime.velocityY * dt);
1392
+ }
1393
+ } else {
1394
+ // External physics maintains state.grounded; just track timers.
1395
+ if (state.grounded) {
1396
+ state.timeSinceGrounded = 0;
1397
+ state.airborneTime = 0;
1398
+ } else {
1399
+ state.timeSinceGrounded += dt;
1400
+ state.airborneTime += dt;
1401
+ }
1402
+ }
1403
+ }
1404
+
1405
+ /**
1406
+ * Landing transition: dip / impact springs, exertion impulse, flag
1407
+ * resets, and the `onLand` signal (fired LAST so handlers see fully-
1408
+ * reacted state). Shared by both move paths. `impactVy` is the
1409
+ * downward speed at touchdown (positive).
1410
+ * @private
1411
+ */
1412
+ _onLand(controller, runtime, impactVy) {
1413
+ const cfg = controller.config;
1414
+ const state = controller.state;
1415
+ const kind = impactVy >= cfg.landing.hardThreshold ? "hard" : "soft";
1416
+
1417
+ const massScaledDip = impactVy * cfg.landing.recovery.dipPerVy
1418
+ * runtime.massRatios.landingDipScale;
1419
+ const dip = clamp(massScaledDip, 0, cfg.landing.recovery.dipMax);
1420
+ runtime.landSpring.settle(-dip);
1421
+
1422
+ const landImpulse = clamp(
1423
+ impactVy * cfg.exertion.landImpulsePerVy * runtime.massRatios.exertionRiseScale,
1424
+ 0, cfg.exertion.landImpulseMax,
1425
+ );
1426
+ state.exertion = clamp(state.exertion + landImpulse, 0, 1);
1427
+
1428
+ runtime.midJump = false;
1429
+ state.isAscending = false;
1430
+ state.isVariableJumpCut = false;
1431
+ state.fallDistance = 0;
1432
+
1433
+ controller.signals.onLand.send1({ verticalSpeed: impactVy, kind });
1434
+ }
1435
+
1436
+ /**
1437
+ * Leave-ground transition: `onLeaveGround` signal + takeoff bookkeeping.
1438
+ * Shared by both move paths.
1439
+ * @private
1440
+ */
1441
+ _onLeaveGround(controller, runtime, bodyTransform) {
1442
+ controller.signals.onLeaveGround.send1({ reason: runtime.midJump ? "jump" : "fall" });
1443
+ runtime.takeoffVy = runtime.velocityY;
1444
+ runtime.peakAltitude = bodyTransform.translation_y;
1445
+ }
1446
+
1447
+ /**
1448
+ * Fire `onJumpApex` once, when an in-progress jump stops rising.
1449
+ * @private
1450
+ */
1451
+ _detectJumpApex(controller, runtime, bodyTransform) {
1452
+ if (runtime.midJump && !runtime.apexFired) {
1453
+ if (bodyTransform.translation_y > runtime.peakAltitude) {
1454
+ runtime.peakAltitude = bodyTransform.translation_y;
1455
+ } else if (runtime.velocityY <= 0) {
1456
+ controller.signals.onJumpApex.send0();
1457
+ runtime.apexFired = true;
1458
+ }
1459
+ }
1460
+ }
1461
+
1462
+ /**
1463
+ * Run the base (no-ability) L1 locomotion phases: speed selection,
1464
+ * desired-velocity computation, accel/decel, jump FSM, gravity, body
1465
+ * integration, ground resolution. Only invoked when no ability owns
1466
+ * the tick (see {@link AbilitySet.tick}).
1467
+ *
1468
+ * @private
1469
+ * @param {FirstPersonPlayerController} controller
1470
+ * @param {PerEntityRuntime} runtime
1471
+ * @param {Transform64} bodyTransform
1472
+ * @param {number} dt
1473
+ * @param {boolean} isCrouchActive
1474
+ * @param {boolean} isSprintIntent
1475
+ * @param {boolean} isBackwardIntent
1476
+ */
1477
+ _runBaseLocomotion(controller, runtime, bodyTransform, dt,
1478
+ isCrouchActive, isSprintIntent, isBackwardIntent) {
1479
+ const cfg = controller.config;
1480
+ const intent = controller.intent;
1481
+ const state = controller.state;
1482
+
1483
+ // -- L1.b: Speed selection ------------------------------------
1484
+ let targetSpeed;
1485
+ if (isCrouchActive) {
1486
+ targetSpeed = cfg.motion.crouchSpeed;
1487
+ } else if (isSprintIntent) {
1488
+ targetSpeed = cfg.motion.sprintSpeed;
1489
+ } else {
1490
+ targetSpeed = cfg.motion.walkSpeed;
1491
+ }
1492
+ if (isBackwardIntent) {
1493
+ targetSpeed *= cfg.motion.backwardSpeedFactor;
1494
+ }
1495
+
1496
+ // Airborne momentum floor — preserve whatever horizontal speed
1497
+ // the player carried into the jump. Without this, a sprint
1498
+ // jump (9 m/s) decays toward walkSpeed (4.5 m/s) at
1499
+ // airAccel = 14 m/s², losing all sprint momentum in ~0.32 s —
1500
+ // well before the apex of a `peakHeight = 1.8 m` jump arc. The
1501
+ // air-control band (Mirror's Edge, Titanfall, modern CoD) and
1502
+ // the long-jump biomechanics literature both say the same
1503
+ // thing: there's no thrust source in flight, so horizontal
1504
+ // velocity is conserved across the arc and air "control" is
1505
+ // for steering (direction) — not for changing speed magnitude.
1506
+ // Raising the target to the current speed makes `stepTowards`
1507
+ // a no-op when the player keeps pressing forward, while
1508
+ // releasing the stick still lets `airAccel` decelerate to
1509
+ // `walkSpeed` (the user CAN bleed off speed, just not have it
1510
+ // bled off for them).
1511
+ if (!state.grounded) {
1512
+ const horizSpeed = Math.hypot(runtime.velocityX, runtime.velocityZ);
1513
+ if (horizSpeed > targetSpeed) targetSpeed = horizSpeed;
1514
+ }
1515
+
1516
+ // -- L1.c: Move intent → desired horizontal velocity ----------
1517
+ // screen_forward(θ) = ( sin θ, 0, cos θ )
1518
+ // screen_right (θ) = (-cos θ, 0, sin θ )
1519
+ const { sinYaw, cosYaw } = runtime;
1520
+ const mvX = intent.move.x;
1521
+ const mvY = intent.move.y;
1522
+ const mvMag = Math.hypot(mvX, mvY);
1523
+ const nmvX = mvMag > 1 ? mvX / mvMag : mvX;
1524
+ const nmvY = mvMag > 1 ? mvY / mvMag : mvY;
1525
+ const desiredVx = sinYaw * nmvY + -cosYaw * nmvX;
1526
+ const desiredVz = cosYaw * nmvY + sinYaw * nmvX;
1527
+ const desiredHorizontalVx = desiredVx * targetSpeed;
1528
+ const desiredHorizontalVz = desiredVz * targetSpeed;
1529
+
1530
+ // -- L1.d: Accel/decel toward desired velocity ----------------
1531
+ //
1532
+ // Three regimes — air control, grounded decel-to-stop, grounded
1533
+ // accel-to-target — each with its own model:
1534
+ //
1535
+ // • Air control: constant-rate `stepTowards`. No ground
1536
+ // reaction force in flight; air control is a steering
1537
+ // budget, not a thrust curve. Constant accel matches the
1538
+ // player mental model of "fixed mid-air authority".
1539
+ //
1540
+ // • Grounded decel (no intent): constant-rate `stepTowards`
1541
+ // toward zero. Friction is approximately constant for a
1542
+ // biped on level ground — Coulomb friction. Faster than
1543
+ // accel because the body's own resistance + active
1544
+ // decel-foot-plants combine into a sharper deceleration.
1545
+ //
1546
+ // • Grounded accel (intent active): mono-exponential
1547
+ // approach (Hill 1927; Furusawa-Hill 1928). dv/dt is
1548
+ // proportional to (v_target − v), so accel is highest at
1549
+ // low speed and tapers as v approaches v_target. Matches
1550
+ // human sprint biomechanics — modern sprint-profiling
1551
+ // work (Morin & Samozino 2016) fits this same mono-exp
1552
+ // curve to empirical force-plate data.
1553
+ //
1554
+ // The mass + mastery + backward scalars compose multiplicatively
1555
+ // on the EFFECTIVE half-life (heavier ⇒ longer half-life ⇒
1556
+ // slower ramp; mastery accel-bonus ⇒ shorter half-life ⇒
1557
+ // faster ramp). See FirstPersonPlayerControllerConfig.js's
1558
+ // `groundAccelHalfLife` doc for the literature and the
1559
+ // SprintAcceleration.spec.js for the model assertions.
1560
+ const intentLen = Math.hypot(nmvX, nmvY);
1561
+ if (!state.grounded) {
1562
+ const maxStep = cfg.motion.airAccel * dt;
1563
+ runtime.velocityX = stepTowards(runtime.velocityX, desiredHorizontalVx, maxStep);
1564
+ runtime.velocityZ = stepTowards(runtime.velocityZ, desiredHorizontalVz, maxStep);
1565
+ } else if (intentLen < 1e-4) {
1566
+ let decel = cfg.motion.groundDecel * runtime.massRatios.groundAccelScale;
1567
+ decel *= controller.mastery.evaluate(DecisionPoint.GroundAccel, controller, runtime);
1568
+ const maxStep = decel * dt;
1569
+ runtime.velocityX = stepTowards(runtime.velocityX, 0, maxStep);
1570
+ runtime.velocityZ = stepTowards(runtime.velocityZ, 0, maxStep);
1571
+ } else {
1572
+ // Mono-exponential approach. Scale half-life by the
1573
+ // inverse of the accel scalars so that "more accel" (large
1574
+ // groundAccelScale, mastery > 1.0) translates to a shorter
1575
+ // half-life (faster ramp). Backward intent slows things
1576
+ // down — backwardAccelFactor < 1 ⇒ longer half-life.
1577
+ let halfLife = cfg.motion.groundAccelHalfLife
1578
+ / runtime.massRatios.groundAccelScale
1579
+ / controller.mastery.evaluate(DecisionPoint.GroundAccel, controller, runtime);
1580
+ if (isBackwardIntent) halfLife /= cfg.motion.backwardAccelFactor;
1581
+ runtime.velocityX = exponentialApproach(runtime.velocityX, desiredHorizontalVx, halfLife, dt);
1582
+ runtime.velocityZ = exponentialApproach(runtime.velocityZ, desiredHorizontalVz, halfLife, dt);
1583
+ }
1584
+
1585
+ // -- L1.e/f/g/h: jump FSM + vertical integration --------------
1586
+ this._advanceJumpFsm(controller, runtime, bodyTransform, dt);
1587
+ this._integrateVerticalAndResolveGround(controller, runtime, bodyTransform, dt);
1588
+
1589
+ // -- Publish posture for L2 consumers (eye height, gait gating).
1590
+ // Base owns posture when no ability is active: Crouch if the
1591
+ // crouch intent is resolved active, otherwise Stand. Abilities
1592
+ // that need a different posture (slide → Prone, ledge-grab →
1593
+ // Hang) set state.posture themselves in their tick.
1594
+ let posture = isCrouchActive
1595
+ ? FirstPersonPosture.Crouch
1596
+ : FirstPersonPosture.Stand;
1597
+ // Headroom floor — block UN-proning under a very low overhang. Only an
1598
+ // ALREADY-prone body (e.g. one that slid into a crawl-height tunnel) is
1599
+ // force-kept prone while the Crouch collider can't fit above it. A player
1600
+ // merely crouching by intent is NOT auto-dropped to prone when they meet
1601
+ // a lower overhang — same rule as above: the mover blocks their forward
1602
+ // motion instead of the ceiling shoving them down. Uses the post-move
1603
+ // position, so it reflects where the body actually ended up this tick.
1604
+ if (posture === FirstPersonPosture.Crouch
1605
+ && runtime.lastPosture === FirstPersonPosture.Prone) {
1606
+ const proneGrow = runtime.crouchTop - runtime.proneTop;
1607
+ if (!this._hasHeadroomToGrow(runtime, bodyTransform, runtime.colliderShapeProne, proneGrow)) {
1608
+ posture = FirstPersonPosture.Prone;
1609
+ }
1610
+ }
1611
+ controller.state.posture = posture;
1612
+
1613
+ // -- Publish lean target for L2.f. Base writes the natural
1614
+ // (lat-accel + look-lean) value; abilities override in their
1615
+ // own tick. L2.f spring-steps toward whatever's here.
1616
+ runtime.leanTargetRad = this._computeNaturalLeanTarget(controller, runtime, dt);
1617
+ }
1618
+
1619
+ /**
1620
+ * Compute the natural camera lean for this tick: lat-accel-driven
1621
+ * roll into a turn, plus a yaw-rate look-lean contribution, both
1622
+ * clamped. The result is the target the lean spring chases each
1623
+ * tick when no ability has opinions.
1624
+ *
1625
+ * Pure-ish helper — reads `controller`, `runtime`, `dt`; returns a
1626
+ * number. Extracted so both base and any future ability that wants
1627
+ * to compose its lean on top of the natural value can call it.
1628
+ *
1629
+ * @private
1630
+ * @param {FirstPersonPlayerController} controller
1631
+ * @param {PerEntityRuntime} runtime
1632
+ * @param {number} dt
1633
+ * @returns {number} target roll in radians
1634
+ */
1635
+ _computeNaturalLeanTarget(controller, runtime, dt) {
1636
+ const cfg = controller.config;
1637
+ const state = controller.state;
1638
+ if (!cfg.lean.enabled) return 0;
1639
+
1640
+ const sinYaw = runtime.sinYaw;
1641
+ const cosYaw = runtime.cosYaw;
1642
+
1643
+ // Lateral acceleration projected onto screen-right.
1644
+ // accel_world = (vel - prevVel) / dt; screen_right = (-cos θ, 0, sin θ).
1645
+ const accWorldX = (runtime.velocityX - runtime.prevVelocityX) / Math.max(dt, 1e-4);
1646
+ const accWorldZ = (runtime.velocityZ - runtime.prevVelocityZ) / Math.max(dt, 1e-4);
1647
+ const latAccel = accWorldX * (-cosYaw) + accWorldZ * sinYaw;
1648
+ const normalized = clamp(latAccel / 9.81, -2, 2);
1649
+ //
1650
+ // Sign convention for the roll (the eye composes the rotation
1651
+ // as qYaw * qPitch * qRoll, where qRoll is around (0,0,1)).
1652
+ // After the engine's camera-invert pipeline:
1653
+ // φ > 0 → camera-up tilts toward screen-right (−X) → HEAD TILTS RIGHT
1654
+ // φ < 0 → camera-up tilts toward screen-left (+X) → HEAD TILTS LEFT
1655
+ //
1656
+ // For the "bank into the turn" feel (Apex / Titanfall / Mirror's
1657
+ // Edge): accelerating right (latAccel > 0) should tilt the head
1658
+ // RIGHT, i.e. positive φ. So leanTargetRad has the SAME sign
1659
+ // as latAccel.
1660
+ let leanTargetRad = normalized * cfg.lean.maxRollDeg * DEG_TO_RAD;
1661
+
1662
+ // Look-lean: yaw-rate-driven banking. runtime.yawRateRadPerSec
1663
+ // was cached at L1.a — negative is the "turn right" convention.
1664
+ // For "bank into the turn": turning right → head tilts right →
1665
+ // positive engine roll. So lookLean = -yawRate * scale matches
1666
+ // sign.
1667
+ //
1668
+ // Crouched players are in a low, stable, low-momentum stance —
1669
+ // banking the head from a mouse turn reads as unmotivated. We
1670
+ // scale the contribution down (default to 0) while crouched.
1671
+ // Lat-accel lean is left alone: its magnitude naturally tracks
1672
+ // the (lower) crouch acceleration, so it stays motivated.
1673
+ if (cfg.lean.lookLeanEnabled) {
1674
+ const yawRate = clamp(
1675
+ runtime.yawRateRadPerSec,
1676
+ -cfg.lean.lookLeanYawRateClamp,
1677
+ cfg.lean.lookLeanYawRateClamp,
1678
+ );
1679
+ const crouchFactor = state.crouchActive ? cfg.lean.crouchLookLeanFactor : 1.0;
1680
+ leanTargetRad += -yawRate * cfg.lean.lookLeanDegPerRadPerSec * DEG_TO_RAD * crouchFactor;
1681
+ }
1682
+
1683
+ // Final clamp on the sum: cap the combined target to ±2 ×
1684
+ // maxRollDeg (matches the latAccel normalized clamp range) so
1685
+ // even simultaneous max-strafe-accel + max-yaw-flick produces a
1686
+ // sane upper bound.
1687
+ const maxTotal = cfg.lean.maxRollDeg * DEG_TO_RAD * 2;
1688
+ return clamp(leanTargetRad, -maxTotal, maxTotal);
1689
+ }
1690
+
1691
+ /**
1692
+ * Snapshot the per-tick "what is the body doing" information into the
1693
+ * pose channels for downstream consumption (skeleton, sound, AI).
1694
+ * Read-only with respect to controller state — this is purely a publish
1695
+ * step.
1696
+ *
1697
+ * @private
1698
+ * @param {FirstPersonPlayerController} controller
1699
+ * @param {PerEntityRuntime} runtime
1700
+ * @param {Transform64} bodyTransform
1701
+ */
1702
+ _publishPose(controller, runtime, bodyTransform) {
1703
+ const cfg = controller.config;
1704
+ const state = controller.state;
1705
+ const pose = controller.pose;
1706
+
1707
+ pose.rootPosition.set(bodyTransform.translation_x, bodyTransform.translation_y, bodyTransform.translation_z);
1708
+ pose.rootYawRad = runtime.bodyYaw;
1709
+ pose.headYawRad = runtime.bodyYaw;
1710
+ pose.headPitchRad = runtime.eyePitch;
1711
+ pose.headRollRad = state.leanRollRad;
1712
+ pose.locomotionPhase = state.stridePhase;
1713
+ pose.locomotionSpeed = runtime.horizSpeed;
1714
+ // Strafe component: project velocity onto screen-right (-cos θ, 0, sin θ).
1715
+ // Positive = moving to the player's right.
1716
+ pose.locomotionStrafe = (runtime.velocityX * (-runtime.cosYaw) + runtime.velocityZ * runtime.sinYaw)
1717
+ / Math.max(cfg.motion.sprintSpeed, 1e-3);
1718
+ pose.actionState =
1719
+ state.inJumpAnticipation ? FirstPersonActionState.Anticipating
1720
+ : !state.grounded ? FirstPersonActionState.Airborne
1721
+ : (Math.abs(runtime.landSpring.value) > 0.01 ? FirstPersonActionState.Landing
1722
+ : FirstPersonActionState.Grounded);
1723
+ pose.locomotionMode = state.locomotionMode;
1724
+ const crouchSpan = Math.max(cfg.body.height - cfg.body.crouchHeight, 1e-3);
1725
+ pose.crouchAmount = clamp((cfg.body.height - state.eyeHeight) / crouchSpan, 0, 1);
1726
+
1727
+ // Posture channel for downstream animation: which body shape +
1728
+ // how far the body is into it from the standing neutral.
1729
+ //
1730
+ // `posture` is the enum (Stand / Crouch / Prone / Hang) — picks
1731
+ // the animation track. `postureAmount` is the [0..1] blend
1732
+ // weight from standing toward that posture, derived from the
1733
+ // eye-height spring so the value transitions smoothly across
1734
+ // changes (matches the visible camera motion).
1735
+ pose.posture = state.posture;
1736
+ let postureTargetH;
1737
+ switch (state.posture) {
1738
+ case FirstPersonPosture.Prone: postureTargetH = cfg.body.proneHeight; break;
1739
+ case FirstPersonPosture.Crouch: postureTargetH = cfg.body.crouchHeight; break;
1740
+ case FirstPersonPosture.Hang: postureTargetH = cfg.body.height; break;
1741
+ case FirstPersonPosture.Stand:
1742
+ default: postureTargetH = cfg.body.height; break;
1743
+ }
1744
+ const postureSpan = Math.max(cfg.body.height - postureTargetH, 1e-3);
1745
+ pose.postureAmount = clamp((cfg.body.height - state.eyeHeight) / postureSpan, 0, 1);
1746
+
1747
+ pose.aimPitch = runtime.eyePitch;
1748
+
1749
+ this._assertTickInvariants(controller, runtime, bodyTransform);
1750
+ this._snapshotRenderPose(runtime, bodyTransform);
1751
+ }
1752
+
1753
+ /**
1754
+ * Record this fixed step's authoritative body position as the latest
1755
+ * render-interpolation endpoint, shifting the old latest to "previous".
1756
+ * {@link _composeEye} blends prev→cur by the sub-step alpha so the camera
1757
+ * position is smooth at any refresh rate. (Yaw/pitch aren't snapshotted —
1758
+ * they're render-rate.) On a teleport (`renderSnap`), prev and cur collapse
1759
+ * to the new position so the camera jumps rather than gliding across it.
1760
+ * @private
1761
+ */
1762
+ _snapshotRenderPose(runtime, bodyTransform) {
1763
+ const p = bodyTransform;
1764
+ if (runtime.renderSnap) {
1765
+ runtime.renderSnap = false;
1766
+ runtime.renderPrevPos.set(p.translation_x, p.translation_y, p.translation_z);
1767
+ runtime.renderCurPos.set(p.translation_x, p.translation_y, p.translation_z);
1768
+ return;
1769
+ }
1770
+ runtime.renderPrevPos.copy(runtime.renderCurPos);
1771
+ runtime.renderCurPos.set(p.translation_x, p.translation_y, p.translation_z);
1772
+ }
1773
+
1774
+ /**
1775
+ * Dev-only invariant guard (meep asserts compile out of prod, so this is
1776
+ * free there). Catches the class of corruption that surfaces as an
1777
+ * unexplained camera "snap": a NaN/Infinity leaking into the yaw/pitch the
1778
+ * eye composes from, or into the body's velocity / position. Runs once at
1779
+ * the END of every fixed step, so a bad value is caught the tick it appears
1780
+ * — naming the layer that produced it — instead of manifesting as a camera
1781
+ * jump frames (or seconds) later when something finally reads it.
1782
+ * @private
1783
+ */
1784
+ _assertTickInvariants(controller, runtime, bodyTransform) {
1785
+ const p = bodyTransform;
1786
+ assert.ok(Number.isFinite(runtime.bodyYaw),
1787
+ "FP invariant: runtime.bodyYaw is non-finite (camera yaw corrupted)");
1788
+ assert.ok(Number.isFinite(runtime.eyePitch),
1789
+ "FP invariant: runtime.eyePitch is non-finite (camera pitch corrupted)");
1790
+ assert.ok(Number.isFinite(controller.state.leanRollRad),
1791
+ "FP invariant: state.leanRollRad is non-finite (camera roll corrupted)");
1792
+ assert.ok(
1793
+ Number.isFinite(runtime.velocityX)
1794
+ && Number.isFinite(runtime.velocityY)
1795
+ && Number.isFinite(runtime.velocityZ),
1796
+ "FP invariant: runtime.velocity is non-finite",
1797
+ );
1798
+ assert.ok(
1799
+ Number.isFinite(p.translation_x) && Number.isFinite(p.translation_y) && Number.isFinite(p.translation_z),
1800
+ "FP invariant: body position is non-finite",
1801
+ );
1802
+ }
1803
+
1804
+ /**
1805
+ * Consume the accumulated mouse-look delta into body yaw + eye pitch at
1806
+ * RENDER rate (once per render frame, from {@link _composeEye}). This is the
1807
+ * fix for the slow-turn camera snap: sampling `intent.look` at the 60Hz
1808
+ * fixed step aliases an unaligned mouse report rate into an uneven turn,
1809
+ * whereas consuming it every render frame tracks the mouse as smoothly as it
1810
+ * arrives. `intent.look` is zeroed after consuming so the same delta isn't
1811
+ * re-applied next frame.
1812
+ *
1813
+ * Conventions (raw mouse delta source — movementX/Y positive moving
1814
+ * right/down): look.x > 0 ("mouse right") → turn right (the yaw sign is
1815
+ * negated because the engine is left-handed with +Z forward, so a +Y
1816
+ * rotation reads as a LEFT turn through the camera); look.y > 0 → look down,
1817
+ * flipped by invertY.
1818
+ * @private
1819
+ */
1820
+ _consumeLook(controller, runtime) {
1821
+ const intent = controller.intent;
1822
+ const cfg = controller.config;
1823
+ let yawDelta = -intent.look.x;
1824
+ const pitchSign = cfg.look.invertY ? -1 : 1;
1825
+ let pitchDelta = intent.look.y * pitchSign;
1826
+ intent.look.set(0, 0);
1827
+
1828
+ // Reject input SPIKES. A single render frame's look delta beyond a sane
1829
+ // bound is a corrupt browser/driver `movementX` (a high-res mouse under
1830
+ // OS pointer acceleration, or a pointer-lock burst), not a real turn —
1831
+ // applying it snaps the camera 20–100°. Drop the spiking axis; legit
1832
+ // turns (even fast flicks) stay well under the bound at render rate.
1833
+ const maxDelta = cfg.look.maxFrameDeltaRad;
1834
+ if (Math.abs(yawDelta) > maxDelta) yawDelta = 0;
1835
+ if (Math.abs(pitchDelta) > maxDelta) pitchDelta = 0;
1836
+
1837
+ runtime.bodyYaw += yawDelta;
1838
+ if (runtime.bodyYaw > Math.PI) runtime.bodyYaw -= TWO_PI;
1839
+ else if (runtime.bodyYaw < -Math.PI) runtime.bodyYaw += TWO_PI;
1840
+
1841
+ runtime.eyePitch = clamp(
1842
+ runtime.eyePitch + pitchDelta,
1843
+ cfg.look.pitchMinDeg * DEG_TO_RAD,
1844
+ cfg.look.pitchMaxDeg * DEG_TO_RAD,
1845
+ );
1846
+ }
1847
+
1848
+ /**
1849
+ * Compose the eye transform from body + state-driven offsets.
1850
+ * @private
1851
+ * @param {FirstPersonPlayerController} controller
1852
+ * @param {number} entity
1853
+ */
1854
+ _composeEye(controller, entity) {
1855
+ const ecd = this.entityManager.dataset;
1856
+ const runtime = this.runtime.get(entity);
1857
+ if (runtime === undefined) return;
1858
+
1859
+ // Consume mouse-look at RENDER rate so the camera direction tracks the
1860
+ // mouse smoothly, decoupled from the aliased 60Hz sim sampling.
1861
+ this._consumeLook(controller, runtime);
1862
+
1863
+ const dt = this._currentRenderDt;
1864
+ const cfg = controller.config;
1865
+ const state = controller.state;
1866
+
1867
+ const bodyTransform = ecd.getComponent(entity, Transform64);
1868
+ if (bodyTransform === undefined) return;
1869
+
1870
+ if (controller.eyeEntity === -1) return;
1871
+ const eyeTransform = ecd.getComponent(controller.eyeEntity, Transform64);
1872
+ const camera = ecd.getComponent(controller.eyeEntity, Camera);
1873
+ if (eyeTransform === undefined || camera === undefined) return;
1874
+
1875
+ // -- Body-local eye offset, composed via the additive stack ----
1876
+ // The base (0, eyeHeight, 0) is the standing/crouched neutral; each
1877
+ // additional contribution (bob, breath, landing, anticipation,
1878
+ // sprint posture) goes through the stack so external systems can
1879
+ // push their own contributions on the same channel.
1880
+ const stack = runtime.eyeOffsetStack;
1881
+ stack.clear();
1882
+ stack.push("eyeHeight", 0, state.eyeHeight, 0);
1883
+
1884
+ // Bob — gated on grounded only (the impact spring decays naturally
1885
+ // even at rest, so the bob fade-out is smooth; lateral amp uses the
1886
+ // bob-intensity envelope which spring-decays after stopping).
1887
+ if (state.grounded) {
1888
+ const phase = state.stridePhase * TWO_PI;
1889
+ const massBoost = (cfg.body.mass - 80) * cfg.bob.ampMassScale;
1890
+ const intensity = runtime.bobIntensitySpring.value;
1891
+
1892
+ // Back-pedal amp boost — lateral grows more than vertical because
1893
+ // backward gait has worse side-to-side balance than vertical compression.
1894
+ // Exertion adds a smaller boost on top: tired = wobbly gait.
1895
+ const ampLMult = 1 + (cfg.bob.backwardLateralAmpFactor - 1) * runtime.backwardness;
1896
+ const exertionBoost = 1 + cfg.exertion.bobLateralBoostAtMax * state.exertion;
1897
+ const ampL = (cfg.bob.lateralAmpAtWalk + massBoost) * intensity * ampLMult * exertionBoost;
1898
+
1899
+ // Vertical: read directly from the impact spring (footfall kicks,
1900
+ // under-damped recovery → trough + leg-push overshoot).
1901
+ stack.push("bob.impact", 0, runtime.verticalImpactSpring.value, 0);
1902
+
1903
+ // Lateral: head shifts toward the foot bearing weight. Polarity
1904
+ // sourced from runtime.standingFoot — the same signal the
1905
+ // footstep emits — so bob direction and footstep side agree.
1906
+ // |sin(phase)| is the non-negative "midstance envelope".
1907
+ const lateralPolarity = runtime.standingFoot === "R" ? -1 : 1;
1908
+ stack.push("bob.lateral", ampL * lateralPolarity * Math.abs(Math.sin(phase)), 0, 0);
1909
+ }
1910
+
1911
+ // Breath — sine + tiny noise riding the rate spring.
1912
+ const breathOffset = -state.breathAmplitudeM
1913
+ * Math.sin(state.breathPhase * TWO_PI)
1914
+ * (1 + cfg.breath.noiseAmount * (Math.sin(state.breathPhase * 13.7) * 0.5));
1915
+ stack.push("breath", 0, breathOffset, 0);
1916
+
1917
+ // Landing spring dip (under-damped — overshoots once on recovery).
1918
+ stack.push("landing", 0, runtime.landSpring.value, 0);
1919
+
1920
+ // Jump anticipation dip (eased ramp during the squash window).
1921
+ if (state.inJumpAnticipation) {
1922
+ const t = 1 - clamp(runtime.anticipationRemaining / Math.max(cfg.jump.anticipation.duration, 1e-3), 0, 1);
1923
+ const eased = t * (2 - t); // ease-out quad
1924
+ stack.push("anticipation", 0, -cfg.jump.anticipation.dipAmount * eased, 0);
1925
+ }
1926
+
1927
+ // Sprint posture: head leans slightly forward as commitment builds.
1928
+ // Pitch part is in the rotation block below; the +Z position shift
1929
+ // sells "head leading the hips" (Mirror's Edge), tied to the same
1930
+ // spring envelope so they move together.
1931
+ const sprintPitch = runtime.sprintPostureSpring.value;
1932
+ const sprintShiftFraction =
1933
+ cfg.posture.sprintForwardPitchDeg > 0
1934
+ ? sprintPitch / (cfg.posture.sprintForwardPitchDeg * DEG_TO_RAD)
1935
+ : 0;
1936
+ stack.push("posture.sprintShift", 0, 0, cfg.posture.sprintForwardShiftM * sprintShiftFraction);
1937
+
1938
+ // Body POSITION advances only at the fixed step → blend the last two
1939
+ // steps by the engine's sub-step alpha (the same fraction the
1940
+ // InterpolationSystem uses for physics bodies) so it's smooth on a high-
1941
+ // refresh display. Yaw + pitch are already render-rate (consumed in
1942
+ // _consumeLook), so they're used directly — no blend, no aliasing.
1943
+ const alpha = this.entityManager.getFixedStepAlpha();
1944
+ const iPosX = lerp(runtime.renderPrevPos.x, runtime.renderCurPos.x, alpha);
1945
+ const iPosY = lerp(runtime.renderPrevPos.y, runtime.renderCurPos.y, alpha);
1946
+ const iPosZ = lerp(runtime.renderPrevPos.z, runtime.renderCurPos.z, alpha);
1947
+ const qYaw0 = SCRATCH_Q_A.fromAxisAngle(Vector3.up, runtime.bodyYaw);
1948
+
1949
+ // Transform body-local accumulated offset into world space (about the
1950
+ // render-rate yaw, to match the interpolated base position).
1951
+ const worldOffset = SCRATCH_V3_B.copy(stack.offset);
1952
+ worldOffset.applyQuaternion(qYaw0);
1953
+
1954
+ eyeTransform.setTranslation(
1955
+ iPosX + worldOffset.x,
1956
+ iPosY + worldOffset.y,
1957
+ iPosZ + worldOffset.z
1958
+ );
1959
+
1960
+ // -- Eye rotation: body yaw × eye pitch × roll -------------------
1961
+ // Bob roll mixes in for a subtle head sway (in phase with lateral bob).
1962
+ // Breath pitch is a small extra nod 90° out of phase with vertical
1963
+ // breath; merged into the main pitch so we don't pay an extra quat
1964
+ // multiply and the composition stays trivially correct.
1965
+ let rollTotal = state.leanRollRad;
1966
+ if (state.grounded) {
1967
+ // Roll: head tilts toward the standing foot, in phase with the
1968
+ // lateral sway. Polarity sourced from runtime.standingFoot for
1969
+ // consistency with the lateral bob. Positive engine roll = head
1970
+ // tilts RIGHT (camera-invert convention), so R-foot midstance =
1971
+ // positive roll, L-foot midstance = negative roll.
1972
+ const phase = state.stridePhase * TWO_PI;
1973
+ const rollBackMult = 1 + (cfg.bob.backwardRollFactor - 1) * runtime.backwardness;
1974
+ const ampRoll = cfg.bob.rollAtWalkDeg * DEG_TO_RAD * runtime.bobIntensitySpring.value * rollBackMult;
1975
+ const rollPolarity = runtime.standingFoot === "R" ? 1 : -1;
1976
+ const rollEnvelope = Math.abs(Math.sin(phase));
1977
+ const bobRollSigned = ampRoll * rollPolarity * rollEnvelope;
1978
+
1979
+ // Lean × bob coupling: excursions in the lean direction get
1980
+ // amplified, opposite excursions attenuated. Lean is normalized
1981
+ // against maxRollDeg so the coupling magnitude stays bounded
1982
+ // regardless of how aggressively lean is configured.
1983
+ const maxLeanRad = Math.max(cfg.lean.maxRollDeg * DEG_TO_RAD, 1e-6);
1984
+ const leanFraction = clamp(state.leanRollRad / maxLeanRad, -1, 1);
1985
+ // sign(bobRollSigned) matches lean? amplify; else attenuate.
1986
+ const sameSign = (bobRollSigned * leanFraction) >= 0;
1987
+ const couplingMag = cfg.bob.leanCouplingFactor * Math.abs(leanFraction);
1988
+ const couplingScale = sameSign ? (1 + couplingMag) : (1 - couplingMag);
1989
+ rollTotal += bobRollSigned * couplingScale;
1990
+ }
1991
+
1992
+ const breathPitch = lerp(cfg.breath.pitchAmpRestDeg, cfg.breath.pitchAmpMaxDeg, state.exertion)
1993
+ * DEG_TO_RAD
1994
+ * Math.cos(state.breathPhase * TWO_PI);
1995
+ // Combined pitch contributions: player input + breath nod + sprint
1996
+ // commitment + fatigue droop. All in the same "positive = look-down"
1997
+ // convention so they sum cleanly.
1998
+ const pitchTotal = runtime.eyePitch
1999
+ + breathPitch
2000
+ + runtime.sprintPostureSpring.value
2001
+ + runtime.headDroopSpring.value;
2002
+
2003
+ // composition: yaw * pitch * roll
2004
+ // pitch around world X — yaw applied after, so effective axis is camera-local right
2005
+ // roll around world Z — yaw and pitch applied after, so effective axis is camera-local forward
2006
+ const qYaw = qYaw0; // SCRATCH_Q_A, already the render-rate yaw
2007
+ const qPitch = SCRATCH_Q_B.fromAxisAngle(Vector3.right, pitchTotal);
2008
+ const qRoll = SCRATCH_Q_C.fromAxisAngle(Vector3.forward, rollTotal);
2009
+
2010
+ // composed in a scratch: a transform keeps its rotation as four numbers and has no
2011
+ // quaternion product of its own
2012
+ SCRATCH_Q_EULER.multiplyQuaternions(qYaw, qPitch);
2013
+ SCRATCH_Q_EULER.multiply(qRoll);
2014
+
2015
+ eyeTransform.setRotation(
2016
+ SCRATCH_Q_EULER.x, SCRATCH_Q_EULER.y, SCRATCH_Q_EULER.z, SCRATCH_Q_EULER.w
2017
+ );
2018
+
2019
+ eyeTransform.updateMatrix();
2020
+
2021
+ // Dev-only camera-output guard (compiled out of prod). The composed eye
2022
+ // direction is exactly what the player sees; if a spring (head-droop,
2023
+ // breath nod) or lean ever drove pitch/roll to NaN, the camera would
2024
+ // snap to garbage. Assert it here, at the source of the visible jump.
2025
+ assert.ok(
2026
+ Number.isFinite(eyeTransform.rotation_x) && Number.isFinite(eyeTransform.rotation_y) && Number.isFinite(eyeTransform.rotation_z) && Number.isFinite(eyeTransform.rotation_w),
2027
+ "FP invariant: composed eye rotation is non-finite (camera direction corrupted)",
2028
+ );
2029
+
2030
+ // -- FOV ---------------------------------------------------------
2031
+ let fovTarget = cfg.fov.base;
2032
+ if (cfg.fov.sprintAdd !== 0) {
2033
+ fovTarget += cfg.fov.sprintAdd * runtime.sprintness;
2034
+ }
2035
+ if (state.crouchActive) fovTarget += cfg.fov.crouchAdd;
2036
+
2037
+ runtime.fovSpring.stepTo(fovTarget, cfg.fov.smoothHalfLife, 1.0, dt);
2038
+ camera.fov.set(runtime.fovSpring.value);
2039
+ }
2040
+ }
2041
+
2042
+ // ---------------------------------------------------------------------------
2043
+ // helpers
2044
+ // ---------------------------------------------------------------------------
2045
+
2046
+ /**
2047
+ * Exponential approach with half-life parameterization.
2048
+ * @param {number} current
2049
+ * @param {number} target
2050
+ * @param {number} halfLife
2051
+ * @param {number} dt
2052
+ * @returns {number}
2053
+ */
2054
+ function exponentialApproach(current, target, halfLife, dt) {
2055
+ if (halfLife <= 0) return target;
2056
+ const alpha = 1 - Math.exp(-LN2 * dt / halfLife);
2057
+ return current + (target - current) * alpha;
2058
+ }
2059
+
2060
+ /**
2061
+ * Detect that phase value crossed a boundary in [0,1) between two ticks.
2062
+ * Handles the wraparound case where phase jumps from e.g. 0.95 to 0.05.
2063
+ *
2064
+ * @param {number} prev previous phase in [0,1)
2065
+ * @param {number} next current phase in [0,1)
2066
+ * @param {number} boundary in [0,1)
2067
+ * @returns {boolean}
2068
+ */
2069
+ function phaseCrossed(prev, next, boundary) {
2070
+ if (next >= prev) {
2071
+ // no wrap
2072
+ return prev < boundary && next >= boundary;
2073
+ } else {
2074
+ // wrapped past 1.0
2075
+ return prev < boundary || next >= boundary;
2076
+ }
2077
+ }
2078
+