@woosh/meep-engine 2.138.19 → 2.139.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 (496) hide show
  1. package/package.json +2 -1
  2. package/src/core/collection/PairUint32Map.d.ts +100 -0
  3. package/src/core/collection/PairUint32Map.d.ts.map +1 -0
  4. package/src/core/collection/PairUint32Map.js +321 -0
  5. package/src/core/collection/Uint32Map.d.ts +119 -0
  6. package/src/core/collection/Uint32Map.d.ts.map +1 -0
  7. package/src/core/collection/Uint32Map.js +345 -0
  8. package/src/core/collection/array/array_shuffle.d.ts +10 -3
  9. package/src/core/collection/array/array_shuffle.d.ts.map +1 -1
  10. package/src/core/collection/array/array_shuffle.js +27 -22
  11. package/src/core/collection/heap/FibonacciHeap.d.ts +195 -0
  12. package/src/core/collection/heap/FibonacciHeap.d.ts.map +1 -0
  13. package/src/core/collection/heap/FibonacciHeap.js +586 -0
  14. package/src/core/collection/heap/Uint32Heap.js +1 -1
  15. package/src/core/collection/heap/Uint32Heap4.d.ts +169 -0
  16. package/src/core/collection/heap/Uint32Heap4.d.ts.map +1 -0
  17. package/src/core/collection/heap/Uint32Heap4.js +490 -0
  18. package/src/core/geom/3d/line/line3_closest_points_segment_segment.d.ts +27 -0
  19. package/src/core/geom/3d/line/line3_closest_points_segment_segment.d.ts.map +1 -0
  20. package/src/core/geom/3d/line/line3_closest_points_segment_segment.js +88 -0
  21. package/src/core/geom/3d/shape/BoxShape3D.d.ts +61 -0
  22. package/src/core/geom/3d/shape/BoxShape3D.d.ts.map +1 -0
  23. package/src/core/geom/3d/shape/BoxShape3D.js +158 -0
  24. package/src/core/geom/3d/shape/CapsuleShape3D.d.ts +11 -0
  25. package/src/core/geom/3d/shape/CapsuleShape3D.d.ts.map +1 -1
  26. package/src/core/geom/3d/shape/CapsuleShape3D.js +12 -0
  27. package/src/core/geom/3d/shape/UnitCubeShape3D.d.ts +37 -9
  28. package/src/core/geom/3d/shape/UnitCubeShape3D.d.ts.map +1 -1
  29. package/src/core/geom/3d/shape/UnitCubeShape3D.js +45 -98
  30. package/src/core/geom/3d/shape/UnitSphereShape3D.d.ts +10 -0
  31. package/src/core/geom/3d/shape/UnitSphereShape3D.d.ts.map +1 -1
  32. package/src/core/geom/3d/shape/UnitSphereShape3D.js +11 -0
  33. package/src/core/geom/3d/shape/util/shape3d_voxelize_to_grid.d.ts +61 -0
  34. package/src/core/geom/3d/shape/util/shape3d_voxelize_to_grid.d.ts.map +1 -0
  35. package/src/core/geom/3d/shape/util/shape3d_voxelize_to_grid.js +148 -0
  36. package/src/core/geom/3d/tetrahedra/compute_tetrahedral_mesh_from_surface.d.ts +39 -0
  37. package/src/core/geom/3d/tetrahedra/compute_tetrahedral_mesh_from_surface.d.ts.map +1 -0
  38. package/src/core/geom/3d/tetrahedra/compute_tetrahedral_mesh_from_surface.js +147 -0
  39. package/src/core/geom/3d/tetrahedra/compute_tetrahedron_quality.d.ts +15 -0
  40. package/src/core/geom/3d/tetrahedra/compute_tetrahedron_quality.d.ts.map +1 -0
  41. package/src/core/geom/3d/tetrahedra/compute_tetrahedron_quality.js +22 -0
  42. package/src/core/geom/3d/tetrahedra/prototype_tetrahedrize_mesh.d.ts +2 -0
  43. package/src/core/geom/3d/tetrahedra/prototype_tetrahedrize_mesh.d.ts.map +1 -0
  44. package/src/core/geom/3d/tetrahedra/prototype_tetrahedrize_mesh.js +673 -0
  45. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_carve_outside_surface.d.ts +26 -0
  46. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_carve_outside_surface.d.ts.map +1 -0
  47. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_carve_outside_surface.js +222 -0
  48. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_find_tets_around_edge.d.ts +34 -0
  49. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_find_tets_around_edge.d.ts.map +1 -0
  50. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_find_tets_around_edge.js +146 -0
  51. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_flip_23.d.ts +36 -0
  52. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_flip_23.d.ts.map +1 -0
  53. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_flip_23.js +232 -0
  54. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_flip_32.d.ts +33 -0
  55. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_flip_32.d.ts.map +1 -0
  56. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_flip_32.js +255 -0
  57. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_improve_quality.d.ts +68 -0
  58. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_improve_quality.d.ts.map +1 -0
  59. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_improve_quality.js +365 -0
  60. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_smooth_vertex.d.ts +31 -0
  61. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_smooth_vertex.d.ts.map +1 -0
  62. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_smooth_vertex.js +112 -0
  63. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_vertex_is_boundary.d.ts +22 -0
  64. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_vertex_is_boundary.d.ts.map +1 -0
  65. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_vertex_is_boundary.js +55 -0
  66. package/src/core/geom/3d/tetrahedra/tetrahedron_compute_quality.d.ts +32 -0
  67. package/src/core/geom/3d/tetrahedra/tetrahedron_compute_quality.d.ts.map +1 -0
  68. package/src/core/geom/3d/tetrahedra/tetrahedron_compute_quality.js +66 -0
  69. package/src/core/geom/3d/topology/struct/binary/BinaryElementPool.d.ts +22 -0
  70. package/src/core/geom/3d/topology/struct/binary/BinaryElementPool.d.ts.map +1 -1
  71. package/src/core/geom/3d/topology/struct/binary/BinaryElementPool.js +49 -0
  72. package/src/core/geom/3d/topology/struct/binary/BinaryTopology.d.ts +134 -0
  73. package/src/core/geom/3d/topology/struct/binary/BinaryTopology.d.ts.map +1 -1
  74. package/src/core/geom/3d/topology/struct/binary/BinaryTopology.js +276 -3
  75. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_close_boundary_holes.d.ts +17 -0
  76. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_close_boundary_holes.d.ts.map +1 -0
  77. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_close_boundary_holes.js +135 -0
  78. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_compact.d.ts +14 -0
  79. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_compact.d.ts.map +1 -0
  80. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_compact.js +177 -0
  81. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_face_decouple.d.ts.map +1 -1
  82. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_face_decouple.js +20 -4
  83. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_simplify.d.ts.map +1 -1
  84. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_simplify.js +5 -3
  85. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_create.d.ts.map +1 -1
  86. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_create.js +9 -0
  87. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_get_or_create.d.ts.map +1 -1
  88. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_get_or_create.js +21 -45
  89. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_kill.d.ts.map +1 -1
  90. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_kill.js +7 -1
  91. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_kill_parallels.d.ts +8 -6
  92. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_kill_parallels.d.ts.map +1 -1
  93. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_kill_parallels.js +8 -6
  94. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_mesh_kill_short_edges.d.ts +22 -0
  95. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_mesh_kill_short_edges.d.ts.map +1 -0
  96. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_mesh_kill_short_edges.js +73 -0
  97. package/src/core/geom/3d/topology/struct/binary/io/vertex/bt_vertex_replace.d.ts.map +1 -1
  98. package/src/core/geom/3d/topology/struct/binary/io/vertex/bt_vertex_replace.js +51 -1
  99. package/src/core/geom/3d/topology/struct/binary/query/bt_edge_get.d.ts +10 -0
  100. package/src/core/geom/3d/topology/struct/binary/query/bt_edge_get.d.ts.map +1 -0
  101. package/src/core/geom/3d/topology/struct/binary/query/bt_edge_get.js +42 -0
  102. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_sample_interior_grid_points.d.ts +28 -0
  103. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_sample_interior_grid_points.d.ts.map +1 -0
  104. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_sample_interior_grid_points.js +227 -0
  105. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_walk_boundary_loops.d.ts +13 -0
  106. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_walk_boundary_loops.d.ts.map +1 -0
  107. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_walk_boundary_loops.js +108 -0
  108. package/src/core/geom/3d/topology/struct/binary/query/bt_query_edge_is_boundary.d.ts +11 -0
  109. package/src/core/geom/3d/topology/struct/binary/query/bt_query_edge_is_boundary.d.ts.map +1 -0
  110. package/src/core/geom/3d/topology/struct/binary/query/bt_query_edge_is_boundary.js +20 -0
  111. package/src/core/geom/3d/triangle/triangle_mesh_compute_signed_volume.d.ts +20 -0
  112. package/src/core/geom/3d/triangle/triangle_mesh_compute_signed_volume.d.ts.map +1 -0
  113. package/src/core/geom/3d/triangle/triangle_mesh_compute_signed_volume.js +38 -0
  114. package/src/core/graph/csr/CSRGraph.d.ts +168 -0
  115. package/src/core/graph/csr/CSRGraph.d.ts.map +1 -0
  116. package/src/core/graph/csr/CSRGraph.js +319 -0
  117. package/src/core/graph/metis/cluster_mesh_metis.d.ts +12 -0
  118. package/src/core/graph/metis/cluster_mesh_metis.d.ts.map +1 -1
  119. package/src/core/graph/metis/cluster_mesh_metis.js +12 -0
  120. package/src/core/graph/metis/metis.d.ts +19 -0
  121. package/src/core/graph/metis/metis.d.ts.map +1 -1
  122. package/src/core/graph/metis/metis.js +20 -0
  123. package/src/core/graph/metis/metis_cluster_bs.d.ts +11 -0
  124. package/src/core/graph/metis/metis_cluster_bs.d.ts.map +1 -1
  125. package/src/core/graph/metis/metis_cluster_bs.js +11 -0
  126. package/src/core/graph/metis/metis_options.d.ts +17 -2
  127. package/src/core/graph/metis/metis_options.d.ts.map +1 -1
  128. package/src/core/graph/metis/metis_options.js +17 -2
  129. package/src/core/graph/metis/native/MetisGraph.d.ts +144 -0
  130. package/src/core/graph/metis/native/MetisGraph.d.ts.map +1 -0
  131. package/src/core/graph/metis/native/MetisGraph.js +212 -0
  132. package/src/core/graph/metis/native/bisection/BisectionScratch.d.ts +72 -0
  133. package/src/core/graph/metis/native/bisection/BisectionScratch.d.ts.map +1 -0
  134. package/src/core/graph/metis/native/bisection/BisectionScratch.js +101 -0
  135. package/src/core/graph/metis/native/bisection/bisect_graph.d.ts +37 -0
  136. package/src/core/graph/metis/native/bisection/bisect_graph.d.ts.map +1 -0
  137. package/src/core/graph/metis/native/bisection/bisect_graph.js +100 -0
  138. package/src/core/graph/metis/native/bisection/compute_2way_params.d.ts +15 -0
  139. package/src/core/graph/metis/native/bisection/compute_2way_params.d.ts.map +1 -0
  140. package/src/core/graph/metis/native/bisection/compute_2way_params.js +84 -0
  141. package/src/core/graph/metis/native/bisection/fm_2way.d.ts +30 -0
  142. package/src/core/graph/metis/native/bisection/fm_2way.d.ts.map +1 -0
  143. package/src/core/graph/metis/native/bisection/fm_2way.js +290 -0
  144. package/src/core/graph/metis/native/bisection/grow_bisection.d.ts +23 -0
  145. package/src/core/graph/metis/native/bisection/grow_bisection.d.ts.map +1 -0
  146. package/src/core/graph/metis/native/bisection/grow_bisection.js +137 -0
  147. package/src/core/graph/metis/native/bisection/split_graph_two_way.d.ts +28 -0
  148. package/src/core/graph/metis/native/bisection/split_graph_two_way.d.ts.map +1 -0
  149. package/src/core/graph/metis/native/bisection/split_graph_two_way.js +119 -0
  150. package/src/core/graph/metis/native/coarsen/coarsen_graph.d.ts +20 -0
  151. package/src/core/graph/metis/native/coarsen/coarsen_graph.d.ts.map +1 -0
  152. package/src/core/graph/metis/native/coarsen/coarsen_graph.js +94 -0
  153. package/src/core/graph/metis/native/coarsen/create_coarse_graph.d.ts +24 -0
  154. package/src/core/graph/metis/native/coarsen/create_coarse_graph.d.ts.map +1 -0
  155. package/src/core/graph/metis/native/coarsen/create_coarse_graph.js +158 -0
  156. package/src/core/graph/metis/native/coarsen/match_shem.d.ts +41 -0
  157. package/src/core/graph/metis/native/coarsen/match_shem.d.ts.map +1 -0
  158. package/src/core/graph/metis/native/coarsen/match_shem.js +175 -0
  159. package/src/core/graph/metis/native/initial/initial_kway_bfs.d.ts +24 -0
  160. package/src/core/graph/metis/native/initial/initial_kway_bfs.d.ts.map +1 -0
  161. package/src/core/graph/metis/native/initial/initial_kway_bfs.js +122 -0
  162. package/src/core/graph/metis/native/initial/initial_kway_recursive_bisection.d.ts +29 -0
  163. package/src/core/graph/metis/native/initial/initial_kway_recursive_bisection.d.ts.map +1 -0
  164. package/src/core/graph/metis/native/initial/initial_kway_recursive_bisection.js +170 -0
  165. package/src/core/graph/metis/native/metis_partition_kway.d.ts +41 -0
  166. package/src/core/graph/metis/native/metis_partition_kway.d.ts.map +1 -0
  167. package/src/core/graph/metis/native/metis_partition_kway.js +126 -0
  168. package/src/core/graph/metis/native/refine/IndexedFloatMaxHeap.d.ts +62 -0
  169. package/src/core/graph/metis/native/refine/IndexedFloatMaxHeap.d.ts.map +1 -0
  170. package/src/core/graph/metis/native/refine/IndexedFloatMaxHeap.js +261 -0
  171. package/src/core/graph/metis/native/refine/RefinementScratch.d.ts +45 -0
  172. package/src/core/graph/metis/native/refine/RefinementScratch.d.ts.map +1 -0
  173. package/src/core/graph/metis/native/refine/RefinementScratch.js +53 -0
  174. package/src/core/graph/metis/native/refine/compute_kway_params.d.ts +18 -0
  175. package/src/core/graph/metis/native/refine/compute_kway_params.d.ts.map +1 -0
  176. package/src/core/graph/metis/native/refine/compute_kway_params.js +138 -0
  177. package/src/core/graph/metis/native/refine/fm_kway.d.ts +63 -0
  178. package/src/core/graph/metis/native/refine/fm_kway.d.ts.map +1 -0
  179. package/src/core/graph/metis/native/refine/fm_kway.js +462 -0
  180. package/src/core/graph/metis/native/refine/project_kway.d.ts +22 -0
  181. package/src/core/graph/metis/native/refine/project_kway.d.ts.map +1 -0
  182. package/src/core/graph/metis/native/refine/project_kway.js +43 -0
  183. package/src/core/graph/metis/native/refine/refine_kway.d.ts +34 -0
  184. package/src/core/graph/metis/native/refine/refine_kway.d.ts.map +1 -0
  185. package/src/core/graph/metis/native/refine/refine_kway.js +43 -0
  186. package/src/core/math/linalg/eigen/matrix_householder_in_place.d.ts +2 -2
  187. package/src/core/math/linalg/eigen/matrix_householder_in_place.js +2 -2
  188. package/src/core/math/linalg/eigen/matrix_qr_in_place.d.ts +6 -4
  189. package/src/core/math/linalg/eigen/matrix_qr_in_place.d.ts.map +1 -1
  190. package/src/core/math/linalg/eigen/matrix_qr_in_place.js +69 -23
  191. package/src/engine/EngineHarness.d.ts +3 -1
  192. package/src/engine/EngineHarness.d.ts.map +1 -1
  193. package/src/engine/EngineHarness.js +6 -4
  194. package/src/engine/control/first-person/DESIGN.md +30 -6
  195. package/src/engine/control/first-person/DESIGN_EXTENSIONS.md +563 -0
  196. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts +102 -9
  197. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts.map +1 -1
  198. package/src/engine/control/first-person/FirstPersonPlayerController.js +38 -3
  199. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts +533 -4
  200. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts.map +1 -1
  201. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.js +315 -6
  202. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts +220 -22
  203. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts.map +1 -1
  204. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js +858 -241
  205. package/src/engine/control/first-person/TODO.md +127 -0
  206. package/src/engine/control/first-person/abilities/Ability.d.ts +101 -0
  207. package/src/engine/control/first-person/abilities/Ability.d.ts.map +1 -0
  208. package/src/engine/control/first-person/abilities/Ability.js +119 -0
  209. package/src/engine/control/first-person/abilities/AbilitySet.d.ts +86 -0
  210. package/src/engine/control/first-person/abilities/AbilitySet.d.ts.map +1 -0
  211. package/src/engine/control/first-person/abilities/AbilitySet.js +185 -0
  212. package/src/engine/control/first-person/abilities/LedgeGrab.d.ts +62 -0
  213. package/src/engine/control/first-person/abilities/LedgeGrab.d.ts.map +1 -0
  214. package/src/engine/control/first-person/abilities/LedgeGrab.js +199 -0
  215. package/src/engine/control/first-person/abilities/Mantle.d.ts +45 -0
  216. package/src/engine/control/first-person/abilities/Mantle.d.ts.map +1 -0
  217. package/src/engine/control/first-person/abilities/Mantle.js +188 -0
  218. package/src/engine/control/first-person/abilities/Slide.d.ts +33 -0
  219. package/src/engine/control/first-person/abilities/Slide.d.ts.map +1 -0
  220. package/src/engine/control/first-person/abilities/Slide.js +158 -0
  221. package/src/engine/control/first-person/abilities/WallJump.d.ts +45 -0
  222. package/src/engine/control/first-person/abilities/WallJump.d.ts.map +1 -0
  223. package/src/engine/control/first-person/abilities/WallJump.js +131 -0
  224. package/src/engine/control/first-person/abilities/WallRun.d.ts +44 -0
  225. package/src/engine/control/first-person/abilities/WallRun.d.ts.map +1 -0
  226. package/src/engine/control/first-person/abilities/WallRun.js +180 -0
  227. package/src/engine/control/first-person/composer/EyeOffsetStack.d.ts +49 -0
  228. package/src/engine/control/first-person/composer/EyeOffsetStack.d.ts.map +1 -0
  229. package/src/engine/control/first-person/composer/EyeOffsetStack.js +60 -0
  230. package/src/engine/control/first-person/mastery/BreathRhythmEvaluator.d.ts +100 -0
  231. package/src/engine/control/first-person/mastery/BreathRhythmEvaluator.d.ts.map +1 -0
  232. package/src/engine/control/first-person/mastery/BreathRhythmEvaluator.js +133 -0
  233. package/src/engine/control/first-person/mastery/DecisionPoint.d.ts +10 -0
  234. package/src/engine/control/first-person/mastery/DecisionPoint.d.ts.map +1 -0
  235. package/src/engine/control/first-person/mastery/DecisionPoint.js +30 -0
  236. package/src/engine/control/first-person/mastery/FootAsymmetryTurnEvaluator.d.ts +61 -0
  237. package/src/engine/control/first-person/mastery/FootAsymmetryTurnEvaluator.d.ts.map +1 -0
  238. package/src/engine/control/first-person/mastery/FootAsymmetryTurnEvaluator.js +109 -0
  239. package/src/engine/control/first-person/mastery/MasteryEvaluator.d.ts +40 -0
  240. package/src/engine/control/first-person/mastery/MasteryEvaluator.d.ts.map +1 -0
  241. package/src/engine/control/first-person/mastery/MasteryEvaluator.js +45 -0
  242. package/src/engine/control/first-person/mastery/MasteryScore.d.ts +68 -0
  243. package/src/engine/control/first-person/mastery/MasteryScore.d.ts.map +1 -0
  244. package/src/engine/control/first-person/mastery/MasteryScore.js +100 -0
  245. package/src/engine/control/first-person/mastery/MasterySet.d.ts +60 -0
  246. package/src/engine/control/first-person/mastery/MasterySet.d.ts.map +1 -0
  247. package/src/engine/control/first-person/mastery/MasterySet.js +86 -0
  248. package/src/engine/control/first-person/mastery/SlideInitiationTimingEvaluator.d.ts +58 -0
  249. package/src/engine/control/first-person/mastery/SlideInitiationTimingEvaluator.d.ts.map +1 -0
  250. package/src/engine/control/first-person/mastery/SlideInitiationTimingEvaluator.js +83 -0
  251. package/src/engine/control/first-person/mastery/StrideTimingJumpEvaluator.d.ts +69 -0
  252. package/src/engine/control/first-person/mastery/StrideTimingJumpEvaluator.d.ts.map +1 -0
  253. package/src/engine/control/first-person/mastery/StrideTimingJumpEvaluator.js +109 -0
  254. package/src/engine/control/first-person/math/Spring.d.ts +56 -0
  255. package/src/engine/control/first-person/math/Spring.d.ts.map +1 -0
  256. package/src/engine/control/first-person/math/Spring.js +71 -0
  257. package/src/engine/control/first-person/math/computeLRCBreathRate.d.ts +26 -0
  258. package/src/engine/control/first-person/math/computeLRCBreathRate.d.ts.map +1 -0
  259. package/src/engine/control/first-person/math/computeLRCBreathRate.js +41 -0
  260. package/src/engine/control/first-person/math/computeMassRatios.d.ts +35 -0
  261. package/src/engine/control/first-person/math/computeMassRatios.d.ts.map +1 -0
  262. package/src/engine/control/first-person/math/computeMassRatios.js +44 -0
  263. package/src/engine/control/first-person/pose/FirstPersonPose.d.ts +31 -1
  264. package/src/engine/control/first-person/pose/FirstPersonPose.d.ts.map +1 -1
  265. package/src/engine/control/first-person/pose/FirstPersonPose.js +49 -3
  266. package/src/engine/control/first-person/pose/FirstPersonPosture.d.ts +7 -0
  267. package/src/engine/control/first-person/pose/FirstPersonPosture.d.ts.map +1 -0
  268. package/src/engine/control/first-person/pose/FirstPersonPosture.js +27 -0
  269. package/src/engine/control/first-person/prototype_first_person_controller.js +550 -119
  270. package/src/engine/control/first-person/sensors/FirstPersonSensors.d.ts +58 -0
  271. package/src/engine/control/first-person/sensors/FirstPersonSensors.d.ts.map +1 -0
  272. package/src/engine/control/first-person/sensors/FirstPersonSensors.js +77 -0
  273. package/src/engine/control/first-person/sensors/FirstPersonSensorsSystem.d.ts +80 -0
  274. package/src/engine/control/first-person/sensors/FirstPersonSensorsSystem.d.ts.map +1 -0
  275. package/src/engine/control/first-person/sensors/FirstPersonSensorsSystem.js +196 -0
  276. package/src/engine/control/first-person/test/buildTestPlayer.d.ts +20 -0
  277. package/src/engine/control/first-person/test/buildTestPlayer.d.ts.map +1 -0
  278. package/src/engine/control/first-person/test/buildTestPlayer.js +28 -0
  279. package/src/engine/ecs/EntityManager.d.ts +2 -2
  280. package/src/engine/ecs/EntityManager.d.ts.map +1 -1
  281. package/src/engine/ecs/EntityManager.js +13 -8
  282. package/src/engine/ecs/System.d.ts.map +1 -1
  283. package/src/engine/ecs/System.js +2 -2
  284. package/src/engine/graphics/camera/testClippingPlaneComputation.js +0 -2
  285. package/src/engine/graphics/ecs/light/Light.d.ts.map +1 -1
  286. package/src/engine/graphics/ecs/light/Light.js +27 -0
  287. package/src/engine/graphics/ecs/light/LightSystem.js +1 -1
  288. package/src/engine/graphics/ecs/path/PathDisplaySystem.d.ts.map +1 -1
  289. package/src/engine/graphics/ecs/path/testPathDisplaySystem.js +0 -2
  290. package/src/engine/graphics/ecs/path/tube/prototypeAnimatedPathMask.js +0 -2
  291. package/src/engine/graphics/render/buffer/buffers/prototypeNormalFrameBuffer.js +0 -2
  292. package/src/engine/graphics/render/forward_plus/plugin/ptototypeFPPlugin.js +0 -2
  293. package/src/engine/graphics/render/visibility/hiz/prototypeHiZ.js +0 -2
  294. package/src/engine/navigation/grid/find_path_on_grid_astar.d.ts.map +1 -1
  295. package/src/engine/navigation/grid/find_path_on_grid_astar.js +11 -2
  296. package/src/engine/navigation/mesh/bt_mesh_face_find_path.d.ts.map +1 -1
  297. package/src/engine/navigation/mesh/bt_mesh_face_find_path.js +11 -1
  298. package/src/engine/physics/PLAN.md +236 -0
  299. package/src/engine/physics/body/BodyStorage.d.ts +187 -0
  300. package/src/engine/physics/body/BodyStorage.d.ts.map +1 -0
  301. package/src/engine/physics/body/BodyStorage.js +427 -0
  302. package/src/engine/physics/broadphase/PairList.d.ts +62 -0
  303. package/src/engine/physics/broadphase/PairList.d.ts.map +1 -0
  304. package/src/engine/physics/broadphase/PairList.js +97 -0
  305. package/src/engine/physics/broadphase/aabb_transform_oriented.d.ts +30 -0
  306. package/src/engine/physics/broadphase/aabb_transform_oriented.d.ts.map +1 -0
  307. package/src/engine/physics/broadphase/aabb_transform_oriented.js +93 -0
  308. package/src/engine/physics/broadphase/compute_fat_world_aabb.d.ts +16 -0
  309. package/src/engine/physics/broadphase/compute_fat_world_aabb.d.ts.map +1 -0
  310. package/src/engine/physics/broadphase/compute_fat_world_aabb.js +61 -0
  311. package/src/engine/physics/broadphase/generate_pairs.d.ts +38 -0
  312. package/src/engine/physics/broadphase/generate_pairs.d.ts.map +1 -0
  313. package/src/engine/physics/broadphase/generate_pairs.js +101 -0
  314. package/src/engine/physics/contact/ManifoldStore.d.ts +226 -0
  315. package/src/engine/physics/contact/ManifoldStore.d.ts.map +1 -0
  316. package/src/engine/physics/contact/ManifoldStore.js +499 -0
  317. package/src/engine/physics/ecs/BodyKind.d.ts +23 -0
  318. package/src/engine/physics/ecs/BodyKind.d.ts.map +1 -0
  319. package/src/engine/physics/ecs/BodyKind.js +24 -0
  320. package/src/engine/physics/ecs/Collider.d.ts +98 -0
  321. package/src/engine/physics/ecs/Collider.d.ts.map +1 -0
  322. package/src/engine/physics/ecs/Collider.js +136 -0
  323. package/src/engine/physics/ecs/ColliderFlags.d.ts +14 -0
  324. package/src/engine/physics/ecs/ColliderFlags.d.ts.map +1 -0
  325. package/src/engine/physics/ecs/ColliderFlags.js +15 -0
  326. package/src/engine/physics/ecs/ColliderObserverSystem.d.ts +58 -0
  327. package/src/engine/physics/ecs/ColliderObserverSystem.d.ts.map +1 -0
  328. package/src/engine/physics/ecs/ColliderObserverSystem.js +103 -0
  329. package/src/engine/physics/ecs/ColliderSerializationAdapter.d.ts +25 -0
  330. package/src/engine/physics/ecs/ColliderSerializationAdapter.d.ts.map +1 -0
  331. package/src/engine/physics/ecs/ColliderSerializationAdapter.js +37 -0
  332. package/src/engine/physics/ecs/PhysicsEvents.d.ts +15 -0
  333. package/src/engine/physics/ecs/PhysicsEvents.d.ts.map +1 -0
  334. package/src/engine/physics/ecs/PhysicsEvents.js +16 -0
  335. package/src/engine/physics/ecs/PhysicsSystem.d.ts +520 -0
  336. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -0
  337. package/src/engine/physics/ecs/PhysicsSystem.js +1159 -0
  338. package/src/engine/physics/ecs/RigidBody.d.ts +197 -0
  339. package/src/engine/physics/ecs/RigidBody.d.ts.map +1 -0
  340. package/src/engine/physics/ecs/RigidBody.js +240 -0
  341. package/src/engine/physics/ecs/RigidBodyFlags.d.ts +21 -0
  342. package/src/engine/physics/ecs/RigidBodyFlags.d.ts.map +1 -0
  343. package/src/engine/physics/ecs/RigidBodyFlags.js +22 -0
  344. package/src/engine/physics/ecs/RigidBodySerializationAdapter.d.ts +28 -0
  345. package/src/engine/physics/ecs/RigidBodySerializationAdapter.d.ts.map +1 -0
  346. package/src/engine/physics/ecs/RigidBodySerializationAdapter.js +81 -0
  347. package/src/engine/physics/ecs/SleepState.d.ts +11 -0
  348. package/src/engine/physics/ecs/SleepState.d.ts.map +1 -0
  349. package/src/engine/physics/ecs/SleepState.js +12 -0
  350. package/src/engine/physics/events/ContactEventBuffer.d.ts +46 -0
  351. package/src/engine/physics/events/ContactEventBuffer.d.ts.map +1 -0
  352. package/src/engine/physics/events/ContactEventBuffer.js +83 -0
  353. package/src/engine/physics/events/diff_manifolds.d.ts +25 -0
  354. package/src/engine/physics/events/diff_manifolds.d.ts.map +1 -0
  355. package/src/engine/physics/events/diff_manifolds.js +50 -0
  356. package/src/engine/physics/fluid/FluidField.d.ts +294 -16
  357. package/src/engine/physics/fluid/FluidField.d.ts.map +1 -1
  358. package/src/engine/physics/fluid/FluidField.js +510 -66
  359. package/src/engine/physics/fluid/FluidSimulator.d.ts +188 -5
  360. package/src/engine/physics/fluid/FluidSimulator.d.ts.map +1 -1
  361. package/src/engine/physics/fluid/FluidSimulator.js +455 -95
  362. package/src/engine/physics/fluid/SliceVisualiser.d.ts +29 -6
  363. package/src/engine/physics/fluid/SliceVisualiser.d.ts.map +1 -1
  364. package/src/engine/physics/fluid/SliceVisualiser.js +190 -165
  365. package/src/engine/physics/fluid/ecs/FluidComponent.d.ts +154 -0
  366. package/src/engine/physics/fluid/ecs/FluidComponent.d.ts.map +1 -0
  367. package/src/engine/physics/fluid/ecs/FluidComponent.js +238 -0
  368. package/src/engine/physics/fluid/ecs/FluidEffectorsComponent.d.ts +45 -0
  369. package/src/engine/physics/fluid/ecs/FluidEffectorsComponent.d.ts.map +1 -0
  370. package/src/engine/physics/fluid/ecs/FluidEffectorsComponent.js +89 -0
  371. package/src/engine/physics/fluid/ecs/FluidSystem.d.ts +107 -0
  372. package/src/engine/physics/fluid/ecs/FluidSystem.d.ts.map +1 -0
  373. package/src/engine/physics/fluid/ecs/FluidSystem.js +278 -0
  374. package/src/engine/physics/fluid/effector/AbstractFluidEffector.d.ts +62 -1
  375. package/src/engine/physics/fluid/effector/AbstractFluidEffector.d.ts.map +1 -1
  376. package/src/engine/physics/fluid/effector/AbstractFluidEffector.js +81 -6
  377. package/src/engine/physics/fluid/effector/GlobalFluidEffector.d.ts +17 -4
  378. package/src/engine/physics/fluid/effector/GlobalFluidEffector.d.ts.map +1 -1
  379. package/src/engine/physics/fluid/effector/GlobalFluidEffector.js +105 -12
  380. package/src/engine/physics/fluid/effector/ImpulseFluidEffector.d.ts +43 -0
  381. package/src/engine/physics/fluid/effector/ImpulseFluidEffector.d.ts.map +1 -0
  382. package/src/engine/physics/fluid/effector/ImpulseFluidEffector.js +210 -0
  383. package/src/engine/physics/fluid/effector/WakeFluidEffector.d.ts +62 -1
  384. package/src/engine/physics/fluid/effector/WakeFluidEffector.d.ts.map +1 -1
  385. package/src/engine/physics/fluid/effector/WakeFluidEffector.js +302 -8
  386. package/src/engine/physics/fluid/prototype.js +102 -91
  387. package/src/engine/physics/fluid/solver/optimal_sor_omega.d.ts +33 -0
  388. package/src/engine/physics/fluid/solver/optimal_sor_omega.d.ts.map +1 -0
  389. package/src/engine/physics/fluid/solver/optimal_sor_omega.js +41 -0
  390. package/src/engine/physics/fluid/solver/v3_grid_apply_advection_forward.d.ts +20 -5
  391. package/src/engine/physics/fluid/solver/v3_grid_apply_advection_forward.d.ts.map +1 -1
  392. package/src/engine/physics/fluid/solver/v3_grid_apply_advection_forward.js +60 -38
  393. package/src/engine/physics/fluid/solver/v3_grid_apply_diffusion.d.ts +25 -4
  394. package/src/engine/physics/fluid/solver/v3_grid_apply_diffusion.d.ts.map +1 -1
  395. package/src/engine/physics/fluid/solver/v3_grid_apply_diffusion.js +93 -73
  396. package/src/engine/physics/fluid/solver/v3_grid_apply_scalar_advection.d.ts +23 -0
  397. package/src/engine/physics/fluid/solver/v3_grid_apply_scalar_advection.d.ts.map +1 -0
  398. package/src/engine/physics/fluid/solver/v3_grid_apply_scalar_advection.js +60 -0
  399. package/src/engine/physics/fluid/solver/v3_grid_compute_divergence.d.ts +23 -0
  400. package/src/engine/physics/fluid/solver/v3_grid_compute_divergence.d.ts.map +1 -0
  401. package/src/engine/physics/fluid/solver/v3_grid_compute_divergence.js +68 -0
  402. package/src/engine/physics/fluid/solver/v3_grid_compute_solid_neighbour_mask.d.ts +30 -0
  403. package/src/engine/physics/fluid/solver/v3_grid_compute_solid_neighbour_mask.d.ts.map +1 -0
  404. package/src/engine/physics/fluid/solver/v3_grid_compute_solid_neighbour_mask.js +66 -0
  405. package/src/engine/physics/fluid/solver/v3_grid_patch_edges_uniform.d.ts +26 -0
  406. package/src/engine/physics/fluid/solver/v3_grid_patch_edges_uniform.d.ts.map +1 -0
  407. package/src/engine/physics/fluid/solver/v3_grid_patch_edges_uniform.js +113 -0
  408. package/src/engine/physics/fluid/solver/v3_grid_shift_in_place.d.ts +30 -0
  409. package/src/engine/physics/fluid/solver/v3_grid_shift_in_place.d.ts.map +1 -0
  410. package/src/engine/physics/fluid/solver/v3_grid_shift_in_place.js +107 -0
  411. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure.d.ts +49 -0
  412. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure.d.ts.map +1 -0
  413. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure.js +126 -0
  414. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_pcg.d.ts +93 -0
  415. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_pcg.d.ts.map +1 -0
  416. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_pcg.js +424 -0
  417. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_unmasked_legacy.d.ts +20 -0
  418. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_unmasked_legacy.d.ts.map +1 -0
  419. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_unmasked_legacy.js +83 -0
  420. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.d.ts +26 -0
  421. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.d.ts.map +1 -0
  422. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.js +70 -0
  423. package/src/engine/physics/gjk/expanding_polytope_algorithm.d.ts.map +1 -1
  424. package/src/engine/physics/gjk/expanding_polytope_algorithm.js +8 -10
  425. package/src/engine/physics/inertia/world_inverse_inertia.d.ts +29 -0
  426. package/src/engine/physics/inertia/world_inverse_inertia.d.ts.map +1 -0
  427. package/src/engine/physics/inertia/world_inverse_inertia.js +79 -0
  428. package/src/engine/physics/integration/integrate_position.d.ts +16 -0
  429. package/src/engine/physics/integration/integrate_position.d.ts.map +1 -0
  430. package/src/engine/physics/integration/integrate_position.js +48 -0
  431. package/src/engine/physics/integration/integrate_velocity.d.ts +25 -0
  432. package/src/engine/physics/integration/integrate_velocity.d.ts.map +1 -0
  433. package/src/engine/physics/integration/integrate_velocity.js +79 -0
  434. package/src/engine/physics/integration/quat_integrate.d.ts +27 -0
  435. package/src/engine/physics/integration/quat_integrate.d.ts.map +1 -0
  436. package/src/engine/physics/integration/quat_integrate.js +62 -0
  437. package/src/engine/physics/island/IslandBuilder.d.ts +167 -0
  438. package/src/engine/physics/island/IslandBuilder.d.ts.map +1 -0
  439. package/src/engine/physics/island/IslandBuilder.js +411 -0
  440. package/src/engine/physics/island/union_find.d.ts +51 -0
  441. package/src/engine/physics/island/union_find.d.ts.map +1 -0
  442. package/src/engine/physics/island/union_find.js +76 -0
  443. package/src/engine/physics/narrowphase/PosedShape.d.ts +59 -0
  444. package/src/engine/physics/narrowphase/PosedShape.d.ts.map +1 -0
  445. package/src/engine/physics/narrowphase/PosedShape.js +110 -0
  446. package/src/engine/physics/narrowphase/box_box_manifold.d.ts +32 -0
  447. package/src/engine/physics/narrowphase/box_box_manifold.d.ts.map +1 -0
  448. package/src/engine/physics/narrowphase/box_box_manifold.js +543 -0
  449. package/src/engine/physics/narrowphase/capsule_contacts.d.ts +122 -0
  450. package/src/engine/physics/narrowphase/capsule_contacts.d.ts.map +1 -0
  451. package/src/engine/physics/narrowphase/capsule_contacts.js +508 -0
  452. package/src/engine/physics/narrowphase/narrowphase_step.d.ts +11 -0
  453. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -0
  454. package/src/engine/physics/narrowphase/narrowphase_step.js +382 -0
  455. package/src/engine/physics/narrowphase/sphere_box_contact.d.ts +38 -0
  456. package/src/engine/physics/narrowphase/sphere_box_contact.d.ts.map +1 -0
  457. package/src/engine/physics/narrowphase/sphere_box_contact.js +130 -0
  458. package/src/engine/physics/narrowphase/sphere_sphere_contact.d.ts +26 -0
  459. package/src/engine/physics/narrowphase/sphere_sphere_contact.d.ts.map +1 -0
  460. package/src/engine/physics/narrowphase/sphere_sphere_contact.js +51 -0
  461. package/src/engine/physics/queries/PhysicsSurfacePoint.d.ts +83 -0
  462. package/src/engine/physics/queries/PhysicsSurfacePoint.d.ts.map +1 -0
  463. package/src/engine/physics/queries/PhysicsSurfacePoint.js +100 -0
  464. package/src/engine/physics/queries/raycast.d.ts +20 -0
  465. package/src/engine/physics/queries/raycast.d.ts.map +1 -0
  466. package/src/engine/physics/queries/raycast.js +249 -0
  467. package/src/engine/physics/solver/friction_cone.d.ts +16 -0
  468. package/src/engine/physics/solver/friction_cone.d.ts.map +1 -0
  469. package/src/engine/physics/solver/friction_cone.js +37 -0
  470. package/src/engine/physics/solver/solve_contacts.d.ts +36 -0
  471. package/src/engine/physics/solver/solve_contacts.d.ts.map +1 -0
  472. package/src/engine/physics/solver/solve_contacts.js +598 -0
  473. package/src/core/geom/3d/topology/struct/binary/io/edge/OrderedEdge.d.ts +0 -34
  474. package/src/core/geom/3d/topology/struct/binary/io/edge/OrderedEdge.d.ts.map +0 -1
  475. package/src/core/geom/3d/topology/struct/binary/io/edge/OrderedEdge.js +0 -66
  476. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_mesh_calc_edges.d.ts +0 -2
  477. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_mesh_calc_edges.d.ts.map +0 -1
  478. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_mesh_calc_edges.js +0 -54
  479. package/src/core/geom/3d/topology/struct/binary/io/edge/get_or_create_edge_map.d.ts +0 -2
  480. package/src/core/geom/3d/topology/struct/binary/io/edge/get_or_create_edge_map.d.ts.map +0 -1
  481. package/src/core/geom/3d/topology/struct/binary/io/edge/get_or_create_edge_map.js +0 -26
  482. package/src/engine/ecs/components/Motion.d.ts +0 -21
  483. package/src/engine/ecs/components/Motion.d.ts.map +0 -1
  484. package/src/engine/ecs/components/Motion.js +0 -27
  485. package/src/engine/ecs/components/MotionSerializationAdapter.d.ts +0 -20
  486. package/src/engine/ecs/components/MotionSerializationAdapter.d.ts.map +0 -1
  487. package/src/engine/ecs/components/MotionSerializationAdapter.js +0 -26
  488. package/src/engine/ecs/systems/MotionSystem.d.ts +0 -9
  489. package/src/engine/ecs/systems/MotionSystem.d.ts.map +0 -1
  490. package/src/engine/ecs/systems/MotionSystem.js +0 -29
  491. package/src/engine/physics/fluid/Fluid.d.ts +0 -26
  492. package/src/engine/physics/fluid/Fluid.d.ts.map +0 -1
  493. package/src/engine/physics/fluid/Fluid.js +0 -221
  494. package/src/engine/physics/fluid/solver/v3_grid_apply_advection_reverse.d.ts +0 -7
  495. package/src/engine/physics/fluid/solver/v3_grid_apply_advection_reverse.d.ts.map +0 -1
  496. package/src/engine/physics/fluid/solver/v3_grid_apply_advection_reverse.js +0 -8
@@ -1,3 +1,4 @@
1
+ import { assert } from "../../../core/assert.js";
1
2
  import Quaternion from "../../../core/geom/Quaternion.js";
2
3
  import Vector3 from "../../../core/geom/Vector3.js";
3
4
  import { clamp } from "../../../core/math/clamp.js";
@@ -10,12 +11,19 @@ import Entity from "../../ecs/Entity.js";
10
11
  import { System } from "../../ecs/System.js";
11
12
  import { Transform } from "../../ecs/transform/Transform.js";
12
13
  import { Camera } from "../../graphics/ecs/camera/Camera.js";
14
+ import { EyeOffsetStack } from "./composer/EyeOffsetStack.js";
15
+ import { BodyKind } from "../../physics/ecs/BodyKind.js";
16
+ import { RigidBody } from "../../physics/ecs/RigidBody.js";
13
17
  import { FirstPersonPlayerController } from "./FirstPersonPlayerController.js";
18
+ import { DecisionPoint } from "./mastery/DecisionPoint.js";
14
19
  import { computeJumpFromApex } from "./math/computeJumpFromApex.js";
15
- import { criticallyDampedSpringStep } from "./math/criticallyDampedSpring.js";
16
- import { dampedSpringStep } from "./math/dampedSpringStep.js";
20
+ import { computeLRCBreathRate } from "./math/computeLRCBreathRate.js";
21
+ import { computeMassRatios } from "./math/computeMassRatios.js";
22
+ import { Spring } from "./math/Spring.js";
17
23
  import { stepTowards } from "./math/stepTowards.js";
18
- import { FirstPersonActionState } from "./pose/FirstPersonPose.js";
24
+ import { FirstPersonActionState, FirstPersonLocomotionMode } from "./pose/FirstPersonPose.js";
25
+ import { FirstPersonPosture } from "./pose/FirstPersonPosture.js";
26
+ import { FirstPersonSensors } from "./sensors/FirstPersonSensors.js";
19
27
 
20
28
  // ---------------------------------------------------------------------------
21
29
  // Scratch allocations — reused per frame to avoid GC pressure
@@ -38,10 +46,22 @@ const LN2 = Math.log(2);
38
46
  */
39
47
  class PerEntityRuntime {
40
48
  constructor() {
49
+ /**
50
+ * Co-attached kinematic body. Set by {@link FirstPersonPlayerControllerSystem.link}
51
+ * after asserting it's present. The controller writes Transform.position
52
+ * directly (existing motion logic); physics derives the body's velocity
53
+ * from the per-step delta. Other physics systems (raycasts, contact
54
+ * events) see the player through this body.
55
+ * @type {RigidBody|null}
56
+ */
57
+ this.rigidBody = null;
58
+
41
59
  /** Eye pitch in radians, clamped to config.look limits. */
42
60
  this.eyePitch = 0;
43
61
  /** Body yaw in radians (around world up). */
44
62
  this.bodyYaw = 0;
63
+ /** Yaw rate (rad/s) computed in look consumption — for evaluators. */
64
+ this.yawRateRadPerSec = 0;
45
65
 
46
66
  /** Horizontal+vertical velocity. We integrate these inside the system
47
67
  * when no external physics layer is attached. */
@@ -60,17 +80,32 @@ class PerEntityRuntime {
60
80
  this.anticipationRemaining = 0;
61
81
  /** Cached derived gravity (m/s^2) from peakHeight + timeToApex. */
62
82
  this.gravity = 9.81;
63
- /** Cached derived jump impulse (m/s upward). */
83
+ /** Cached derived jump impulse (m/s upward), post-mass-scaling. */
64
84
  this.jumpInitialVy = 5.0;
65
-
66
- /** Spring state for landing dip (mutated in place). */
67
- this.landSpring = { value: 0, velocity: 0 };
68
- /** Spring state for FOV. */
69
- this.fovSpring = { value: 70, velocity: 0 };
70
- /** Spring state for eye height (crouch). */
71
- this.eyeHeightSpring = { value: 1.80, velocity: 0 };
72
- /** Spring state for lean roll (radians). */
73
- this.leanSpring = { value: 0, velocity: 0 };
85
+ /**
86
+ * Cached mass scaling factors computed once at link. See
87
+ * {@link computeMassRatios}. Heavier lower jumpV0Scale, lower
88
+ * groundAccelScale, higher landingDipScale + exertionRiseScale.
89
+ */
90
+ this.massRatios = null;
91
+
92
+ /** Spring for landing dip (under-damped → rings after impact). */
93
+ this.landSpring = new Spring();
94
+ /** Spring for FOV (critically damped). */
95
+ this.fovSpring = new Spring(70);
96
+ /** Spring for eye height (crouch transition). */
97
+ this.eyeHeightSpring = new Spring(1.80);
98
+ /** Spring for lean roll (radians) — banks into lateral acceleration. */
99
+ this.leanSpring = new Spring();
100
+ /**
101
+ * Lean target this tick (radians). Always set; L2.f spring-steps
102
+ * toward this value. Whoever owned motion this tick wrote it:
103
+ * base writes the lat-accel + look-lean derived value at the end
104
+ * of {@link _runBaseLocomotion}; abilities that want to override
105
+ * (WallRun → tilt-into-wall, Slide/Mantle/LedgeGrab → zero) write
106
+ * their own value in tick. Uniform channel — no null sentinel.
107
+ */
108
+ this.leanTargetRad = 0;
74
109
 
75
110
  /** Previous horizontal velocity — for lateral acceleration → lean. */
76
111
  this.prevVelocityX = 0;
@@ -93,6 +128,94 @@ class PerEntityRuntime {
93
128
  this.prevBreathPhase = 0;
94
129
  /** Which foot fires next — flipped on each footstep signal. */
95
130
  this.nextFootSide = "R";
131
+ /**
132
+ * Which foot is currently bearing the body's weight (the foot that
133
+ * most recently landed). Drives the lateral-bob direction: at R
134
+ * midstance the COM is over the right foot, so the head shifts
135
+ * laterally toward screen-right; at L midstance the opposite.
136
+ * Coupled to the same signal the footstep emits, so anything that
137
+ * listens to onFootStep.side will see the bob agree.
138
+ * Initialized "L" so the very first footstep fires "R" and the
139
+ * standingFoot updates to "R" — putting the head laterally right
140
+ * during the first half-stride, as expected.
141
+ */
142
+ this.standingFoot = "L";
143
+
144
+ /**
145
+ * [0..1] How "backward" the player is currently moving. Derived in
146
+ * fixedUpdate from velocity · screen-forward, normalized to sprint
147
+ * speed. Drives the gait wobble amplifier on the L3 camera-composition
148
+ * pass. Stored on runtime (rather than state) because it's a render-
149
+ * side input — downstream observers should look at velocity directly.
150
+ */
151
+ this.backwardness = 0;
152
+
153
+ /**
154
+ * Smoothed bob amplitude envelope. Target = max(speedNormalized,
155
+ * backwardness) when grounded, 0 airborne. Spring decay prevents
156
+ * the whiplash where stopping motion would snap the bob to neutral.
157
+ */
158
+ this.bobIntensitySpring = new Spring();
159
+
160
+ /**
161
+ * Vertical impact spring — kicked downward at each footfall, decays
162
+ * with a slight under-damped overshoot. Produces the impact-arrest +
163
+ * leg-push curve. value units: meters (added directly to eyeLocal.y).
164
+ */
165
+ this.verticalImpactSpring = new Spring();
166
+
167
+ /**
168
+ * Sprint-posture spring — eye pitches forward as the player commits
169
+ * to a sprint, returns to neutral when they slow. Value is in
170
+ * radians; slower half-life than other springs so it feels like
171
+ * a posture change rather than an input twitch. See cfg.posture.
172
+ */
173
+ this.sprintPostureSpring = new Spring();
174
+
175
+ /**
176
+ * Head-droop spring — additional forward pitch as exertion rises.
177
+ * Sells fatigue subtly. Target tracks exertion-driven max droop
178
+ * angle; spring lag keeps the transition slow and physical.
179
+ */
180
+ this.headDroopSpring = new Spring();
181
+
182
+ /**
183
+ * [0..1] sprintness — how much of the walk→sprint speed range the
184
+ * body is currently in. Computed in fixedUpdate, read by L3 for FOV
185
+ * and the sprint-posture pitch / forward-shift offset.
186
+ */
187
+ this.sprintness = 0;
188
+
189
+ /**
190
+ * Cached sin/cos of current body yaw — written once per fixedUpdate
191
+ * after look intent is consumed, read by every downstream step
192
+ * (locomotion, backwardness, lean look-rate, pose channels). Avoids
193
+ * recomputing the trig 3+ times per tick.
194
+ */
195
+ this.sinYaw = 0;
196
+ this.cosYaw = 1;
197
+
198
+ /** Cached horizontal speed (m/s) for this tick — written in derived-state. */
199
+ this.horizSpeed = 0;
200
+
201
+ /** Cached stride frequency (Hz) for this tick — written in breath block, read by stride. */
202
+ this.strideFreqHz = 0;
203
+
204
+ /**
205
+ * Additive accumulator for body-local eye-position offsets. The
206
+ * system pushes its own contributions (bob, breath, landing,
207
+ * sprint posture) each render frame; external systems can push
208
+ * recoil/shake/knockback contributions via the same interface.
209
+ */
210
+ this.eyeOffsetStack = new EyeOffsetStack();
211
+
212
+ /**
213
+ * Spatial-query results populated by {@link FirstPersonSensorsSystem}
214
+ * (when present). Abilities and the locomotion FSM read this.
215
+ * Lives on runtime so other systems can populate it without
216
+ * touching the controller component's public surface.
217
+ */
218
+ this.sensors = new FirstPersonSensors();
96
219
 
97
220
  /** Cached eye entity ID. -1 until link assigns it. */
98
221
  this.eyeEntity = -1;
@@ -121,11 +244,18 @@ export class FirstPersonPlayerControllerSystem extends System {
121
244
  constructor() {
122
245
  super();
123
246
 
247
+ // Dependencies kept to (controller, transform) so we can ASSERT on
248
+ // RigidBody at link time and emit a clear error if missing. If
249
+ // RigidBody were a hard dep, entities lacking one would silently
250
+ // never link — the controller would appear inert with no
251
+ // diagnostic. The assert below catches the missing-body case
252
+ // explicitly.
124
253
  this.dependencies = [FirstPersonPlayerController, Transform];
125
254
 
126
255
  this.components_used = [
127
256
  ResourceAccessSpecification.from(Transform, ResourceAccessKind.Write),
128
257
  ResourceAccessSpecification.from(Camera, ResourceAccessKind.Write),
258
+ ResourceAccessSpecification.from(RigidBody, ResourceAccessKind.Write),
129
259
  ];
130
260
 
131
261
  /**
@@ -147,6 +277,25 @@ export class FirstPersonPlayerControllerSystem extends System {
147
277
  * @type {number}
148
278
  */
149
279
  this.groundY = 0;
280
+
281
+ /**
282
+ * Optional callback that returns the surface Y under the player
283
+ * for ground resolution. Called each tick with the player's
284
+ * current (x, y, z); returns the world-Y of the ground below,
285
+ * or null if no ground is below (gap / void).
286
+ *
287
+ * Combines with `useBuiltInFlatGround`: the effective ground for
288
+ * the tick is `max(this.groundY when enabled, resolver(...))`.
289
+ * Set both off (`useBuiltInFlatGround=false`, `groundResolver=null`)
290
+ * to defer to external physics entirely.
291
+ *
292
+ * Designed for prototypes / gyms that need elevated platforms
293
+ * without a full physics layer. Production should wire a real
294
+ * physics system instead.
295
+ *
296
+ * @type {((x:number, y:number, z:number) => number|null) | null}
297
+ */
298
+ this.groundResolver = null;
150
299
  }
151
300
 
152
301
  /**
@@ -157,14 +306,38 @@ export class FirstPersonPlayerControllerSystem extends System {
157
306
  link(controller, bodyTransform, entity) {
158
307
  const ecd = this.entityManager.dataset;
159
308
 
309
+ // The controller assumes a kinematic-position RigidBody is co-
310
+ // attached on this entity. The body is the spatial proxy used
311
+ // for sensor raycasts and physics-side observers (other entities
312
+ // raycasting against the player, dynamic bodies colliding with
313
+ // the capsule, etc.). The controller writes Transform directly,
314
+ // physics derives velocity from the per-step delta. If a body is
315
+ // missing the controller could still drive the camera, but the
316
+ // physics integration silently breaks — assert here so the
317
+ // misconfiguration is caught at link time.
318
+ const rigidBody = ecd.getComponent(entity, RigidBody);
319
+ assert.ok(rigidBody !== undefined,
320
+ "FirstPersonPlayerController entity must have a co-attached RigidBody "
321
+ + "(kinematic capsule). See prototype_first_person_controller.js for setup.");
322
+ assert.equal(rigidBody.kind, BodyKind.KinematicPosition,
323
+ "FirstPersonPlayerController RigidBody must be BodyKind.KinematicPosition; "
324
+ + "the controller owns the Transform and physics derives velocity.");
325
+
160
326
  const runtime = new PerEntityRuntime();
327
+ runtime.rigidBody = rigidBody;
161
328
  this.runtime.set(entity, runtime);
162
329
 
163
- // Derive gravity + jump impulse from designer-friendly params
330
+ // Derive gravity + jump impulse from designer-friendly params, then
331
+ // mass-scale the initial velocity (heavier ⇒ lower jump).
332
+ runtime.massRatios = computeMassRatios(
333
+ controller.config.body.mass,
334
+ controller.config.body.referenceMass,
335
+ controller.config.body.massCouplingStrength,
336
+ );
164
337
  const derived = { gravity: 0, initialVelocity: 0 };
165
338
  computeJumpFromApex(controller.config.jump.peakHeight, controller.config.jump.timeToApex, derived);
166
339
  runtime.gravity = derived.gravity;
167
- runtime.jumpInitialVy = derived.initialVelocity;
340
+ runtime.jumpInitialVy = derived.initialVelocity * runtime.massRatios.jumpV0Scale;
168
341
 
169
342
  // Seed yaw from the starting body rotation. `toEulerAnglesYXZ`
170
343
  // returns (pitch, yaw, roll) — we only care about y.
@@ -173,8 +346,8 @@ export class FirstPersonPlayerControllerSystem extends System {
173
346
  runtime.eyePitch = 0;
174
347
 
175
348
  // Initialize springs to standing-eye-height baseline
176
- runtime.eyeHeightSpring.value = controller.config.body.height;
177
- runtime.fovSpring.value = controller.config.fov.base;
349
+ runtime.eyeHeightSpring.settle(controller.config.body.height);
350
+ runtime.fovSpring.settle(controller.config.fov.base);
178
351
  controller.state.eyeHeight = controller.config.body.height;
179
352
 
180
353
  // Create eye entity if one wasn't supplied
@@ -221,6 +394,19 @@ export class FirstPersonPlayerControllerSystem extends System {
221
394
  this.runtime.delete(entity);
222
395
  }
223
396
 
397
+ /**
398
+ * Look up the per-entity runtime for an entity that has this
399
+ * controller. Used by cross-system code (sensors system, future
400
+ * ability-driven systems) to reach internal state without leaking
401
+ * it onto the controller component itself.
402
+ *
403
+ * @param {number} entity
404
+ * @returns {PerEntityRuntime|undefined} undefined if entity is not linked
405
+ */
406
+ getRuntime(entity) {
407
+ return this.runtime.get(entity);
408
+ }
409
+
224
410
  /**
225
411
  * Deterministic simulation step — L1 + L2 + L4.
226
412
  * @param {number} dt
@@ -264,6 +450,12 @@ export class FirstPersonPlayerControllerSystem extends System {
264
450
  const bodyTransform = ecd.getComponent(entity, Transform);
265
451
  if (bodyTransform === undefined) return;
266
452
 
453
+ // Decay the mastery score's EMA. Doing this once per tick keeps the
454
+ // score's time-window characteristic stable regardless of how many
455
+ // evaluators fire (they each *record* a sample, the decay
456
+ // independently ages all samples).
457
+ controller.mastery.tick(dt);
458
+
267
459
  // -- L1.a: Consume look delta -----------------------------------
268
460
  // intent.look is zeroed after consume so accumulated input doesn't
269
461
  // re-apply on the next fixed step.
@@ -283,6 +475,11 @@ export class FirstPersonPlayerControllerSystem extends System {
283
475
  const pitchDelta = intent.look.y * pitchSign;
284
476
  intent.look.set(0, 0);
285
477
 
478
+ // Cache yaw rate for mastery evaluators (look-lean, foot-asymmetry-
479
+ // turn, etc.). Rad/s, signed (negative = turning right in our
480
+ // convention — matches yawDelta).
481
+ runtime.yawRateRadPerSec = yawDelta / Math.max(dt, 1e-4);
482
+
286
483
  runtime.bodyYaw += yawDelta;
287
484
  // keep yaw bounded (purely cosmetic — sin/cos handle wraparound fine)
288
485
  if (runtime.bodyYaw > Math.PI) runtime.bodyYaw -= TWO_PI;
@@ -297,68 +494,311 @@ export class FirstPersonPlayerControllerSystem extends System {
297
494
  // Write body yaw back to transform (pure yaw, no pitch on body)
298
495
  bodyTransform.rotation.fromAxisAngle(Vector3.up, runtime.bodyYaw);
299
496
 
300
- // -- L1.b: Speed selection --------------------------------------
497
+ // -- Shared flags. Computed BEFORE the ability tick so abilities
498
+ // can read them. `isCrouchActive` is deliberately computed
499
+ // AFTER the ability tick because `_resolveCrouchHeld` mutates
500
+ // `runtime.prevCrouchHeld` — abilities like Slide need to see
501
+ // the previous-tick value to detect a rising edge on the
502
+ // crouch press.
301
503
  const isSprintIntent = intent.sprint && intent.move.y > 0.5 && state.grounded;
504
+ const isBackwardIntent = intent.move.y < 0;
505
+ runtime.sinYaw = Math.sin(runtime.bodyYaw);
506
+ runtime.cosYaw = Math.cos(runtime.bodyYaw);
507
+ // L2 observers read sinYaw/cosYaw as locals — destructure once.
508
+ const { sinYaw, cosYaw } = runtime;
509
+
510
+ // -- Ability layer: at most one active ability owns motion. The
511
+ // set returns true when no ability owned the tick, in which
512
+ // case base L1.b-h runs below; false means an ability fully
513
+ // handled this tick (it called the system's helpers for any
514
+ // standard work it wanted to keep, e.g. gravity).
515
+ const runBaseLocomotion = controller.abilities.tick(
516
+ controller, runtime, bodyTransform, runtime.sensors, dt, this,
517
+ );
518
+
519
+ // Now resolve crouch (updates prevCrouchHeld) — used by base and L2.
302
520
  const isCrouchActive = this._resolveCrouchHeld(controller, runtime);
303
521
 
304
- let targetSpeed;
522
+ if (runBaseLocomotion) {
523
+ this._runBaseLocomotion(
524
+ controller, runtime, bodyTransform, dt,
525
+ isCrouchActive, isSprintIntent, isBackwardIntent,
526
+ );
527
+ }
528
+
529
+ // (everything below this line runs every tick — L2 observers don't
530
+ // care who owned motion)
531
+
532
+ // -- L2.a: speed / moveMode ------------------------------------
533
+ // -- L2.a: speed / moveMode ------------------------------------
534
+ const horizSpeed = Math.hypot(runtime.velocityX, runtime.velocityZ);
535
+ runtime.horizSpeed = horizSpeed;
536
+ state.speed = horizSpeed;
537
+ state.speedNormalized = clamp(horizSpeed / Math.max(cfg.motion.sprintSpeed, 1e-3), 0, 1);
538
+
539
+ // Backwardness: 0 = moving forward (or sideways), 1 = moving directly
540
+ // backward at the back-pedal speed ceiling. Derived from the actual
541
+ // velocity (not the intent) so external knockback or stuck states
542
+ // also register as "moving backward" and the gait wobble reflects it.
543
+ //
544
+ // Reference speed is the *achievable* backward max — walkSpeed ×
545
+ // backwardSpeedFactor — NOT the sprint speed. Backward can never
546
+ // reach sprint, so normalizing against sprint would cap backwardness
547
+ // at ~0.3 and the wobble multipliers below would barely apply.
548
+ const screenFwdVel = runtime.velocityX * sinYaw + runtime.velocityZ * cosYaw;
549
+ const maxBackwardSpeed = Math.max(cfg.motion.walkSpeed * cfg.motion.backwardSpeedFactor, 1e-3);
550
+ runtime.backwardness = clamp(-screenFwdVel / maxBackwardSpeed, 0, 1);
551
+
552
+ // Locomotion mode is the *intent-driven* horizontal mode. Airborne
553
+ // state is tracked separately on pose.actionState — they're
554
+ // orthogonal facets (you can be Sprint+Airborne after a jump).
555
+ const prevLocomotionMode = state.locomotionMode;
305
556
  if (isCrouchActive) {
306
- targetSpeed = cfg.motion.crouchSpeed;
307
- } else if (isSprintIntent) {
308
- targetSpeed = cfg.motion.sprintSpeed;
557
+ state.locomotionMode = FirstPersonLocomotionMode.Crouch;
558
+ } else if (isSprintIntent && horizSpeed > 0.1) {
559
+ state.locomotionMode = FirstPersonLocomotionMode.Sprint;
560
+ } else if (horizSpeed > 0.1) {
561
+ state.locomotionMode = FirstPersonLocomotionMode.Walk;
309
562
  } else {
310
- targetSpeed = cfg.motion.walkSpeed;
563
+ state.locomotionMode = FirstPersonLocomotionMode.Idle;
311
564
  }
312
565
 
313
- // -- L1.c: Move intent → desired horizontal velocity -----------
314
- //
315
- // Player-perceived axes at yaw=0 (looking +Z through the engine's
316
- // inverted-camera presentation):
317
- // forward (W) +Z velocity
318
- // right (D) → -X velocity (camera presents -X as screen-right)
319
- //
320
- // In closed form for arbitrary yaw, screen-forward and screen-right
321
- // are 90° apart with screen-right being LEFT of screen-forward
322
- // (the inversion is purely a consequence of camera-side coordinate
323
- // handling — non-camera entities use the usual right = +X).
566
+ if (state.locomotionMode === FirstPersonLocomotionMode.Sprint
567
+ && prevLocomotionMode !== FirstPersonLocomotionMode.Sprint) {
568
+ sig.onSprintStart.send0();
569
+ } else if (prevLocomotionMode === FirstPersonLocomotionMode.Sprint
570
+ && state.locomotionMode !== FirstPersonLocomotionMode.Sprint) {
571
+ sig.onSprintStop.send0();
572
+ }
573
+
574
+ // -- L2.b: Exertion --------------------------------------------
575
+ // Heavier bodies tire faster sprint rise scales with massRatios.exertionRiseScale.
576
+ const exertionRise = isSprintIntent
577
+ ? cfg.exertion.sprintRiseRate * runtime.massRatios.exertionRiseScale
578
+ : 0;
579
+ const exertionFall = exertionRise > 0 ? 0 : cfg.exertion.idleDecayRate;
580
+ state.exertion = clamp(state.exertion + (exertionRise - exertionFall) * dt, 0, 1);
581
+
582
+ // -- L2.c: Breath ----------------------------------------------
583
+ // breathRate and breathAmplitude lag exertion through separate
584
+ // exponential decays. Rate hangs around longer than amplitude.
585
+ const metabolicRate = lerp(cfg.breath.rateRestHz, cfg.breath.rateMaxHz, state.exertion);
586
+ const targetAmp = lerp(cfg.breath.amplitudeRestM, cfg.breath.amplitudeMaxM, state.exertion);
587
+
588
+ // Locomotor-respiratory coupling — see math/computeLRCBreathRate.
589
+ // The pure function is unit-tested; this site just provides inputs.
324
590
  //
325
- // screen_forward(θ) = ( sin θ, 0, cos θ )
326
- // screen_right (θ) = (-cos θ, 0, sin θ )
327
- const sinYaw = Math.sin(runtime.bodyYaw);
328
- const cosYaw = Math.cos(runtime.bodyYaw);
591
+ // Gait is gated on a "feet strike the ground" posture (Stand /
592
+ // Crouch). Prone (slide) and Hang (ledge-grab) have no stride
593
+ // the body's feet are not making contact in a walking pattern,
594
+ // so stride frequency drops to zero and downstream gait
595
+ // signals (footsteps, bob intensity) go quiet.
596
+ const feetStriking = state.posture === FirstPersonPosture.Stand
597
+ || state.posture === FirstPersonPosture.Crouch;
598
+ const strideFreqHz = feetStriking && state.grounded && horizSpeed > cfg.bob.minStepSpeed
599
+ ? cfg.bob.stepFreqAtWalk * Math.pow(
600
+ Math.max(horizSpeed, 1e-3) / Math.max(cfg.motion.walkSpeed, 1e-3),
601
+ cfg.bob.stepFreqExp,
602
+ )
603
+ : 0;
604
+ const targetRate = computeLRCBreathRate(
605
+ metabolicRate,
606
+ strideFreqHz,
607
+ state.exertion,
608
+ cfg.breath.locomotorCouplingMax,
609
+ cfg.breath.couplingMinStrideFreqHz,
610
+ );
611
+ state.breathRateHz = exponentialApproach(state.breathRateHz, targetRate, cfg.exertion.rateDecayHalfLife, dt);
612
+ state.breathAmplitudeM = exponentialApproach(state.breathAmplitudeM, targetAmp, cfg.exertion.ampDecayHalfLife, dt);
329
613
 
330
- // Normalize the move vector (so diagonal isn't √2× faster)
331
- const mvX = intent.move.x; // strafe (right+)
332
- const mvY = intent.move.y; // forward (forward+)
333
- const mvMag = Math.hypot(mvX, mvY);
334
- const nmvX = mvMag > 1 ? mvX / mvMag : mvX;
335
- const nmvY = mvMag > 1 ? mvY / mvMag : mvY;
614
+ runtime.prevBreathPhase = state.breathPhase;
615
+ state.breathPhase += state.breathRateHz * dt;
616
+ state.breathPhase -= Math.floor(state.breathPhase); // wrap [0,1)
336
617
 
337
- const desiredVx = sinYaw * nmvY + -cosYaw * nmvX;
338
- const desiredVz = cosYaw * nmvY + sinYaw * nmvX;
618
+ // Breath edge detection inhale at 0.25, exhale at 0.75
619
+ if (phaseCrossed(runtime.prevBreathPhase, state.breathPhase, 0.25)) {
620
+ sig.onBreathIn.send1({ amplitude: state.breathAmplitudeM, rateHz: state.breathRateHz });
621
+ }
622
+ if (phaseCrossed(runtime.prevBreathPhase, state.breathPhase, 0.75)) {
623
+ sig.onBreathOut.send1({ amplitude: state.breathAmplitudeM, rateHz: state.breathRateHz });
624
+ }
339
625
 
340
- const desiredHorizontalVx = desiredVx * targetSpeed;
341
- const desiredHorizontalVz = desiredVz * targetSpeed;
626
+ // -- L2.d: Stride ----------------------------------------------
627
+ // strideFreqHz computed above in the breath block; reused here.
628
+ runtime.prevStridePhase = state.stridePhase;
629
+ if (strideFreqHz > 0) {
630
+ // 1 full stride cycle = 2 footfalls; phase advances at freq/2 of cycle
631
+ state.stridePhase += (strideFreqHz * 0.5) * dt;
632
+ state.stridePhase -= Math.floor(state.stridePhase);
633
+ }
634
+ // Footstep on phase wraparound past 0 (R) or past 0.5 (L). Same
635
+ // posture gate as stride advance — feet must be striking.
636
+ if (feetStriking && state.grounded && horizSpeed > cfg.bob.minStepSpeed) {
637
+ const fireFootstep = () => {
638
+ state.stepCount++;
639
+ const side = runtime.nextFootSide;
640
+ runtime.nextFootSide = side === "R" ? "L" : "R";
641
+ // The foot that just fired is now the one bearing weight
642
+ // through the upcoming half-stride. Drives lateral-bob sign.
643
+ runtime.standingFoot = side;
644
+ sig.onFootStep.send1({ side, speed: horizSpeed, surfaceTag: state.surfaceTag });
645
+ // Kick the vertical impact spring DOWNWARD. The kick magnitude
646
+ // is the per-step desired peak dip × impactKickMultiplier; the
647
+ // multiplier is empirical (depends on impact spring params) so
648
+ // that "verticalAmpAtWalk" still corresponds approximately to
649
+ // the visible peak dip depth. Scaled by bobIntensity so a
650
+ // mid-deceleration footstep doesn't deliver a full-strength
651
+ // impulse.
652
+ const massBoost = (cfg.body.mass - 80) * cfg.bob.ampMassScale;
653
+ const ampVMult = 1 + (cfg.bob.backwardVerticalAmpFactor - 1) * runtime.backwardness;
654
+ const peakDip = (cfg.bob.verticalAmpAtWalk + massBoost) * runtime.bobIntensitySpring.value * ampVMult;
655
+ runtime.verticalImpactSpring.kick(-peakDip * cfg.bob.impactKickMultiplier);
656
+ };
657
+ if (phaseCrossed(runtime.prevStridePhase, state.stridePhase, 0)) {
658
+ fireFootstep();
659
+ }
660
+ if (phaseCrossed(runtime.prevStridePhase, state.stridePhase, 0.5)) {
661
+ fireFootstep();
662
+ }
663
+ }
342
664
 
343
- // -- L1.d: Apply accel/decel to horizontal velocity -------------
344
- // Velocity lives on the runtime we don't depend on Motion existing,
345
- // because the system runs its own integrator when no external
346
- // physics layer is attached.
347
- const intentLen = Math.hypot(nmvX, nmvY);
348
- let horizAccel;
349
- if (!state.grounded) {
350
- horizAccel = cfg.motion.airAccel;
351
- } else if (intentLen < 1e-4) {
352
- horizAccel = cfg.motion.groundDecel;
353
- } else {
354
- horizAccel = cfg.motion.groundAccel;
665
+ // -- L2.d.bob-intensity & impact -------------------------------
666
+ // Smoothed bob amplitude envelope: when the player starts/stops
667
+ // moving the visible bob fades in/out rather than cutting on/off.
668
+ // Target = the "natural" amp scale (max of speed and backwardness)
669
+ // while grounded, zero while airborne so the bob disappears mid-jump.
670
+ const naturalBobIntensity = Math.max(state.speedNormalized, runtime.backwardness);
671
+ // Bob fades to zero whenever feet aren't striking (airborne, or
672
+ // Prone/Hang posture). The verticalImpactSpring (separate
673
+ // channel) still carries any entry/landing kicks through to the
674
+ // camera, but no recurring step bob.
675
+ const targetBobIntensity = (state.grounded && feetStriking) ? naturalBobIntensity : 0;
676
+ runtime.bobIntensitySpring.stepTo(targetBobIntensity, cfg.bob.intensityHalfLife, 1.0, dt);
677
+
678
+ // Vertical impact spring — damped decay toward 0, with the under-
679
+ // damped overshoot that produces the recovery + leg-push curve.
680
+ runtime.verticalImpactSpring.stepTo(0, cfg.bob.impactSpringHalfLife, cfg.bob.impactSpringZeta, dt);
681
+
682
+ // Sprint posture — head pitches forward as commitment to sprint
683
+ // builds. Driven by "sprintness" — how much of the gap between
684
+ // walk and sprint speed the player is *currently* in (0..1). The
685
+ // pitch target is multiplied by sprintness, then critically damped.
686
+ // Only applies while grounded — pitching into airborne motion looks weird.
687
+ const sprintness = clamp(
688
+ (state.speed - cfg.motion.walkSpeed)
689
+ / Math.max(cfg.motion.sprintSpeed - cfg.motion.walkSpeed, 1e-3),
690
+ 0, 1,
691
+ );
692
+ const targetSprintPitch = state.grounded
693
+ ? cfg.posture.sprintForwardPitchDeg * DEG_TO_RAD * sprintness
694
+ : 0;
695
+ runtime.sprintPostureSpring.stepTo(
696
+ targetSprintPitch,
697
+ cfg.posture.sprintForwardPitchHalfLife,
698
+ 1.0, dt,
699
+ );
700
+ runtime.sprintness = sprintness;
701
+
702
+ // Head droop — exertion drives a subtle additional forward pitch.
703
+ // Combines with sprintPostureSpring (sprint = head down to commit)
704
+ // so a fatigued sprinter has BOTH effects layered.
705
+ const targetDroopRad = cfg.exertion.headDroopAtMaxDeg * DEG_TO_RAD * state.exertion;
706
+ runtime.headDroopSpring.stepTo(targetDroopRad, cfg.exertion.headDroopHalfLife, 1.0, dt);
707
+
708
+ // -- L2.e: Posture → eye height --------------------------------
709
+ // Posture is set by whichever layer owned motion this tick: base
710
+ // writes Stand / Crouch from isCrouchActive (see end of
711
+ // _runBaseLocomotion); active abilities write Prone (Slide) or
712
+ // Hang (LedgeGrab) in their tick. Mapping is one switch — adding
713
+ // a new posture is one enum value + one case.
714
+ let targetEyeH;
715
+ switch (state.posture) {
716
+ case FirstPersonPosture.Prone: targetEyeH = cfg.body.proneHeight; break;
717
+ case FirstPersonPosture.Crouch: targetEyeH = cfg.body.crouchHeight; break;
718
+ case FirstPersonPosture.Hang: targetEyeH = cfg.body.height; break;
719
+ case FirstPersonPosture.Stand:
720
+ default: targetEyeH = cfg.body.height; break;
355
721
  }
722
+ const crouchHalfLife = cfg.crouch.transitionTime / 4; // halfLife is ~quarter of full transition
723
+ runtime.eyeHeightSpring.stepTo(targetEyeH, crouchHalfLife, 1.0, dt);
724
+ state.eyeHeight = runtime.eyeHeightSpring.value;
356
725
 
357
- const maxStep = horizAccel * dt;
358
- runtime.velocityX = stepTowards(runtime.velocityX, desiredHorizontalVx, maxStep);
359
- runtime.velocityZ = stepTowards(runtime.velocityZ, desiredHorizontalVz, maxStep);
726
+ if (isCrouchActive !== state.crouchActive) {
727
+ state.crouchActive = isCrouchActive;
728
+ if (isCrouchActive) {
729
+ sig.onCrouchEnter.send0();
730
+ // Impulse: dropping into a crouch grips the knees. Small
731
+ // bump — we don't want crouch-spamming to instantly tire.
732
+ state.exertion = clamp(
733
+ state.exertion + cfg.exertion.crouchEnterRise * runtime.massRatios.exertionRiseScale,
734
+ 0, 1,
735
+ );
736
+ } else {
737
+ sig.onCrouchExit.send0();
738
+ }
739
+ }
740
+
741
+ // -- L2.f: Lean spring → camera roll ---------------------------
742
+ // The TARGET for this tick was written by whichever layer owned
743
+ // motion: base writes the lat-accel + look-lean derived value at
744
+ // the end of _runBaseLocomotion; abilities override (WallRun
745
+ // tilts toward the wall; Slide / LedgeGrab / Mantle force zero).
746
+ // L2.f is now a flat spring-step + commit — no branching, no
747
+ // null sentinel.
748
+ runtime.prevVelocityX = runtime.velocityX;
749
+ runtime.prevVelocityZ = runtime.velocityZ;
750
+ runtime.leanSpring.stepTo(runtime.leanTargetRad, cfg.lean.spring.halfLife, cfg.lean.spring.zeta, dt);
751
+ state.leanRollRad = runtime.leanSpring.value;
752
+
753
+ // -- L2.g: Land spring decay (drives the landing recovery dip) -
754
+ // Target is 0; under-damped (cfg zeta < 1) so it rings.
755
+ runtime.landSpring.stepTo(0, cfg.landing.recovery.spring.halfLife, cfg.landing.recovery.spring.zeta, dt);
756
+
757
+ // -- L2.h: Publish pose channels --------------------------------
758
+ this._publishPose(controller, runtime, bodyTransform);
759
+ }
760
+
761
+ /**
762
+ * @private
763
+ * @param {FirstPersonPlayerController} controller
764
+ * @param {PerEntityRuntime} runtime
765
+ * @returns {boolean}
766
+ */
767
+ _resolveCrouchHeld(controller, runtime) {
768
+ const cfg = controller.config;
769
+ const intent = controller.intent;
770
+
771
+ if (cfg.crouch.mode === "toggle") {
772
+ // Edge: rising press flips the latch
773
+ if (intent.crouch && !runtime.prevCrouchHeld) {
774
+ runtime.crouchLatched = !runtime.crouchLatched;
775
+ }
776
+ runtime.prevCrouchHeld = intent.crouch;
777
+ return runtime.crouchLatched;
778
+ }
779
+ // "hold" mode
780
+ runtime.prevCrouchHeld = intent.crouch;
781
+ return intent.crouch;
782
+ }
783
+
784
+ /**
785
+ * Jump finite-state-machine: button-edge detection, buffer + coyote
786
+ * grace, anticipation timer, impulse on completion. Variable-height
787
+ * cut is captured here as a `state.isVariableJumpCut` flag that the
788
+ * gravity step in `_integrateVerticalAndResolveGround` consumes.
789
+ *
790
+ * @private
791
+ * @param {FirstPersonPlayerController} controller
792
+ * @param {PerEntityRuntime} runtime
793
+ * @param {Transform} bodyTransform
794
+ * @param {number} dt
795
+ */
796
+ _advanceJumpFsm(controller, runtime, bodyTransform, dt) {
797
+ const cfg = controller.config;
798
+ const intent = controller.intent;
799
+ const state = controller.state;
800
+ const sig = controller.signals;
360
801
 
361
- // -- L1.e: Jump (edge-triggered, buffered, coyote-graced) -------
362
802
  const jumpPressedEdge = intent.jump && !runtime.prevJumpHeld;
363
803
  const jumpReleasedEdge = !intent.jump && runtime.prevJumpHeld;
364
804
  runtime.prevJumpHeld = intent.jump;
@@ -381,37 +821,68 @@ export class FirstPersonPlayerControllerSystem extends System {
381
821
  state.jumpBufferRemaining = 0; // claimed
382
822
  }
383
823
 
384
- // Variable-height cut: only valid during ascent and once jump has launched
824
+ // Variable-height cut: only valid during ascent, post-launch.
385
825
  if (jumpReleasedEdge && runtime.midJump && runtime.velocityY > 0) {
386
826
  state.isVariableJumpCut = true;
387
827
  }
388
828
 
389
- // Anticipation timer; impulse on completion
829
+ // Anticipation timer; impulse on completion.
390
830
  if (state.inJumpAnticipation) {
391
- // If the entity goes airborne mid-anticipation (ground rug-pulled),
392
- // abandon the queued impulse — fire onLeaveGround{fall} instead.
393
831
  if (!state.grounded) {
832
+ // Ground rug-pulled mid-anticipation — abandon the queued
833
+ // impulse; the airborne-transition path will fire onLeaveGround.
394
834
  state.inJumpAnticipation = false;
395
835
  runtime.anticipationRemaining = 0;
396
836
  } else {
397
837
  runtime.anticipationRemaining -= dt;
398
838
  if (runtime.anticipationRemaining <= 0) {
399
- runtime.velocityY = runtime.jumpInitialVy;
839
+ // Mastery: gather a multiplier from all evaluators
840
+ // registered for JumpImpulse. Default (no evaluators)
841
+ // returns 1.0 → unchanged behaviour.
842
+ const masteryMul = controller.mastery.evaluate(
843
+ DecisionPoint.JumpImpulse, controller, runtime,
844
+ );
845
+ runtime.velocityY = runtime.jumpInitialVy * masteryMul;
400
846
  runtime.midJump = true;
401
847
  runtime.apexFired = false;
402
848
  runtime.peakAltitude = bodyTransform.position.y;
403
849
  state.inJumpAnticipation = false;
404
850
  state.isVariableJumpCut = false;
405
851
  state.isAscending = true;
406
- controller.state.exertion = clamp(controller.state.exertion + cfg.exertion.jumpRise, 0, 1);
852
+ state.exertion = clamp(
853
+ state.exertion + cfg.exertion.jumpRise * runtime.massRatios.exertionRiseScale,
854
+ 0, 1,
855
+ );
407
856
 
408
857
  sig.onJumpStart.send1({ peakHeight: cfg.jump.peakHeight });
409
858
  sig.onLeaveGround.send1({ reason: "jump" });
410
859
  }
411
860
  }
412
861
  }
862
+ }
863
+
864
+ /**
865
+ * Gravity (with fall and cut multipliers), vertical integration,
866
+ * built-in flat-floor resolution (land event + impulse), and jump-apex
867
+ * detection. The full vertical phase of one fixed step.
868
+ *
869
+ * The built-in flat-floor branch only runs when `useBuiltInFlatGround`
870
+ * is true (the prototype's standalone mode); with an external physics
871
+ * layer attached the system relies on the layer to set `state.grounded`
872
+ * and only maintains airborne/grounded timers here.
873
+ *
874
+ * @private
875
+ * @param {FirstPersonPlayerController} controller
876
+ * @param {PerEntityRuntime} runtime
877
+ * @param {Transform} bodyTransform
878
+ * @param {number} dt
879
+ */
880
+ _integrateVerticalAndResolveGround(controller, runtime, bodyTransform, dt) {
881
+ const cfg = controller.config;
882
+ const state = controller.state;
883
+ const sig = controller.signals;
413
884
 
414
- // -- L1.f: Gravity ---------------------------------------------
885
+ // Gravity with fall/cut multipliers.
415
886
  let gMag = runtime.gravity;
416
887
  if (runtime.velocityY <= 0) {
417
888
  gMag *= cfg.jump.fallGravityMult;
@@ -419,37 +890,63 @@ export class FirstPersonPlayerControllerSystem extends System {
419
890
  } else if (state.isVariableJumpCut) {
420
891
  gMag *= cfg.jump.cutGravityMult;
421
892
  }
422
-
423
893
  runtime.velocityY -= gMag * dt;
424
894
 
425
- // -- L1.g: Integrate position ----------------------------------
895
+ // Integrate position.
426
896
  bodyTransform.position._add(
427
897
  runtime.velocityX * dt,
428
898
  runtime.velocityY * dt,
429
899
  runtime.velocityZ * dt,
430
900
  );
431
901
 
432
- // -- L1.h: Ground resolution (built-in flat floor) -------------
433
- if (this.useBuiltInFlatGround) {
434
- if (bodyTransform.position.y <= this.groundY) {
435
- bodyTransform.position.setY(this.groundY);
902
+ // Ground resolution.
903
+ // Effective ground = max(built-in flat ground, optional resolver).
904
+ // - useBuiltInFlatGround=true gives a baseline floor at groundY.
905
+ // - groundResolver lets the host scene raise the floor under
906
+ // platforms / terrain. Returns the surface Y under the player,
907
+ // or null when no ground is below (gap / void).
908
+ // If both are off, the original "external physics" branch
909
+ // (else-block below) just tracks timers and leaves grounded
910
+ // alone — the host's physics layer is expected to set it.
911
+ if (this.useBuiltInFlatGround || this.groundResolver !== null) {
912
+ let testY = this.useBuiltInFlatGround ? this.groundY : Number.NEGATIVE_INFINITY;
913
+ if (this.groundResolver !== null) {
914
+ const resolved = this.groundResolver(
915
+ bodyTransform.position.x,
916
+ bodyTransform.position.y,
917
+ bodyTransform.position.z,
918
+ );
919
+ if (resolved !== null && resolved > testY) testY = resolved;
920
+ }
921
+ const haveGround = testY !== Number.NEGATIVE_INFINITY;
922
+ if (haveGround && bodyTransform.position.y <= testY) {
923
+ bodyTransform.position.setY(testY);
436
924
 
437
925
  if (!state.grounded) {
438
- // Land
439
- const impactVy = -runtime.velocityY; // positive magnitude
926
+ // Land — apply all state changes first, then fire the
927
+ // signal LAST so handlers see the fully-reacted state.
928
+ const impactVy = -runtime.velocityY;
440
929
  const kind = impactVy >= cfg.landing.hardThreshold ? "hard"
441
930
  : (impactVy >= cfg.landing.softThreshold ? "soft" : "soft");
442
- sig.onLand.send1({ verticalSpeed: impactVy, kind });
443
931
 
444
- // Land dip drives the under-damped spring downward
445
- const dip = clamp(impactVy * cfg.landing.recovery.dipPerVy, 0, cfg.landing.recovery.dipMax);
446
- runtime.landSpring.value = -dip; // start displaced
447
- runtime.landSpring.velocity = 0;
932
+ const massScaledDip = impactVy * cfg.landing.recovery.dipPerVy
933
+ * runtime.massRatios.landingDipScale;
934
+ const dip = clamp(massScaledDip, 0, cfg.landing.recovery.dipMax);
935
+ runtime.landSpring.settle(-dip);
936
+
937
+ const landImpulse = clamp(
938
+ impactVy * cfg.exertion.landImpulsePerVy * runtime.massRatios.exertionRiseScale,
939
+ 0,
940
+ cfg.exertion.landImpulseMax,
941
+ );
942
+ state.exertion = clamp(state.exertion + landImpulse, 0, 1);
448
943
 
449
944
  runtime.midJump = false;
450
945
  state.isAscending = false;
451
946
  state.isVariableJumpCut = false;
452
947
  state.fallDistance = 0;
948
+
949
+ sig.onLand.send1({ verticalSpeed: impactVy, kind });
453
950
  }
454
951
 
455
952
  state.grounded = true;
@@ -470,8 +967,7 @@ export class FirstPersonPlayerControllerSystem extends System {
470
967
  state.fallDistance += Math.max(0, -runtime.velocityY * dt);
471
968
  }
472
969
  } else {
473
- // External physics is expected to maintain state.grounded /
474
- // state.verticalSpeed; we still track airborne timer.
970
+ // External physics maintains state.grounded; just track timers.
475
971
  if (state.grounded) {
476
972
  state.timeSinceGrounded = 0;
477
973
  state.airborneTime = 0;
@@ -481,7 +977,7 @@ export class FirstPersonPlayerControllerSystem extends System {
481
977
  }
482
978
  }
483
979
 
484
- // Detect jump apex
980
+ // Jump apex detection.
485
981
  if (runtime.midJump && !runtime.apexFired) {
486
982
  if (bodyTransform.position.y > runtime.peakAltitude) {
487
983
  runtime.peakAltitude = bodyTransform.position.y;
@@ -490,163 +986,230 @@ export class FirstPersonPlayerControllerSystem extends System {
490
986
  runtime.apexFired = true;
491
987
  }
492
988
  }
989
+ }
493
990
 
494
- // -- L2.a: speed / moveMode ------------------------------------
495
- const horizSpeed = Math.hypot(runtime.velocityX, runtime.velocityZ);
496
- state.speed = horizSpeed;
497
- state.speedNormalized = clamp(horizSpeed / Math.max(cfg.motion.sprintSpeed, 1e-3), 0, 1);
991
+ /**
992
+ * Run the base (no-ability) L1 locomotion phases: speed selection,
993
+ * desired-velocity computation, accel/decel, jump FSM, gravity, body
994
+ * integration, ground resolution. Only invoked when no ability owns
995
+ * the tick (see {@link AbilitySet.tick}).
996
+ *
997
+ * @private
998
+ * @param {FirstPersonPlayerController} controller
999
+ * @param {PerEntityRuntime} runtime
1000
+ * @param {Transform} bodyTransform
1001
+ * @param {number} dt
1002
+ * @param {boolean} isCrouchActive
1003
+ * @param {boolean} isSprintIntent
1004
+ * @param {boolean} isBackwardIntent
1005
+ */
1006
+ _runBaseLocomotion(controller, runtime, bodyTransform, dt,
1007
+ isCrouchActive, isSprintIntent, isBackwardIntent) {
1008
+ const cfg = controller.config;
1009
+ const intent = controller.intent;
1010
+ const state = controller.state;
498
1011
 
499
- const prevMoveMode = state.moveMode;
500
- if (!state.grounded) {
501
- state.moveMode = "Air";
502
- } else if (isCrouchActive) {
503
- state.moveMode = "Crouch";
504
- } else if (isSprintIntent && horizSpeed > 0.1) {
505
- state.moveMode = "Sprint";
506
- } else if (horizSpeed > 0.1) {
507
- state.moveMode = "Walk";
1012
+ // -- L1.b: Speed selection ------------------------------------
1013
+ let targetSpeed;
1014
+ if (isCrouchActive) {
1015
+ targetSpeed = cfg.motion.crouchSpeed;
1016
+ } else if (isSprintIntent) {
1017
+ targetSpeed = cfg.motion.sprintSpeed;
508
1018
  } else {
509
- state.moveMode = "Idle";
1019
+ targetSpeed = cfg.motion.walkSpeed;
510
1020
  }
511
-
512
- if (state.moveMode === "Sprint" && prevMoveMode !== "Sprint") {
513
- sig.onSprintStart.send0();
514
- } else if (prevMoveMode === "Sprint" && state.moveMode !== "Sprint") {
515
- sig.onSprintStop.send0();
1021
+ if (isBackwardIntent) {
1022
+ targetSpeed *= cfg.motion.backwardSpeedFactor;
516
1023
  }
517
1024
 
518
- // -- L2.b: Exertion --------------------------------------------
519
- const exertionRise = isSprintIntent ? cfg.exertion.sprintRiseRate : 0;
520
- const exertionFall = exertionRise > 0 ? 0 : cfg.exertion.idleDecayRate;
521
- state.exertion = clamp(state.exertion + (exertionRise - exertionFall) * dt, 0, 1);
522
-
523
- // -- L2.c: Breath ----------------------------------------------
524
- // breathRate and breathAmplitude lag exertion through separate
525
- // exponential decays. Rate hangs around longer than amplitude.
526
- const targetRate = lerp(cfg.breath.rateRestHz, cfg.breath.rateMaxHz, state.exertion);
527
- const targetAmp = lerp(cfg.breath.amplitudeRestM, cfg.breath.amplitudeMaxM, state.exertion);
528
- state.breathRateHz = exponentialApproach(state.breathRateHz, targetRate, cfg.exertion.rateDecayHalfLife, dt);
529
- state.breathAmplitudeM = exponentialApproach(state.breathAmplitudeM, targetAmp, cfg.exertion.ampDecayHalfLife, dt);
530
-
531
- runtime.prevBreathPhase = state.breathPhase;
532
- state.breathPhase += state.breathRateHz * dt;
533
- state.breathPhase -= Math.floor(state.breathPhase); // wrap [0,1)
1025
+ // -- L1.c: Move intent → desired horizontal velocity ----------
1026
+ // screen_forward(θ) = ( sin θ, 0, cos θ )
1027
+ // screen_right (θ) = (-cos θ, 0, sin θ )
1028
+ const { sinYaw, cosYaw } = runtime;
1029
+ const mvX = intent.move.x;
1030
+ const mvY = intent.move.y;
1031
+ const mvMag = Math.hypot(mvX, mvY);
1032
+ const nmvX = mvMag > 1 ? mvX / mvMag : mvX;
1033
+ const nmvY = mvMag > 1 ? mvY / mvMag : mvY;
1034
+ const desiredVx = sinYaw * nmvY + -cosYaw * nmvX;
1035
+ const desiredVz = cosYaw * nmvY + sinYaw * nmvX;
1036
+ const desiredHorizontalVx = desiredVx * targetSpeed;
1037
+ const desiredHorizontalVz = desiredVz * targetSpeed;
534
1038
 
535
- // Breath edge detection inhale at 0.25, exhale at 0.75
536
- if (phaseCrossed(runtime.prevBreathPhase, state.breathPhase, 0.25)) {
537
- sig.onBreathIn.send1({ amplitude: state.breathAmplitudeM, rateHz: state.breathRateHz });
538
- }
539
- if (phaseCrossed(runtime.prevBreathPhase, state.breathPhase, 0.75)) {
540
- sig.onBreathOut.send1({ amplitude: state.breathAmplitudeM, rateHz: state.breathRateHz });
1039
+ // -- L1.d: Accel/decel toward desired velocity ----------------
1040
+ const intentLen = Math.hypot(nmvX, nmvY);
1041
+ let horizAccel;
1042
+ if (!state.grounded) {
1043
+ horizAccel = cfg.motion.airAccel;
1044
+ } else if (intentLen < 1e-4) {
1045
+ horizAccel = cfg.motion.groundDecel;
1046
+ } else {
1047
+ horizAccel = cfg.motion.groundAccel;
541
1048
  }
542
-
543
- // -- L2.d: Stride ----------------------------------------------
544
- runtime.prevStridePhase = state.stridePhase;
545
- if (state.grounded && horizSpeed > cfg.bob.minStepSpeed) {
546
- const freq = cfg.bob.stepFreqAtWalk
547
- * Math.pow(Math.max(horizSpeed, 1e-3) / Math.max(cfg.motion.walkSpeed, 1e-3), cfg.bob.stepFreqExp);
548
- // 1 full stride cycle = 2 footfalls; phase advances at freq/2 of cycle
549
- state.stridePhase += (freq * 0.5) * dt;
550
- state.stridePhase -= Math.floor(state.stridePhase);
1049
+ if (isBackwardIntent && state.grounded) {
1050
+ horizAccel *= cfg.motion.backwardAccelFactor;
551
1051
  }
552
- // Footstep on phase wraparound past 0 (R) or past 0.5 (L)
553
- if (state.grounded && horizSpeed > cfg.bob.minStepSpeed) {
554
- if (phaseCrossed(runtime.prevStridePhase, state.stridePhase, 0)) {
555
- state.stepCount++;
556
- const side = runtime.nextFootSide === "L" ? "L" : "R";
557
- runtime.nextFootSide = side === "R" ? "L" : "R";
558
- sig.onFootStep.send1({ side, speed: horizSpeed, surfaceTag: state.surfaceTag });
559
- }
560
- if (phaseCrossed(runtime.prevStridePhase, state.stridePhase, 0.5)) {
561
- state.stepCount++;
562
- const side = runtime.nextFootSide === "L" ? "L" : "R";
563
- runtime.nextFootSide = side === "R" ? "L" : "R";
564
- sig.onFootStep.send1({ side, speed: horizSpeed, surfaceTag: state.surfaceTag });
565
- }
1052
+ if (state.grounded) {
1053
+ horizAccel *= runtime.massRatios.groundAccelScale;
1054
+ // Mastery: GroundAccel evaluators can scale per-tick accel
1055
+ // (e.g. foot-asymmetry-turn bonus). Default (no evaluators)
1056
+ // returns 1.0 unchanged.
1057
+ horizAccel *= controller.mastery.evaluate(
1058
+ DecisionPoint.GroundAccel, controller, runtime,
1059
+ );
566
1060
  }
1061
+ const maxStep = horizAccel * dt;
1062
+ runtime.velocityX = stepTowards(runtime.velocityX, desiredHorizontalVx, maxStep);
1063
+ runtime.velocityZ = stepTowards(runtime.velocityZ, desiredHorizontalVz, maxStep);
567
1064
 
568
- // -- L2.e: Crouch eye height -----------------------------------
569
- const targetEyeH = isCrouchActive ? cfg.body.crouchHeight : cfg.body.height;
570
- const crouchHalfLife = cfg.crouch.transitionTime / 4; // halfLife is ~quarter of full transition
571
- criticallyDampedSpringStep(runtime.eyeHeightSpring, targetEyeH, crouchHalfLife, dt);
572
- state.eyeHeight = runtime.eyeHeightSpring.value;
1065
+ // -- L1.e/f/g/h: jump FSM + vertical integration --------------
1066
+ this._advanceJumpFsm(controller, runtime, bodyTransform, dt);
1067
+ this._integrateVerticalAndResolveGround(controller, runtime, bodyTransform, dt);
1068
+
1069
+ // -- Publish posture for L2 consumers (eye height, gait gating).
1070
+ // Base owns posture when no ability is active: Crouch if the
1071
+ // crouch intent is resolved active, otherwise Stand. Abilities
1072
+ // that need a different posture (slide → Prone, ledge-grab →
1073
+ // Hang) set state.posture themselves in their tick.
1074
+ controller.state.posture = isCrouchActive
1075
+ ? FirstPersonPosture.Crouch
1076
+ : FirstPersonPosture.Stand;
1077
+
1078
+ // -- Publish lean target for L2.f. Base writes the natural
1079
+ // (lat-accel + look-lean) value; abilities override in their
1080
+ // own tick. L2.f spring-steps toward whatever's here.
1081
+ runtime.leanTargetRad = this._computeNaturalLeanTarget(controller, runtime, dt);
1082
+ }
573
1083
 
574
- if (isCrouchActive !== state.crouchActive) {
575
- state.crouchActive = isCrouchActive;
576
- if (isCrouchActive) sig.onCrouchEnter.send0();
577
- else sig.onCrouchExit.send0();
578
- }
1084
+ /**
1085
+ * Compute the natural camera lean for this tick: lat-accel-driven
1086
+ * roll into a turn, plus a yaw-rate look-lean contribution, both
1087
+ * clamped. The result is the target the lean spring chases each
1088
+ * tick when no ability has opinions.
1089
+ *
1090
+ * Pure-ish helper — reads `controller`, `runtime`, `dt`; returns a
1091
+ * number. Extracted so both base and any future ability that wants
1092
+ * to compose its lean on top of the natural value can call it.
1093
+ *
1094
+ * @private
1095
+ * @param {FirstPersonPlayerController} controller
1096
+ * @param {PerEntityRuntime} runtime
1097
+ * @param {number} dt
1098
+ * @returns {number} target roll in radians
1099
+ */
1100
+ _computeNaturalLeanTarget(controller, runtime, dt) {
1101
+ const cfg = controller.config;
1102
+ const state = controller.state;
1103
+ if (!cfg.lean.enabled) return 0;
579
1104
 
580
- // -- L2.f: Lean (lateral acceleration → roll) ------------------
581
- let leanTargetRad = 0;
582
- if (cfg.lean.enabled) {
583
- // Lateral acceleration projected onto screen-right.
584
- // accel_world = (vel - prevVel) / dt; screen_right = (-cos θ, 0, sin θ).
585
- const accWorldX = (runtime.velocityX - runtime.prevVelocityX) / Math.max(dt, 1e-4);
586
- const accWorldZ = (runtime.velocityZ - runtime.prevVelocityZ) / Math.max(dt, 1e-4);
587
- const latAccel = accWorldX * (-cosYaw) + accWorldZ * sinYaw;
588
- const normalized = clamp(latAccel / 9.81, -2, 2);
589
- // Negative roll on rightward accel = leaning into the turn
590
- leanTargetRad = -normalized * cfg.lean.maxRollDeg * DEG_TO_RAD;
1105
+ const sinYaw = runtime.sinYaw;
1106
+ const cosYaw = runtime.cosYaw;
1107
+
1108
+ // Lateral acceleration projected onto screen-right.
1109
+ // accel_world = (vel - prevVel) / dt; screen_right = (-cos θ, 0, sin θ).
1110
+ const accWorldX = (runtime.velocityX - runtime.prevVelocityX) / Math.max(dt, 1e-4);
1111
+ const accWorldZ = (runtime.velocityZ - runtime.prevVelocityZ) / Math.max(dt, 1e-4);
1112
+ const latAccel = accWorldX * (-cosYaw) + accWorldZ * sinYaw;
1113
+ const normalized = clamp(latAccel / 9.81, -2, 2);
1114
+ //
1115
+ // Sign convention for the roll (the eye composes the rotation
1116
+ // as qYaw * qPitch * qRoll, where qRoll is around (0,0,1)).
1117
+ // After the engine's camera-invert pipeline:
1118
+ // φ > 0 → camera-up tilts toward screen-right (−X) → HEAD TILTS RIGHT
1119
+ // φ < 0 → camera-up tilts toward screen-left (+X) → HEAD TILTS LEFT
1120
+ //
1121
+ // For the "bank into the turn" feel (Apex / Titanfall / Mirror's
1122
+ // Edge): accelerating right (latAccel > 0) should tilt the head
1123
+ // RIGHT, i.e. positive φ. So leanTargetRad has the SAME sign
1124
+ // as latAccel.
1125
+ let leanTargetRad = normalized * cfg.lean.maxRollDeg * DEG_TO_RAD;
1126
+
1127
+ // Look-lean: yaw-rate-driven banking. runtime.yawRateRadPerSec
1128
+ // was cached at L1.a — negative is the "turn right" convention.
1129
+ // For "bank into the turn": turning right → head tilts right →
1130
+ // positive engine roll. So lookLean = -yawRate * scale matches
1131
+ // sign.
1132
+ //
1133
+ // Crouched players are in a low, stable, low-momentum stance —
1134
+ // banking the head from a mouse turn reads as unmotivated. We
1135
+ // scale the contribution down (default to 0) while crouched.
1136
+ // Lat-accel lean is left alone: its magnitude naturally tracks
1137
+ // the (lower) crouch acceleration, so it stays motivated.
1138
+ if (cfg.lean.lookLeanEnabled) {
1139
+ const yawRate = clamp(
1140
+ runtime.yawRateRadPerSec,
1141
+ -cfg.lean.lookLeanYawRateClamp,
1142
+ cfg.lean.lookLeanYawRateClamp,
1143
+ );
1144
+ const crouchFactor = state.crouchActive ? cfg.lean.crouchLookLeanFactor : 1.0;
1145
+ leanTargetRad += -yawRate * cfg.lean.lookLeanDegPerRadPerSec * DEG_TO_RAD * crouchFactor;
591
1146
  }
592
- runtime.prevVelocityX = runtime.velocityX;
593
- runtime.prevVelocityZ = runtime.velocityZ;
594
- criticallyDampedSpringStep(runtime.leanSpring, leanTargetRad, cfg.lean.spring.halfLife, dt);
595
- state.leanRollRad = runtime.leanSpring.value;
596
1147
 
597
- // -- L2.g: Land spring decay (drives the landing recovery dip) -
598
- // Target is 0; under-damped so it rings.
599
- dampedSpringStep(
600
- runtime.landSpring, 0,
601
- cfg.landing.recovery.spring.halfLife,
602
- cfg.landing.recovery.spring.zeta,
603
- dt,
604
- );
1148
+ // Final clamp on the sum: cap the combined target to ±2 ×
1149
+ // maxRollDeg (matches the latAccel normalized clamp range) so
1150
+ // even simultaneous max-strafe-accel + max-yaw-flick produces a
1151
+ // sane upper bound.
1152
+ const maxTotal = cfg.lean.maxRollDeg * DEG_TO_RAD * 2;
1153
+ return clamp(leanTargetRad, -maxTotal, maxTotal);
1154
+ }
605
1155
 
606
- // -- L2.h: Publish pose channels --------------------------------
1156
+ /**
1157
+ * Snapshot the per-tick "what is the body doing" information into the
1158
+ * pose channels for downstream consumption (skeleton, sound, AI).
1159
+ * Read-only with respect to controller state — this is purely a publish
1160
+ * step.
1161
+ *
1162
+ * @private
1163
+ * @param {FirstPersonPlayerController} controller
1164
+ * @param {PerEntityRuntime} runtime
1165
+ * @param {Transform} bodyTransform
1166
+ */
1167
+ _publishPose(controller, runtime, bodyTransform) {
1168
+ const cfg = controller.config;
1169
+ const state = controller.state;
607
1170
  const pose = controller.pose;
1171
+
608
1172
  pose.rootPosition.copy(bodyTransform.position);
609
1173
  pose.rootYawRad = runtime.bodyYaw;
610
1174
  pose.headYawRad = runtime.bodyYaw;
611
1175
  pose.headPitchRad = runtime.eyePitch;
612
1176
  pose.headRollRad = state.leanRollRad;
613
1177
  pose.locomotionPhase = state.stridePhase;
614
- pose.locomotionSpeed = horizSpeed;
1178
+ pose.locomotionSpeed = runtime.horizSpeed;
615
1179
  // Strafe component: project velocity onto screen-right (-cos θ, 0, sin θ).
616
- // Positive = moving to the player's right (animation blend-space convention).
617
- pose.locomotionStrafe = (runtime.velocityX * (-cosYaw) + runtime.velocityZ * sinYaw)
1180
+ // Positive = moving to the player's right.
1181
+ pose.locomotionStrafe = (runtime.velocityX * (-runtime.cosYaw) + runtime.velocityZ * runtime.sinYaw)
618
1182
  / Math.max(cfg.motion.sprintSpeed, 1e-3);
619
1183
  pose.actionState =
620
1184
  state.inJumpAnticipation ? FirstPersonActionState.Anticipating
621
1185
  : !state.grounded ? FirstPersonActionState.Airborne
622
1186
  : (Math.abs(runtime.landSpring.value) > 0.01 ? FirstPersonActionState.Landing
623
1187
  : FirstPersonActionState.Grounded);
1188
+ pose.locomotionMode = state.locomotionMode;
624
1189
  const crouchSpan = Math.max(cfg.body.height - cfg.body.crouchHeight, 1e-3);
625
1190
  pose.crouchAmount = clamp((cfg.body.height - state.eyeHeight) / crouchSpan, 0, 1);
626
- pose.aimPitch = runtime.eyePitch;
627
- }
628
1191
 
629
- /**
630
- * @private
631
- * @param {FirstPersonPlayerController} controller
632
- * @param {PerEntityRuntime} runtime
633
- * @returns {boolean}
634
- */
635
- _resolveCrouchHeld(controller, runtime) {
636
- const cfg = controller.config;
637
- const intent = controller.intent;
638
-
639
- if (cfg.crouch.mode === "toggle") {
640
- // Edge: rising press flips the latch
641
- if (intent.crouch && !runtime.prevCrouchHeld) {
642
- runtime.crouchLatched = !runtime.crouchLatched;
643
- }
644
- runtime.prevCrouchHeld = intent.crouch;
645
- return runtime.crouchLatched;
1192
+ // Posture channel for downstream animation: which body shape +
1193
+ // how far the body is into it from the standing neutral.
1194
+ //
1195
+ // `posture` is the enum (Stand / Crouch / Prone / Hang) — picks
1196
+ // the animation track. `postureAmount` is the [0..1] blend
1197
+ // weight from standing toward that posture, derived from the
1198
+ // eye-height spring so the value transitions smoothly across
1199
+ // changes (matches the visible camera motion).
1200
+ pose.posture = state.posture;
1201
+ let postureTargetH;
1202
+ switch (state.posture) {
1203
+ case FirstPersonPosture.Prone: postureTargetH = cfg.body.proneHeight; break;
1204
+ case FirstPersonPosture.Crouch: postureTargetH = cfg.body.crouchHeight; break;
1205
+ case FirstPersonPosture.Hang: postureTargetH = cfg.body.height; break;
1206
+ case FirstPersonPosture.Stand:
1207
+ default: postureTargetH = cfg.body.height; break;
646
1208
  }
647
- // "hold" mode
648
- runtime.prevCrouchHeld = intent.crouch;
649
- return intent.crouch;
1209
+ const postureSpan = Math.max(cfg.body.height - postureTargetH, 1e-3);
1210
+ pose.postureAmount = clamp((cfg.body.height - state.eyeHeight) / postureSpan, 0, 1);
1211
+
1212
+ pose.aimPitch = runtime.eyePitch;
650
1213
  }
651
1214
 
652
1215
  /**
@@ -672,40 +1235,71 @@ export class FirstPersonPlayerControllerSystem extends System {
672
1235
  const camera = ecd.getComponent(controller.eyeEntity, Camera);
673
1236
  if (eyeTransform === undefined || camera === undefined) return;
674
1237
 
675
- // -- Body-local eye offset --------------------------------------
676
- const eyeLocal = SCRATCH_V3_A.set(0, state.eyeHeight, 0);
677
-
678
- // Bob only when grounded & moving
679
- if (state.grounded && state.speed > cfg.bob.minStepSpeed) {
680
- const phase = state.stridePhase * TWO_PI; // one full cycle = 2 steps
1238
+ // -- Body-local eye offset, composed via the additive stack ----
1239
+ // The base (0, eyeHeight, 0) is the standing/crouched neutral; each
1240
+ // additional contribution (bob, breath, landing, anticipation,
1241
+ // sprint posture) goes through the stack so external systems can
1242
+ // push their own contributions on the same channel.
1243
+ const stack = runtime.eyeOffsetStack;
1244
+ stack.clear();
1245
+ stack.push("eyeHeight", 0, state.eyeHeight, 0);
1246
+
1247
+ // Bob — gated on grounded only (the impact spring decays naturally
1248
+ // even at rest, so the bob fade-out is smooth; lateral amp uses the
1249
+ // bob-intensity envelope which spring-decays after stopping).
1250
+ if (state.grounded) {
1251
+ const phase = state.stridePhase * TWO_PI;
681
1252
  const massBoost = (cfg.body.mass - 80) * cfg.bob.ampMassScale;
682
- const ampV = (cfg.bob.verticalAmpAtWalk + massBoost) * state.speedNormalized;
683
- const ampL = (cfg.bob.lateralAmpAtWalk + massBoost) * state.speedNormalized;
684
-
685
- eyeLocal.y += -ampV * Math.abs(Math.sin(phase));
686
- eyeLocal.x += ampL * Math.sin(phase * 0.5);
1253
+ const intensity = runtime.bobIntensitySpring.value;
1254
+
1255
+ // Back-pedal amp boost — lateral grows more than vertical because
1256
+ // backward gait has worse side-to-side balance than vertical compression.
1257
+ // Exertion adds a smaller boost on top: tired = wobbly gait.
1258
+ const ampLMult = 1 + (cfg.bob.backwardLateralAmpFactor - 1) * runtime.backwardness;
1259
+ const exertionBoost = 1 + cfg.exertion.bobLateralBoostAtMax * state.exertion;
1260
+ const ampL = (cfg.bob.lateralAmpAtWalk + massBoost) * intensity * ampLMult * exertionBoost;
1261
+
1262
+ // Vertical: read directly from the impact spring (footfall kicks,
1263
+ // under-damped recovery → trough + leg-push overshoot).
1264
+ stack.push("bob.impact", 0, runtime.verticalImpactSpring.value, 0);
1265
+
1266
+ // Lateral: head shifts toward the foot bearing weight. Polarity
1267
+ // sourced from runtime.standingFoot — the same signal the
1268
+ // footstep emits — so bob direction and footstep side agree.
1269
+ // |sin(phase)| is the non-negative "midstance envelope".
1270
+ const lateralPolarity = runtime.standingFoot === "R" ? -1 : 1;
1271
+ stack.push("bob.lateral", ampL * lateralPolarity * Math.abs(Math.sin(phase)), 0, 0);
687
1272
  }
688
1273
 
689
- // Breath — sine + tiny noise
1274
+ // Breath — sine + tiny noise riding the rate spring.
690
1275
  const breathOffset = -state.breathAmplitudeM
691
1276
  * Math.sin(state.breathPhase * TWO_PI)
692
1277
  * (1 + cfg.breath.noiseAmount * (Math.sin(state.breathPhase * 13.7) * 0.5));
693
- eyeLocal.y += breathOffset;
1278
+ stack.push("breath", 0, breathOffset, 0);
694
1279
 
695
- // Landing spring dip
696
- eyeLocal.y += runtime.landSpring.value;
1280
+ // Landing spring dip (under-damped — overshoots once on recovery).
1281
+ stack.push("landing", 0, runtime.landSpring.value, 0);
697
1282
 
698
- // Jump anticipation dip (linear ramp during anticipation)
1283
+ // Jump anticipation dip (eased ramp during the squash window).
699
1284
  if (state.inJumpAnticipation) {
700
1285
  const t = 1 - clamp(runtime.anticipationRemaining / Math.max(cfg.jump.anticipation.duration, 1e-3), 0, 1);
701
- // Ease-out: t * (2 - t)
702
- const eased = t * (2 - t);
703
- eyeLocal.y -= cfg.jump.anticipation.dipAmount * eased;
1286
+ const eased = t * (2 - t); // ease-out quad
1287
+ stack.push("anticipation", 0, -cfg.jump.anticipation.dipAmount * eased, 0);
704
1288
  }
705
1289
 
706
- // Transform body-local offset into world space (body has yaw only,
707
- // so rotate by bodyTransform.rotation around Y)
708
- const worldOffset = SCRATCH_V3_B.copy(eyeLocal);
1290
+ // Sprint posture: head leans slightly forward as commitment builds.
1291
+ // Pitch part is in the rotation block below; the +Z position shift
1292
+ // sells "head leading the hips" (Mirror's Edge), tied to the same
1293
+ // spring envelope so they move together.
1294
+ const sprintPitch = runtime.sprintPostureSpring.value;
1295
+ const sprintShiftFraction =
1296
+ cfg.posture.sprintForwardPitchDeg > 0
1297
+ ? sprintPitch / (cfg.posture.sprintForwardPitchDeg * DEG_TO_RAD)
1298
+ : 0;
1299
+ stack.push("posture.sprintShift", 0, 0, cfg.posture.sprintForwardShiftM * sprintShiftFraction);
1300
+
1301
+ // Transform body-local accumulated offset into world space.
1302
+ const worldOffset = SCRATCH_V3_B.copy(stack.offset);
709
1303
  worldOffset.applyQuaternion(bodyTransform.rotation);
710
1304
 
711
1305
  eyeTransform.position.copy(bodyTransform.position);
@@ -717,16 +1311,42 @@ export class FirstPersonPlayerControllerSystem extends System {
717
1311
  // breath; merged into the main pitch so we don't pay an extra quat
718
1312
  // multiply and the composition stays trivially correct.
719
1313
  let rollTotal = state.leanRollRad;
720
- if (state.grounded && state.speed > cfg.bob.minStepSpeed) {
1314
+ if (state.grounded) {
1315
+ // Roll: head tilts toward the standing foot, in phase with the
1316
+ // lateral sway. Polarity sourced from runtime.standingFoot for
1317
+ // consistency with the lateral bob. Positive engine roll = head
1318
+ // tilts RIGHT (camera-invert convention), so R-foot midstance =
1319
+ // positive roll, L-foot midstance = negative roll.
721
1320
  const phase = state.stridePhase * TWO_PI;
722
- const ampRoll = cfg.bob.rollAtWalkDeg * DEG_TO_RAD * state.speedNormalized;
723
- rollTotal += ampRoll * Math.sin(phase * 0.5);
1321
+ const rollBackMult = 1 + (cfg.bob.backwardRollFactor - 1) * runtime.backwardness;
1322
+ const ampRoll = cfg.bob.rollAtWalkDeg * DEG_TO_RAD * runtime.bobIntensitySpring.value * rollBackMult;
1323
+ const rollPolarity = runtime.standingFoot === "R" ? 1 : -1;
1324
+ const rollEnvelope = Math.abs(Math.sin(phase));
1325
+ const bobRollSigned = ampRoll * rollPolarity * rollEnvelope;
1326
+
1327
+ // Lean × bob coupling: excursions in the lean direction get
1328
+ // amplified, opposite excursions attenuated. Lean is normalized
1329
+ // against maxRollDeg so the coupling magnitude stays bounded
1330
+ // regardless of how aggressively lean is configured.
1331
+ const maxLeanRad = Math.max(cfg.lean.maxRollDeg * DEG_TO_RAD, 1e-6);
1332
+ const leanFraction = clamp(state.leanRollRad / maxLeanRad, -1, 1);
1333
+ // sign(bobRollSigned) matches lean? amplify; else attenuate.
1334
+ const sameSign = (bobRollSigned * leanFraction) >= 0;
1335
+ const couplingMag = cfg.bob.leanCouplingFactor * Math.abs(leanFraction);
1336
+ const couplingScale = sameSign ? (1 + couplingMag) : (1 - couplingMag);
1337
+ rollTotal += bobRollSigned * couplingScale;
724
1338
  }
725
1339
 
726
1340
  const breathPitch = lerp(cfg.breath.pitchAmpRestDeg, cfg.breath.pitchAmpMaxDeg, state.exertion)
727
1341
  * DEG_TO_RAD
728
1342
  * Math.cos(state.breathPhase * TWO_PI);
729
- const pitchTotal = runtime.eyePitch + breathPitch;
1343
+ // Combined pitch contributions: player input + breath nod + sprint
1344
+ // commitment + fatigue droop. All in the same "positive = look-down"
1345
+ // convention so they sum cleanly.
1346
+ const pitchTotal = runtime.eyePitch
1347
+ + breathPitch
1348
+ + runtime.sprintPostureSpring.value
1349
+ + runtime.headDroopSpring.value;
730
1350
 
731
1351
  // composition: yaw * pitch * roll
732
1352
  // pitch around world X — yaw applied after, so effective axis is camera-local right
@@ -741,14 +1361,11 @@ export class FirstPersonPlayerControllerSystem extends System {
741
1361
  // -- FOV ---------------------------------------------------------
742
1362
  let fovTarget = cfg.fov.base;
743
1363
  if (cfg.fov.sprintAdd !== 0) {
744
- // Add proportionally at sprint-speed cap we hit sprintAdd
745
- const sprintness = clamp((state.speed - cfg.motion.walkSpeed)
746
- / Math.max(cfg.motion.sprintSpeed - cfg.motion.walkSpeed, 1e-3), 0, 1);
747
- fovTarget += cfg.fov.sprintAdd * sprintness;
1364
+ fovTarget += cfg.fov.sprintAdd * runtime.sprintness;
748
1365
  }
749
1366
  if (state.crouchActive) fovTarget += cfg.fov.crouchAdd;
750
1367
 
751
- criticallyDampedSpringStep(runtime.fovSpring, fovTarget, cfg.fov.smoothHalfLife, dt);
1368
+ runtime.fovSpring.stepTo(fovTarget, cfg.fov.smoothHalfLife, 1.0, dt);
752
1369
  // Write directly to the underlying Three.js camera. Going through
753
1370
  // camera.fov.set() fires onChanged which triggers a full camera
754
1371
  // rebuild in CameraSystem — far too expensive to do per frame.