@woosh/meep-engine 2.138.20 → 2.140.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 (584) hide show
  1. package/package.json +1 -1
  2. package/src/core/bvh2/bvh3/query/bvh_query_user_data_overlaps_aabb.d.ts +3 -3
  3. package/src/core/bvh2/bvh3/query/bvh_query_user_data_overlaps_aabb.d.ts.map +1 -1
  4. package/src/core/bvh2/bvh3/query/bvh_query_user_data_overlaps_aabb.js +4 -4
  5. package/src/core/collection/PairUint32Map.d.ts +100 -0
  6. package/src/core/collection/PairUint32Map.d.ts.map +1 -0
  7. package/src/core/collection/PairUint32Map.js +321 -0
  8. package/src/core/collection/Uint32Map.d.ts +119 -0
  9. package/src/core/collection/Uint32Map.d.ts.map +1 -0
  10. package/src/core/collection/Uint32Map.js +345 -0
  11. package/src/core/collection/array/array_shuffle.d.ts +10 -3
  12. package/src/core/collection/array/array_shuffle.d.ts.map +1 -1
  13. package/src/core/collection/array/array_shuffle.js +27 -22
  14. package/src/core/collection/heap/FibonacciHeap.d.ts +195 -0
  15. package/src/core/collection/heap/FibonacciHeap.d.ts.map +1 -0
  16. package/src/core/collection/heap/FibonacciHeap.js +586 -0
  17. package/src/core/collection/heap/Uint32Heap.js +1 -1
  18. package/src/core/collection/heap/Uint32Heap4.d.ts +169 -0
  19. package/src/core/collection/heap/Uint32Heap4.d.ts.map +1 -0
  20. package/src/core/collection/heap/Uint32Heap4.js +490 -0
  21. package/src/core/geom/3d/aabb/aabb3_transform_oriented.d.ts +30 -0
  22. package/src/core/geom/3d/aabb/aabb3_transform_oriented.d.ts.map +1 -0
  23. package/src/core/geom/3d/aabb/aabb3_transform_oriented.js +93 -0
  24. package/src/core/geom/3d/line/line3_closest_points_segment_segment.d.ts +27 -0
  25. package/src/core/geom/3d/line/line3_closest_points_segment_segment.d.ts.map +1 -0
  26. package/src/core/geom/3d/line/line3_closest_points_segment_segment.js +88 -0
  27. package/src/core/geom/3d/quaternion/quat3_to_matrix3.d.ts +54 -0
  28. package/src/core/geom/3d/quaternion/quat3_to_matrix3.d.ts.map +1 -0
  29. package/src/core/geom/3d/quaternion/quat3_to_matrix3.js +69 -0
  30. package/src/core/geom/3d/shape/AbstractShape3D.d.ts +24 -2
  31. package/src/core/geom/3d/shape/AbstractShape3D.d.ts.map +1 -1
  32. package/src/core/geom/3d/shape/AbstractShape3D.js +24 -1
  33. package/src/core/geom/3d/shape/BoxShape3D.d.ts +61 -0
  34. package/src/core/geom/3d/shape/BoxShape3D.d.ts.map +1 -0
  35. package/src/core/geom/3d/shape/BoxShape3D.js +158 -0
  36. package/src/core/geom/3d/shape/CapsuleShape3D.d.ts +11 -0
  37. package/src/core/geom/3d/shape/CapsuleShape3D.d.ts.map +1 -1
  38. package/src/core/geom/3d/shape/CapsuleShape3D.js +12 -0
  39. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts +148 -0
  40. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -0
  41. package/src/core/geom/3d/shape/HeightMapShape3D.js +451 -0
  42. package/src/core/geom/3d/shape/MeshShape3D.d.ts +210 -0
  43. package/src/core/geom/3d/shape/MeshShape3D.d.ts.map +1 -0
  44. package/src/core/geom/3d/shape/MeshShape3D.js +593 -0
  45. package/src/core/geom/3d/shape/TransformedShape3D.d.ts.map +1 -1
  46. package/src/core/geom/3d/shape/TransformedShape3D.js +46 -2
  47. package/src/core/geom/3d/shape/Triangle3D.d.ts +95 -0
  48. package/src/core/geom/3d/shape/Triangle3D.d.ts.map +1 -0
  49. package/src/core/geom/3d/shape/Triangle3D.js +318 -0
  50. package/src/core/geom/3d/shape/UnionShape3D.js +13 -0
  51. package/src/core/geom/3d/shape/UnitCubeShape3D.d.ts +37 -9
  52. package/src/core/geom/3d/shape/UnitCubeShape3D.d.ts.map +1 -1
  53. package/src/core/geom/3d/shape/UnitCubeShape3D.js +45 -98
  54. package/src/core/geom/3d/shape/UnitSphereShape3D.d.ts +10 -0
  55. package/src/core/geom/3d/shape/UnitSphereShape3D.d.ts.map +1 -1
  56. package/src/core/geom/3d/shape/UnitSphereShape3D.js +11 -0
  57. package/src/core/geom/3d/shape/shape_mesh_from_geometry.d.ts +30 -0
  58. package/src/core/geom/3d/shape/shape_mesh_from_geometry.d.ts.map +1 -0
  59. package/src/core/geom/3d/shape/shape_mesh_from_geometry.js +64 -0
  60. package/src/core/geom/3d/shape/util/shape3d_voxelize_to_grid.d.ts +61 -0
  61. package/src/core/geom/3d/shape/util/shape3d_voxelize_to_grid.d.ts.map +1 -0
  62. package/src/core/geom/3d/shape/util/shape3d_voxelize_to_grid.js +148 -0
  63. package/src/core/geom/3d/tetrahedra/compute_tetrahedral_mesh_from_surface.d.ts +39 -0
  64. package/src/core/geom/3d/tetrahedra/compute_tetrahedral_mesh_from_surface.d.ts.map +1 -0
  65. package/src/core/geom/3d/tetrahedra/compute_tetrahedral_mesh_from_surface.js +147 -0
  66. package/src/core/geom/3d/tetrahedra/compute_tetrahedron_quality.d.ts +15 -0
  67. package/src/core/geom/3d/tetrahedra/compute_tetrahedron_quality.d.ts.map +1 -0
  68. package/src/core/geom/3d/tetrahedra/compute_tetrahedron_quality.js +22 -0
  69. package/src/core/geom/3d/tetrahedra/prototype_tetrahedrize_mesh.d.ts +2 -0
  70. package/src/core/geom/3d/tetrahedra/prototype_tetrahedrize_mesh.d.ts.map +1 -0
  71. package/src/core/geom/3d/tetrahedra/prototype_tetrahedrize_mesh.js +671 -0
  72. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_build_vertex_to_tets_map.d.ts +28 -0
  73. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_build_vertex_to_tets_map.d.ts.map +1 -0
  74. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_build_vertex_to_tets_map.js +48 -0
  75. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_carve_outside_surface.d.ts +26 -0
  76. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_carve_outside_surface.d.ts.map +1 -0
  77. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_carve_outside_surface.js +222 -0
  78. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_find_tets_around_edge.d.ts +34 -0
  79. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_find_tets_around_edge.d.ts.map +1 -0
  80. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_find_tets_around_edge.js +146 -0
  81. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_flip_23.d.ts +36 -0
  82. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_flip_23.d.ts.map +1 -0
  83. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_flip_23.js +232 -0
  84. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_flip_32.d.ts +33 -0
  85. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_flip_32.d.ts.map +1 -0
  86. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_flip_32.js +255 -0
  87. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_improve_quality.d.ts +68 -0
  88. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_improve_quality.d.ts.map +1 -0
  89. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_improve_quality.js +387 -0
  90. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_smooth_vertex.d.ts +35 -0
  91. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_smooth_vertex.d.ts.map +1 -0
  92. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_smooth_vertex.js +140 -0
  93. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_vertex_is_boundary.d.ts +31 -0
  94. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_vertex_is_boundary.d.ts.map +1 -0
  95. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_vertex_is_boundary.js +97 -0
  96. package/src/core/geom/3d/tetrahedra/tetrahedron_compute_quality.d.ts +32 -0
  97. package/src/core/geom/3d/tetrahedra/tetrahedron_compute_quality.d.ts.map +1 -0
  98. package/src/core/geom/3d/tetrahedra/tetrahedron_compute_quality.js +66 -0
  99. package/src/core/geom/3d/topology/struct/binary/BinaryElementPool.d.ts +41 -0
  100. package/src/core/geom/3d/topology/struct/binary/BinaryElementPool.d.ts.map +1 -1
  101. package/src/core/geom/3d/topology/struct/binary/BinaryElementPool.js +124 -13
  102. package/src/core/geom/3d/topology/struct/binary/BinaryTopology.d.ts +134 -0
  103. package/src/core/geom/3d/topology/struct/binary/BinaryTopology.d.ts.map +1 -1
  104. package/src/core/geom/3d/topology/struct/binary/BinaryTopology.js +276 -3
  105. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_close_boundary_holes.d.ts +17 -0
  106. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_close_boundary_holes.d.ts.map +1 -0
  107. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_close_boundary_holes.js +135 -0
  108. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_compact.d.ts +14 -0
  109. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_compact.d.ts.map +1 -0
  110. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_compact.js +177 -0
  111. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_face_decouple.d.ts.map +1 -1
  112. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_face_decouple.js +20 -4
  113. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_simplify.d.ts.map +1 -1
  114. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_simplify.js +5 -3
  115. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_create.d.ts.map +1 -1
  116. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_create.js +9 -0
  117. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_get_or_create.d.ts.map +1 -1
  118. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_get_or_create.js +21 -45
  119. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_kill.d.ts.map +1 -1
  120. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_kill.js +7 -1
  121. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_kill_parallels.d.ts +8 -6
  122. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_kill_parallels.d.ts.map +1 -1
  123. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_kill_parallels.js +8 -6
  124. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_mesh_kill_short_edges.d.ts +22 -0
  125. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_mesh_kill_short_edges.d.ts.map +1 -0
  126. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_mesh_kill_short_edges.js +73 -0
  127. package/src/core/geom/3d/topology/struct/binary/io/vertex/bt_vertex_replace.d.ts.map +1 -1
  128. package/src/core/geom/3d/topology/struct/binary/io/vertex/bt_vertex_replace.js +51 -1
  129. package/src/core/geom/3d/topology/struct/binary/query/bt_edge_get.d.ts +10 -0
  130. package/src/core/geom/3d/topology/struct/binary/query/bt_edge_get.d.ts.map +1 -0
  131. package/src/core/geom/3d/topology/struct/binary/query/bt_edge_get.js +42 -0
  132. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_sample_interior_grid_points.d.ts +28 -0
  133. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_sample_interior_grid_points.d.ts.map +1 -0
  134. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_sample_interior_grid_points.js +227 -0
  135. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_walk_boundary_loops.d.ts +13 -0
  136. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_walk_boundary_loops.d.ts.map +1 -0
  137. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_walk_boundary_loops.js +108 -0
  138. package/src/core/geom/3d/topology/struct/binary/query/bt_query_edge_is_boundary.d.ts +11 -0
  139. package/src/core/geom/3d/topology/struct/binary/query/bt_query_edge_is_boundary.d.ts.map +1 -0
  140. package/src/core/geom/3d/topology/struct/binary/query/bt_query_edge_is_boundary.js +20 -0
  141. package/src/core/geom/3d/triangle/triangle_mesh_compute_signed_volume.d.ts +20 -0
  142. package/src/core/geom/3d/triangle/triangle_mesh_compute_signed_volume.d.ts.map +1 -0
  143. package/src/core/geom/3d/triangle/triangle_mesh_compute_signed_volume.js +38 -0
  144. package/src/core/geom/3d/triangle/v3_compute_triangle_normal.d.ts +2 -2
  145. package/src/core/geom/3d/triangle/v3_compute_triangle_normal.d.ts.map +1 -1
  146. package/src/core/geom/3d/triangle/v3_compute_triangle_normal.js +1 -1
  147. package/src/core/geom/vec3/v3_dot_array_array.d.ts +3 -3
  148. package/src/core/geom/vec3/v3_dot_array_array.d.ts.map +1 -1
  149. package/src/core/geom/vec3/v3_dot_array_array.js +2 -2
  150. package/src/core/geom/vec3/v3_negate_array.d.ts +3 -3
  151. package/src/core/geom/vec3/v3_negate_array.d.ts.map +1 -1
  152. package/src/core/geom/vec3/v3_negate_array.js +2 -2
  153. package/src/core/geom/vec3/v3_quat3_apply.d.ts +29 -0
  154. package/src/core/geom/vec3/v3_quat3_apply.d.ts.map +1 -0
  155. package/src/core/geom/vec3/v3_quat3_apply.js +39 -0
  156. package/src/core/geom/vec3/v3_quat3_apply_inverse.d.ts +30 -0
  157. package/src/core/geom/vec3/v3_quat3_apply_inverse.d.ts.map +1 -0
  158. package/src/core/geom/vec3/v3_quat3_apply_inverse.js +41 -0
  159. package/src/core/geom/vec3/v3_triple_cross_product.d.ts +32 -0
  160. package/src/core/geom/vec3/v3_triple_cross_product.d.ts.map +1 -0
  161. package/src/core/geom/vec3/v3_triple_cross_product.js +45 -0
  162. package/src/core/graph/csr/CSRGraph.d.ts +168 -0
  163. package/src/core/graph/csr/CSRGraph.d.ts.map +1 -0
  164. package/src/core/graph/csr/CSRGraph.js +319 -0
  165. package/src/core/graph/metis/cluster_mesh_metis.d.ts +12 -0
  166. package/src/core/graph/metis/cluster_mesh_metis.d.ts.map +1 -1
  167. package/src/core/graph/metis/cluster_mesh_metis.js +12 -0
  168. package/src/core/graph/metis/metis.d.ts +19 -0
  169. package/src/core/graph/metis/metis.d.ts.map +1 -1
  170. package/src/core/graph/metis/metis.js +20 -0
  171. package/src/core/graph/metis/metis_cluster_bs.d.ts +11 -0
  172. package/src/core/graph/metis/metis_cluster_bs.d.ts.map +1 -1
  173. package/src/core/graph/metis/metis_cluster_bs.js +11 -0
  174. package/src/core/graph/metis/metis_options.d.ts +17 -2
  175. package/src/core/graph/metis/metis_options.d.ts.map +1 -1
  176. package/src/core/graph/metis/metis_options.js +17 -2
  177. package/src/core/graph/metis/native/MetisGraph.d.ts +144 -0
  178. package/src/core/graph/metis/native/MetisGraph.d.ts.map +1 -0
  179. package/src/core/graph/metis/native/MetisGraph.js +212 -0
  180. package/src/core/graph/metis/native/bisection/BisectionScratch.d.ts +72 -0
  181. package/src/core/graph/metis/native/bisection/BisectionScratch.d.ts.map +1 -0
  182. package/src/core/graph/metis/native/bisection/BisectionScratch.js +101 -0
  183. package/src/core/graph/metis/native/bisection/bisect_graph.d.ts +37 -0
  184. package/src/core/graph/metis/native/bisection/bisect_graph.d.ts.map +1 -0
  185. package/src/core/graph/metis/native/bisection/bisect_graph.js +100 -0
  186. package/src/core/graph/metis/native/bisection/compute_2way_params.d.ts +15 -0
  187. package/src/core/graph/metis/native/bisection/compute_2way_params.d.ts.map +1 -0
  188. package/src/core/graph/metis/native/bisection/compute_2way_params.js +84 -0
  189. package/src/core/graph/metis/native/bisection/fm_2way.d.ts +30 -0
  190. package/src/core/graph/metis/native/bisection/fm_2way.d.ts.map +1 -0
  191. package/src/core/graph/metis/native/bisection/fm_2way.js +290 -0
  192. package/src/core/graph/metis/native/bisection/grow_bisection.d.ts +23 -0
  193. package/src/core/graph/metis/native/bisection/grow_bisection.d.ts.map +1 -0
  194. package/src/core/graph/metis/native/bisection/grow_bisection.js +137 -0
  195. package/src/core/graph/metis/native/bisection/split_graph_two_way.d.ts +28 -0
  196. package/src/core/graph/metis/native/bisection/split_graph_two_way.d.ts.map +1 -0
  197. package/src/core/graph/metis/native/bisection/split_graph_two_way.js +119 -0
  198. package/src/core/graph/metis/native/coarsen/coarsen_graph.d.ts +20 -0
  199. package/src/core/graph/metis/native/coarsen/coarsen_graph.d.ts.map +1 -0
  200. package/src/core/graph/metis/native/coarsen/coarsen_graph.js +94 -0
  201. package/src/core/graph/metis/native/coarsen/create_coarse_graph.d.ts +24 -0
  202. package/src/core/graph/metis/native/coarsen/create_coarse_graph.d.ts.map +1 -0
  203. package/src/core/graph/metis/native/coarsen/create_coarse_graph.js +158 -0
  204. package/src/core/graph/metis/native/coarsen/match_shem.d.ts +41 -0
  205. package/src/core/graph/metis/native/coarsen/match_shem.d.ts.map +1 -0
  206. package/src/core/graph/metis/native/coarsen/match_shem.js +175 -0
  207. package/src/core/graph/metis/native/initial/initial_kway_bfs.d.ts +24 -0
  208. package/src/core/graph/metis/native/initial/initial_kway_bfs.d.ts.map +1 -0
  209. package/src/core/graph/metis/native/initial/initial_kway_bfs.js +122 -0
  210. package/src/core/graph/metis/native/initial/initial_kway_recursive_bisection.d.ts +29 -0
  211. package/src/core/graph/metis/native/initial/initial_kway_recursive_bisection.d.ts.map +1 -0
  212. package/src/core/graph/metis/native/initial/initial_kway_recursive_bisection.js +170 -0
  213. package/src/core/graph/metis/native/metis_partition_kway.d.ts +41 -0
  214. package/src/core/graph/metis/native/metis_partition_kway.d.ts.map +1 -0
  215. package/src/core/graph/metis/native/metis_partition_kway.js +126 -0
  216. package/src/core/graph/metis/native/refine/IndexedFloatMaxHeap.d.ts +62 -0
  217. package/src/core/graph/metis/native/refine/IndexedFloatMaxHeap.d.ts.map +1 -0
  218. package/src/core/graph/metis/native/refine/IndexedFloatMaxHeap.js +261 -0
  219. package/src/core/graph/metis/native/refine/RefinementScratch.d.ts +45 -0
  220. package/src/core/graph/metis/native/refine/RefinementScratch.d.ts.map +1 -0
  221. package/src/core/graph/metis/native/refine/RefinementScratch.js +53 -0
  222. package/src/core/graph/metis/native/refine/compute_kway_params.d.ts +18 -0
  223. package/src/core/graph/metis/native/refine/compute_kway_params.d.ts.map +1 -0
  224. package/src/core/graph/metis/native/refine/compute_kway_params.js +138 -0
  225. package/src/core/graph/metis/native/refine/fm_kway.d.ts +63 -0
  226. package/src/core/graph/metis/native/refine/fm_kway.d.ts.map +1 -0
  227. package/src/core/graph/metis/native/refine/fm_kway.js +462 -0
  228. package/src/core/graph/metis/native/refine/project_kway.d.ts +22 -0
  229. package/src/core/graph/metis/native/refine/project_kway.d.ts.map +1 -0
  230. package/src/core/graph/metis/native/refine/project_kway.js +43 -0
  231. package/src/core/graph/metis/native/refine/refine_kway.d.ts +34 -0
  232. package/src/core/graph/metis/native/refine/refine_kway.d.ts.map +1 -0
  233. package/src/core/graph/metis/native/refine/refine_kway.js +43 -0
  234. package/src/core/math/linalg/eigen/matrix_householder_in_place.d.ts +2 -2
  235. package/src/core/math/linalg/eigen/matrix_householder_in_place.js +2 -2
  236. package/src/core/math/linalg/eigen/matrix_qr_in_place.d.ts +6 -4
  237. package/src/core/math/linalg/eigen/matrix_qr_in_place.d.ts.map +1 -1
  238. package/src/core/math/linalg/eigen/matrix_qr_in_place.js +69 -23
  239. package/src/engine/EngineHarness.d.ts +3 -1
  240. package/src/engine/EngineHarness.d.ts.map +1 -1
  241. package/src/engine/EngineHarness.js +3 -0
  242. package/src/engine/control/first-person/DESIGN.md +30 -6
  243. package/src/engine/control/first-person/DESIGN_EXTENSIONS.md +563 -0
  244. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts +115 -9
  245. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts.map +1 -1
  246. package/src/engine/control/first-person/FirstPersonPlayerController.js +211 -176
  247. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts +601 -8
  248. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts.map +1 -1
  249. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.js +349 -8
  250. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts +319 -23
  251. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts.map +1 -1
  252. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js +1789 -799
  253. package/src/engine/control/first-person/TODO.md +173 -0
  254. package/src/engine/control/first-person/abilities/Ability.d.ts +101 -0
  255. package/src/engine/control/first-person/abilities/Ability.d.ts.map +1 -0
  256. package/src/engine/control/first-person/abilities/Ability.js +119 -0
  257. package/src/engine/control/first-person/abilities/AbilitySet.d.ts +86 -0
  258. package/src/engine/control/first-person/abilities/AbilitySet.d.ts.map +1 -0
  259. package/src/engine/control/first-person/abilities/AbilitySet.js +185 -0
  260. package/src/engine/control/first-person/abilities/LedgeGrab.d.ts +62 -0
  261. package/src/engine/control/first-person/abilities/LedgeGrab.d.ts.map +1 -0
  262. package/src/engine/control/first-person/abilities/LedgeGrab.js +199 -0
  263. package/src/engine/control/first-person/abilities/Mantle.d.ts +45 -0
  264. package/src/engine/control/first-person/abilities/Mantle.d.ts.map +1 -0
  265. package/src/engine/control/first-person/abilities/Mantle.js +188 -0
  266. package/src/engine/control/first-person/abilities/Slide.d.ts +33 -0
  267. package/src/engine/control/first-person/abilities/Slide.d.ts.map +1 -0
  268. package/src/engine/control/first-person/abilities/Slide.js +166 -0
  269. package/src/engine/control/first-person/abilities/WallJump.d.ts +45 -0
  270. package/src/engine/control/first-person/abilities/WallJump.d.ts.map +1 -0
  271. package/src/engine/control/first-person/abilities/WallJump.js +131 -0
  272. package/src/engine/control/first-person/abilities/WallRun.d.ts +44 -0
  273. package/src/engine/control/first-person/abilities/WallRun.d.ts.map +1 -0
  274. package/src/engine/control/first-person/abilities/WallRun.js +180 -0
  275. package/src/engine/control/first-person/composer/EyeOffsetStack.d.ts +49 -0
  276. package/src/engine/control/first-person/composer/EyeOffsetStack.d.ts.map +1 -0
  277. package/src/engine/control/first-person/composer/EyeOffsetStack.js +60 -0
  278. package/src/engine/control/first-person/mastery/BreathRhythmEvaluator.d.ts +100 -0
  279. package/src/engine/control/first-person/mastery/BreathRhythmEvaluator.d.ts.map +1 -0
  280. package/src/engine/control/first-person/mastery/BreathRhythmEvaluator.js +133 -0
  281. package/src/engine/control/first-person/mastery/DecisionPoint.d.ts +10 -0
  282. package/src/engine/control/first-person/mastery/DecisionPoint.d.ts.map +1 -0
  283. package/src/engine/control/first-person/mastery/DecisionPoint.js +30 -0
  284. package/src/engine/control/first-person/mastery/FootAsymmetryTurnEvaluator.d.ts +61 -0
  285. package/src/engine/control/first-person/mastery/FootAsymmetryTurnEvaluator.d.ts.map +1 -0
  286. package/src/engine/control/first-person/mastery/FootAsymmetryTurnEvaluator.js +109 -0
  287. package/src/engine/control/first-person/mastery/MasteryEvaluator.d.ts +40 -0
  288. package/src/engine/control/first-person/mastery/MasteryEvaluator.d.ts.map +1 -0
  289. package/src/engine/control/first-person/mastery/MasteryEvaluator.js +45 -0
  290. package/src/engine/control/first-person/mastery/MasteryScore.d.ts +68 -0
  291. package/src/engine/control/first-person/mastery/MasteryScore.d.ts.map +1 -0
  292. package/src/engine/control/first-person/mastery/MasteryScore.js +100 -0
  293. package/src/engine/control/first-person/mastery/MasterySet.d.ts +60 -0
  294. package/src/engine/control/first-person/mastery/MasterySet.d.ts.map +1 -0
  295. package/src/engine/control/first-person/mastery/MasterySet.js +86 -0
  296. package/src/engine/control/first-person/mastery/SlideInitiationTimingEvaluator.d.ts +58 -0
  297. package/src/engine/control/first-person/mastery/SlideInitiationTimingEvaluator.d.ts.map +1 -0
  298. package/src/engine/control/first-person/mastery/SlideInitiationTimingEvaluator.js +83 -0
  299. package/src/engine/control/first-person/mastery/StrideTimingJumpEvaluator.d.ts +69 -0
  300. package/src/engine/control/first-person/mastery/StrideTimingJumpEvaluator.d.ts.map +1 -0
  301. package/src/engine/control/first-person/mastery/StrideTimingJumpEvaluator.js +109 -0
  302. package/src/engine/control/first-person/math/Spring.d.ts +56 -0
  303. package/src/engine/control/first-person/math/Spring.d.ts.map +1 -0
  304. package/src/engine/control/first-person/math/Spring.js +71 -0
  305. package/src/engine/control/first-person/math/computeLRCBreathRate.d.ts +26 -0
  306. package/src/engine/control/first-person/math/computeLRCBreathRate.d.ts.map +1 -0
  307. package/src/engine/control/first-person/math/computeLRCBreathRate.js +41 -0
  308. package/src/engine/control/first-person/math/computeMassRatios.d.ts +35 -0
  309. package/src/engine/control/first-person/math/computeMassRatios.d.ts.map +1 -0
  310. package/src/engine/control/first-person/math/computeMassRatios.js +44 -0
  311. package/src/engine/control/first-person/pose/FirstPersonPose.d.ts +31 -1
  312. package/src/engine/control/first-person/pose/FirstPersonPose.d.ts.map +1 -1
  313. package/src/engine/control/first-person/pose/FirstPersonPose.js +49 -3
  314. package/src/engine/control/first-person/pose/FirstPersonPosture.d.ts +7 -0
  315. package/src/engine/control/first-person/pose/FirstPersonPosture.d.ts.map +1 -0
  316. package/src/engine/control/first-person/pose/FirstPersonPosture.js +27 -0
  317. package/src/engine/control/first-person/prototype_first_person_controller.js +637 -120
  318. package/src/engine/control/first-person/sensors/FirstPersonSensors.d.ts +58 -0
  319. package/src/engine/control/first-person/sensors/FirstPersonSensors.d.ts.map +1 -0
  320. package/src/engine/control/first-person/sensors/FirstPersonSensors.js +77 -0
  321. package/src/engine/control/first-person/sensors/FirstPersonSensorsSystem.d.ts +80 -0
  322. package/src/engine/control/first-person/sensors/FirstPersonSensorsSystem.d.ts.map +1 -0
  323. package/src/engine/control/first-person/sensors/FirstPersonSensorsSystem.js +196 -0
  324. package/src/engine/control/first-person/test/buildTestPlayer.d.ts +20 -0
  325. package/src/engine/control/first-person/test/buildTestPlayer.d.ts.map +1 -0
  326. package/src/engine/control/first-person/test/buildTestPlayer.js +36 -0
  327. package/src/engine/graphics/camera/testClippingPlaneComputation.js +0 -2
  328. package/src/engine/graphics/ecs/light/Light.d.ts.map +1 -1
  329. package/src/engine/graphics/ecs/light/Light.js +27 -0
  330. package/src/engine/graphics/ecs/light/LightSystem.js +1 -1
  331. package/src/engine/graphics/ecs/path/PathDisplaySystem.d.ts.map +1 -1
  332. package/src/engine/graphics/ecs/path/testPathDisplaySystem.js +0 -2
  333. package/src/engine/graphics/ecs/path/tube/prototypeAnimatedPathMask.js +0 -2
  334. package/src/engine/graphics/geometry/CapsuleGeometry.d.ts +42 -0
  335. package/src/engine/graphics/geometry/CapsuleGeometry.d.ts.map +1 -0
  336. package/src/engine/graphics/geometry/CapsuleGeometry.js +171 -0
  337. package/src/engine/graphics/render/buffer/buffers/prototypeNormalFrameBuffer.js +0 -2
  338. package/src/engine/graphics/render/forward_plus/plugin/ptototypeFPPlugin.js +0 -2
  339. package/src/engine/graphics/render/visibility/hiz/prototypeHiZ.js +0 -2
  340. package/src/engine/navigation/grid/find_path_on_grid_astar.d.ts.map +1 -1
  341. package/src/engine/navigation/grid/find_path_on_grid_astar.js +11 -2
  342. package/src/engine/navigation/mesh/bt_mesh_face_find_path.d.ts.map +1 -1
  343. package/src/engine/navigation/mesh/bt_mesh_face_find_path.js +11 -1
  344. package/src/engine/physics/BULLET_REVIEW.md +945 -0
  345. package/src/engine/physics/CANNON_REVIEW.md +1300 -0
  346. package/src/engine/physics/JOLT_REVIEW.md +913 -0
  347. package/src/engine/physics/PLAN.md +461 -0
  348. package/src/engine/physics/RAPIER_REVIEW.md +934 -0
  349. package/src/engine/physics/REVIEW_001_ACTION_PLAN.md +642 -0
  350. package/src/engine/physics/body/BodyStorage.d.ts +187 -0
  351. package/src/engine/physics/body/BodyStorage.d.ts.map +1 -0
  352. package/src/engine/physics/body/BodyStorage.js +427 -0
  353. package/src/engine/physics/broadphase/PairList.d.ts +62 -0
  354. package/src/engine/physics/broadphase/PairList.d.ts.map +1 -0
  355. package/src/engine/physics/broadphase/PairList.js +97 -0
  356. package/src/engine/physics/broadphase/compute_fat_world_aabb.d.ts +16 -0
  357. package/src/engine/physics/broadphase/compute_fat_world_aabb.d.ts.map +1 -0
  358. package/src/engine/physics/broadphase/compute_fat_world_aabb.js +61 -0
  359. package/src/engine/physics/broadphase/generate_pairs.d.ts +38 -0
  360. package/src/engine/physics/broadphase/generate_pairs.d.ts.map +1 -0
  361. package/src/engine/physics/broadphase/generate_pairs.js +101 -0
  362. package/src/engine/physics/contact/ManifoldStore.d.ts +299 -0
  363. package/src/engine/physics/contact/ManifoldStore.d.ts.map +1 -0
  364. package/src/engine/physics/contact/ManifoldStore.js +608 -0
  365. package/src/engine/physics/ecs/BodyKind.d.ts +23 -0
  366. package/src/engine/physics/ecs/BodyKind.d.ts.map +1 -0
  367. package/src/engine/physics/ecs/BodyKind.js +24 -0
  368. package/src/engine/physics/ecs/Collider.d.ts +98 -0
  369. package/src/engine/physics/ecs/Collider.d.ts.map +1 -0
  370. package/src/engine/physics/ecs/Collider.js +136 -0
  371. package/src/engine/physics/ecs/ColliderFlags.d.ts +14 -0
  372. package/src/engine/physics/ecs/ColliderFlags.d.ts.map +1 -0
  373. package/src/engine/physics/ecs/ColliderFlags.js +15 -0
  374. package/src/engine/physics/ecs/ColliderObserverSystem.d.ts +58 -0
  375. package/src/engine/physics/ecs/ColliderObserverSystem.d.ts.map +1 -0
  376. package/src/engine/physics/ecs/ColliderObserverSystem.js +103 -0
  377. package/src/engine/physics/ecs/ColliderSerializationAdapter.d.ts +25 -0
  378. package/src/engine/physics/ecs/ColliderSerializationAdapter.d.ts.map +1 -0
  379. package/src/engine/physics/ecs/ColliderSerializationAdapter.js +37 -0
  380. package/src/engine/physics/ecs/PhysicsEvents.d.ts +15 -0
  381. package/src/engine/physics/ecs/PhysicsEvents.d.ts.map +1 -0
  382. package/src/engine/physics/ecs/PhysicsEvents.js +16 -0
  383. package/src/engine/physics/ecs/PhysicsSystem.d.ts +628 -0
  384. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -0
  385. package/src/engine/physics/ecs/PhysicsSystem.js +1301 -0
  386. package/src/engine/physics/ecs/RigidBody.d.ts +197 -0
  387. package/src/engine/physics/ecs/RigidBody.d.ts.map +1 -0
  388. package/src/engine/physics/ecs/RigidBody.js +240 -0
  389. package/src/engine/physics/ecs/RigidBodyFlags.d.ts +21 -0
  390. package/src/engine/physics/ecs/RigidBodyFlags.d.ts.map +1 -0
  391. package/src/engine/physics/ecs/RigidBodyFlags.js +22 -0
  392. package/src/engine/physics/ecs/RigidBodySerializationAdapter.d.ts +28 -0
  393. package/src/engine/physics/ecs/RigidBodySerializationAdapter.d.ts.map +1 -0
  394. package/src/engine/physics/ecs/RigidBodySerializationAdapter.js +81 -0
  395. package/src/engine/physics/ecs/SleepState.d.ts +11 -0
  396. package/src/engine/physics/ecs/SleepState.d.ts.map +1 -0
  397. package/src/engine/physics/ecs/SleepState.js +12 -0
  398. package/src/engine/physics/events/ContactEventBuffer.d.ts +46 -0
  399. package/src/engine/physics/events/ContactEventBuffer.d.ts.map +1 -0
  400. package/src/engine/physics/events/ContactEventBuffer.js +83 -0
  401. package/src/engine/physics/events/diff_manifolds.d.ts +25 -0
  402. package/src/engine/physics/events/diff_manifolds.d.ts.map +1 -0
  403. package/src/engine/physics/events/diff_manifolds.js +50 -0
  404. package/src/engine/physics/fluid/FluidField.d.ts +294 -16
  405. package/src/engine/physics/fluid/FluidField.d.ts.map +1 -1
  406. package/src/engine/physics/fluid/FluidField.js +510 -66
  407. package/src/engine/physics/fluid/FluidSimulator.d.ts +188 -5
  408. package/src/engine/physics/fluid/FluidSimulator.d.ts.map +1 -1
  409. package/src/engine/physics/fluid/FluidSimulator.js +456 -95
  410. package/src/engine/physics/fluid/SliceVisualiser.d.ts +29 -6
  411. package/src/engine/physics/fluid/SliceVisualiser.d.ts.map +1 -1
  412. package/src/engine/physics/fluid/SliceVisualiser.js +190 -165
  413. package/src/engine/physics/fluid/ecs/FluidComponent.d.ts +154 -0
  414. package/src/engine/physics/fluid/ecs/FluidComponent.d.ts.map +1 -0
  415. package/src/engine/physics/fluid/ecs/FluidComponent.js +238 -0
  416. package/src/engine/physics/fluid/ecs/FluidEffectorsComponent.d.ts +45 -0
  417. package/src/engine/physics/fluid/ecs/FluidEffectorsComponent.d.ts.map +1 -0
  418. package/src/engine/physics/fluid/ecs/FluidEffectorsComponent.js +89 -0
  419. package/src/engine/physics/fluid/ecs/FluidSystem.d.ts +107 -0
  420. package/src/engine/physics/fluid/ecs/FluidSystem.d.ts.map +1 -0
  421. package/src/engine/physics/fluid/ecs/FluidSystem.js +278 -0
  422. package/src/engine/physics/fluid/effector/AbstractFluidEffector.d.ts +62 -1
  423. package/src/engine/physics/fluid/effector/AbstractFluidEffector.d.ts.map +1 -1
  424. package/src/engine/physics/fluid/effector/AbstractFluidEffector.js +81 -6
  425. package/src/engine/physics/fluid/effector/GlobalFluidEffector.d.ts +17 -4
  426. package/src/engine/physics/fluid/effector/GlobalFluidEffector.d.ts.map +1 -1
  427. package/src/engine/physics/fluid/effector/GlobalFluidEffector.js +105 -12
  428. package/src/engine/physics/fluid/effector/ImpulseFluidEffector.d.ts +43 -0
  429. package/src/engine/physics/fluid/effector/ImpulseFluidEffector.d.ts.map +1 -0
  430. package/src/engine/physics/fluid/effector/ImpulseFluidEffector.js +210 -0
  431. package/src/engine/physics/fluid/effector/WakeFluidEffector.d.ts +62 -1
  432. package/src/engine/physics/fluid/effector/WakeFluidEffector.d.ts.map +1 -1
  433. package/src/engine/physics/fluid/effector/WakeFluidEffector.js +302 -8
  434. package/src/engine/physics/fluid/prototype.js +102 -91
  435. package/src/engine/physics/fluid/solver/optimal_sor_omega.d.ts +33 -0
  436. package/src/engine/physics/fluid/solver/optimal_sor_omega.d.ts.map +1 -0
  437. package/src/engine/physics/fluid/solver/optimal_sor_omega.js +41 -0
  438. package/src/engine/physics/fluid/solver/v3_grid_apply_advection_forward.d.ts +20 -5
  439. package/src/engine/physics/fluid/solver/v3_grid_apply_advection_forward.d.ts.map +1 -1
  440. package/src/engine/physics/fluid/solver/v3_grid_apply_advection_forward.js +60 -38
  441. package/src/engine/physics/fluid/solver/v3_grid_apply_diffusion.d.ts +25 -4
  442. package/src/engine/physics/fluid/solver/v3_grid_apply_diffusion.d.ts.map +1 -1
  443. package/src/engine/physics/fluid/solver/v3_grid_apply_diffusion.js +93 -73
  444. package/src/engine/physics/fluid/solver/v3_grid_apply_scalar_advection.d.ts +23 -0
  445. package/src/engine/physics/fluid/solver/v3_grid_apply_scalar_advection.d.ts.map +1 -0
  446. package/src/engine/physics/fluid/solver/v3_grid_apply_scalar_advection.js +60 -0
  447. package/src/engine/physics/fluid/solver/v3_grid_compute_divergence.d.ts +23 -0
  448. package/src/engine/physics/fluid/solver/v3_grid_compute_divergence.d.ts.map +1 -0
  449. package/src/engine/physics/fluid/solver/v3_grid_compute_divergence.js +68 -0
  450. package/src/engine/physics/fluid/solver/v3_grid_compute_solid_neighbour_mask.d.ts +30 -0
  451. package/src/engine/physics/fluid/solver/v3_grid_compute_solid_neighbour_mask.d.ts.map +1 -0
  452. package/src/engine/physics/fluid/solver/v3_grid_compute_solid_neighbour_mask.js +66 -0
  453. package/src/engine/physics/fluid/solver/v3_grid_patch_edges_uniform.d.ts +26 -0
  454. package/src/engine/physics/fluid/solver/v3_grid_patch_edges_uniform.d.ts.map +1 -0
  455. package/src/engine/physics/fluid/solver/v3_grid_patch_edges_uniform.js +113 -0
  456. package/src/engine/physics/fluid/solver/v3_grid_shift_in_place.d.ts +30 -0
  457. package/src/engine/physics/fluid/solver/v3_grid_shift_in_place.d.ts.map +1 -0
  458. package/src/engine/physics/fluid/solver/v3_grid_shift_in_place.js +107 -0
  459. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure.d.ts +49 -0
  460. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure.d.ts.map +1 -0
  461. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure.js +126 -0
  462. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_pcg.d.ts +93 -0
  463. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_pcg.d.ts.map +1 -0
  464. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_pcg.js +424 -0
  465. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.d.ts +48 -0
  466. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.d.ts.map +1 -0
  467. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.js +92 -0
  468. package/src/engine/physics/gjk/expanding_polytope_algorithm.d.ts +6 -6
  469. package/src/engine/physics/gjk/expanding_polytope_algorithm.d.ts.map +1 -1
  470. package/src/engine/physics/gjk/expanding_polytope_algorithm.js +76 -32
  471. package/src/engine/physics/gjk/gjk.d.ts +28 -2
  472. package/src/engine/physics/gjk/gjk.d.ts.map +1 -1
  473. package/src/engine/physics/gjk/gjk.js +421 -378
  474. package/src/engine/physics/gjk/minkowski_support.d.ts +37 -0
  475. package/src/engine/physics/gjk/minkowski_support.d.ts.map +1 -0
  476. package/src/engine/physics/gjk/minkowski_support.js +75 -0
  477. package/src/engine/physics/gjk/mpr.d.ts +56 -0
  478. package/src/engine/physics/gjk/mpr.d.ts.map +1 -0
  479. package/src/engine/physics/gjk/mpr.js +344 -0
  480. package/src/engine/physics/inertia/world_inverse_inertia.d.ts +44 -0
  481. package/src/engine/physics/inertia/world_inverse_inertia.d.ts.map +1 -0
  482. package/src/engine/physics/inertia/world_inverse_inertia.js +77 -0
  483. package/src/engine/physics/integration/integrate_position.d.ts +34 -0
  484. package/src/engine/physics/integration/integrate_position.d.ts.map +1 -0
  485. package/src/engine/physics/integration/integrate_position.js +79 -0
  486. package/src/engine/physics/integration/integrate_velocity.d.ts +55 -0
  487. package/src/engine/physics/integration/integrate_velocity.d.ts.map +1 -0
  488. package/src/engine/physics/integration/integrate_velocity.js +160 -0
  489. package/src/engine/physics/integration/quat_integrate.d.ts +27 -0
  490. package/src/engine/physics/integration/quat_integrate.d.ts.map +1 -0
  491. package/src/engine/physics/integration/quat_integrate.js +62 -0
  492. package/src/engine/physics/island/IslandBuilder.d.ts +167 -0
  493. package/src/engine/physics/island/IslandBuilder.d.ts.map +1 -0
  494. package/src/engine/physics/island/IslandBuilder.js +411 -0
  495. package/src/engine/physics/island/union_find.d.ts +51 -0
  496. package/src/engine/physics/island/union_find.d.ts.map +1 -0
  497. package/src/engine/physics/island/union_find.js +76 -0
  498. package/src/engine/physics/narrowphase/PosedShape.d.ts +51 -0
  499. package/src/engine/physics/narrowphase/PosedShape.d.ts.map +1 -0
  500. package/src/engine/physics/narrowphase/PosedShape.js +108 -0
  501. package/src/engine/physics/narrowphase/box_box_manifold.d.ts +32 -0
  502. package/src/engine/physics/narrowphase/box_box_manifold.d.ts.map +1 -0
  503. package/src/engine/physics/narrowphase/box_box_manifold.js +639 -0
  504. package/src/engine/physics/narrowphase/box_triangle_contact.d.ts +30 -0
  505. package/src/engine/physics/narrowphase/box_triangle_contact.d.ts.map +1 -0
  506. package/src/engine/physics/narrowphase/box_triangle_contact.js +811 -0
  507. package/src/engine/physics/narrowphase/capsule_contacts.d.ts +122 -0
  508. package/src/engine/physics/narrowphase/capsule_contacts.d.ts.map +1 -0
  509. package/src/engine/physics/narrowphase/capsule_contacts.js +462 -0
  510. package/src/engine/physics/narrowphase/capsule_triangle_contact.d.ts +71 -0
  511. package/src/engine/physics/narrowphase/capsule_triangle_contact.d.ts.map +1 -0
  512. package/src/engine/physics/narrowphase/capsule_triangle_contact.js +375 -0
  513. package/src/engine/physics/narrowphase/compute_penetration.d.ts +91 -0
  514. package/src/engine/physics/narrowphase/compute_penetration.d.ts.map +1 -0
  515. package/src/engine/physics/narrowphase/compute_penetration.js +396 -0
  516. package/src/engine/physics/narrowphase/decomposition/aabb_world_to_local.d.ts +35 -0
  517. package/src/engine/physics/narrowphase/decomposition/aabb_world_to_local.d.ts.map +1 -0
  518. package/src/engine/physics/narrowphase/decomposition/aabb_world_to_local.js +80 -0
  519. package/src/engine/physics/narrowphase/decomposition/decompose_to_triangles.d.ts +31 -0
  520. package/src/engine/physics/narrowphase/decomposition/decompose_to_triangles.d.ts.map +1 -0
  521. package/src/engine/physics/narrowphase/decomposition/decompose_to_triangles.js +55 -0
  522. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts +42 -0
  523. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts.map +1 -0
  524. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.js +204 -0
  525. package/src/engine/physics/narrowphase/decomposition/mesh_enumerate_triangles.d.ts +42 -0
  526. package/src/engine/physics/narrowphase/decomposition/mesh_enumerate_triangles.d.ts.map +1 -0
  527. package/src/engine/physics/narrowphase/decomposition/mesh_enumerate_triangles.js +94 -0
  528. package/src/engine/physics/narrowphase/decomposition/triangle_buffer_layout.d.ts +37 -0
  529. package/src/engine/physics/narrowphase/decomposition/triangle_buffer_layout.d.ts.map +1 -0
  530. package/src/engine/physics/narrowphase/decomposition/triangle_buffer_layout.js +37 -0
  531. package/src/engine/physics/narrowphase/narrowphase_step.d.ts +17 -0
  532. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -0
  533. package/src/engine/physics/narrowphase/narrowphase_step.js +1422 -0
  534. package/src/engine/physics/narrowphase/sphere_box_contact.d.ts +38 -0
  535. package/src/engine/physics/narrowphase/sphere_box_contact.d.ts.map +1 -0
  536. package/src/engine/physics/narrowphase/sphere_box_contact.js +123 -0
  537. package/src/engine/physics/narrowphase/sphere_sphere_contact.d.ts +26 -0
  538. package/src/engine/physics/narrowphase/sphere_sphere_contact.d.ts.map +1 -0
  539. package/src/engine/physics/narrowphase/sphere_sphere_contact.js +51 -0
  540. package/src/engine/physics/narrowphase/sphere_triangle_contact.d.ts +48 -0
  541. package/src/engine/physics/narrowphase/sphere_triangle_contact.d.ts.map +1 -0
  542. package/src/engine/physics/narrowphase/sphere_triangle_contact.js +143 -0
  543. package/src/engine/physics/queries/PhysicsSurfacePoint.d.ts +83 -0
  544. package/src/engine/physics/queries/PhysicsSurfacePoint.d.ts.map +1 -0
  545. package/src/engine/physics/queries/PhysicsSurfacePoint.js +100 -0
  546. package/src/engine/physics/queries/overlap_shape.d.ts +51 -0
  547. package/src/engine/physics/queries/overlap_shape.d.ts.map +1 -0
  548. package/src/engine/physics/queries/overlap_shape.js +183 -0
  549. package/src/engine/physics/queries/raycast.d.ts +20 -0
  550. package/src/engine/physics/queries/raycast.d.ts.map +1 -0
  551. package/src/engine/physics/queries/raycast.js +249 -0
  552. package/src/engine/physics/queries/shape_cast.d.ts +56 -0
  553. package/src/engine/physics/queries/shape_cast.d.ts.map +1 -0
  554. package/src/engine/physics/queries/shape_cast.js +387 -0
  555. package/src/engine/physics/solver/friction_cone.d.ts +16 -0
  556. package/src/engine/physics/solver/friction_cone.d.ts.map +1 -0
  557. package/src/engine/physics/solver/friction_cone.js +37 -0
  558. package/src/engine/physics/solver/solve_contacts.d.ts +122 -0
  559. package/src/engine/physics/solver/solve_contacts.d.ts.map +1 -0
  560. package/src/engine/physics/solver/solve_contacts.js +1016 -0
  561. package/src/core/geom/3d/topology/struct/binary/io/edge/OrderedEdge.d.ts +0 -34
  562. package/src/core/geom/3d/topology/struct/binary/io/edge/OrderedEdge.d.ts.map +0 -1
  563. package/src/core/geom/3d/topology/struct/binary/io/edge/OrderedEdge.js +0 -66
  564. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_mesh_calc_edges.d.ts +0 -2
  565. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_mesh_calc_edges.d.ts.map +0 -1
  566. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_mesh_calc_edges.js +0 -54
  567. package/src/core/geom/3d/topology/struct/binary/io/edge/get_or_create_edge_map.d.ts +0 -2
  568. package/src/core/geom/3d/topology/struct/binary/io/edge/get_or_create_edge_map.d.ts.map +0 -1
  569. package/src/core/geom/3d/topology/struct/binary/io/edge/get_or_create_edge_map.js +0 -26
  570. package/src/engine/ecs/components/Motion.d.ts +0 -21
  571. package/src/engine/ecs/components/Motion.d.ts.map +0 -1
  572. package/src/engine/ecs/components/Motion.js +0 -27
  573. package/src/engine/ecs/components/MotionSerializationAdapter.d.ts +0 -20
  574. package/src/engine/ecs/components/MotionSerializationAdapter.d.ts.map +0 -1
  575. package/src/engine/ecs/components/MotionSerializationAdapter.js +0 -26
  576. package/src/engine/ecs/systems/MotionSystem.d.ts +0 -9
  577. package/src/engine/ecs/systems/MotionSystem.d.ts.map +0 -1
  578. package/src/engine/ecs/systems/MotionSystem.js +0 -29
  579. package/src/engine/physics/fluid/Fluid.d.ts +0 -26
  580. package/src/engine/physics/fluid/Fluid.d.ts.map +0 -1
  581. package/src/engine/physics/fluid/Fluid.js +0 -221
  582. package/src/engine/physics/fluid/solver/v3_grid_apply_advection_reverse.d.ts +0 -7
  583. package/src/engine/physics/fluid/solver/v3_grid_apply_advection_reverse.d.ts.map +0 -1
  584. package/src/engine/physics/fluid/solver/v3_grid_apply_advection_reverse.js +0 -8
@@ -1,799 +1,1789 @@
1
- import Quaternion from "../../../core/geom/Quaternion.js";
2
- import Vector3 from "../../../core/geom/Vector3.js";
3
- import { clamp } from "../../../core/math/clamp.js";
4
- import { DEG_TO_RAD } from "../../../core/math/DEG_TO_RAD.js";
5
- import { lerp } from "../../../core/math/lerp.js";
6
- import { ResourceAccessKind } from "../../../core/model/ResourceAccessKind.js";
7
- import { ResourceAccessSpecification } from "../../../core/model/ResourceAccessSpecification.js";
8
- import { SerializationMetadata } from "../../ecs/components/SerializationMetadata.js";
9
- import Entity from "../../ecs/Entity.js";
10
- import { System } from "../../ecs/System.js";
11
- import { Transform } from "../../ecs/transform/Transform.js";
12
- import { Camera } from "../../graphics/ecs/camera/Camera.js";
13
- import { FirstPersonPlayerController } from "./FirstPersonPlayerController.js";
14
- import { computeJumpFromApex } from "./math/computeJumpFromApex.js";
15
- import { criticallyDampedSpringStep } from "./math/criticallyDampedSpring.js";
16
- import { dampedSpringStep } from "./math/dampedSpringStep.js";
17
- import { stepTowards } from "./math/stepTowards.js";
18
- import { FirstPersonActionState } from "./pose/FirstPersonPose.js";
19
-
20
- // ---------------------------------------------------------------------------
21
- // Scratch allocations reused per frame to avoid GC pressure
22
- // ---------------------------------------------------------------------------
23
- const SCRATCH_V3_A = new Vector3();
24
- const SCRATCH_V3_B = new Vector3();
25
- const SCRATCH_V3_C = new Vector3();
26
- const SCRATCH_Q_A = new Quaternion();
27
- const SCRATCH_Q_B = new Quaternion();
28
- const SCRATCH_Q_C = new Quaternion();
29
-
30
- const TWO_PI = Math.PI * 2;
31
- const LN2 = Math.log(2);
32
-
33
- /**
34
- * Per-entity runtime state the system maintains internally — too transient
35
- * even for {@link FirstPersonPlayerController}'s `state` member, because it
36
- * encodes input-edge bookkeeping and timer values the public surface should
37
- * never see directly.
38
- */
39
- class PerEntityRuntime {
40
- constructor() {
41
- /** Eye pitch in radians, clamped to config.look limits. */
42
- this.eyePitch = 0;
43
- /** Body yaw in radians (around world up). */
44
- this.bodyYaw = 0;
45
-
46
- /** Horizontal+vertical velocity. We integrate these inside the system
47
- * when no external physics layer is attached. */
48
- this.velocityX = 0;
49
- this.velocityY = 0;
50
- this.velocityZ = 0;
51
-
52
- /** Previous-tick jump intent for rising/falling edge detection. */
53
- this.prevJumpHeld = false;
54
- /** Previous-tick crouch intent for toggle-mode edge detection. */
55
- this.prevCrouchHeld = false;
56
- /** True while crouch toggle is latched on (used only in toggle mode). */
57
- this.crouchLatched = false;
58
-
59
- /** Remaining time in jump anticipation, or <= 0 if not anticipating. */
60
- this.anticipationRemaining = 0;
61
- /** Cached derived gravity (m/s^2) from peakHeight + timeToApex. */
62
- this.gravity = 9.81;
63
- /** Cached derived jump impulse (m/s upward). */
64
- 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 };
74
-
75
- /** Previous horizontal velocity for lateral acceleration → lean. */
76
- this.prevVelocityX = 0;
77
- this.prevVelocityZ = 0;
78
-
79
- /** Previous-tick grounded for edge detection. */
80
- this.prevGrounded = true;
81
- /** Vertical speed at moment of last "leave ground". */
82
- this.takeoffVy = 0;
83
- /** Max vertical position since last takeoff — for jump apex detection. */
84
- this.peakAltitude = 0;
85
- /** Set true once a jump has been launched; cleared on land. */
86
- this.midJump = false;
87
- /** Apex already fired for this airborne segment? */
88
- this.apexFired = false;
89
-
90
- /** Stride phase from previous fixed step — for footstep edge detection. */
91
- this.prevStridePhase = 0;
92
- /** Breath phase from previous fixed step — for inhale/exhale edge detection. */
93
- this.prevBreathPhase = 0;
94
- /** Which foot fires next flipped on each footstep signal. */
95
- this.nextFootSide = "R";
96
-
97
- /** Cached eye entity ID. -1 until link assigns it. */
98
- this.eyeEntity = -1;
99
- }
100
- }
101
-
102
- /**
103
- * Drives a first-person camera + body from intent fields. See sibling
104
- * DESIGN.md for goals, architecture, and the five processing layers (L0..L4).
105
- *
106
- * - fixedUpdate runs L1 (locomotion), L2 (pose state), and L4 (events) so
107
- * the simulation remains deterministic.
108
- * - update runs L3 (camera composition) at render rate so the eye is never
109
- * smoother than the screen.
110
- *
111
- * The system itself integrates a simple flat-floor at y = `config.gravity.magnitude > 0
112
- * ? state.groundY : -Infinity` for the prototype. A real physics layer should
113
- * write `state.grounded`/`state.groundNormal` from outside instead; the
114
- * built-in resolver is just a convenience to keep the controller usable
115
- * without dependencies.
116
- *
117
- * @author Alex Goldring
118
- * @copyright Company Named Limited (c) 2026
119
- */
120
- export class FirstPersonPlayerControllerSystem extends System {
121
- constructor() {
122
- super();
123
-
124
- this.dependencies = [FirstPersonPlayerController, Transform];
125
-
126
- this.components_used = [
127
- ResourceAccessSpecification.from(Transform, ResourceAccessKind.Write),
128
- ResourceAccessSpecification.from(Camera, ResourceAccessKind.Write),
129
- ];
130
-
131
- /**
132
- * Per-entity runtime, keyed by entity id.
133
- * @type {Map<number, PerEntityRuntime>}
134
- */
135
- this.runtime = new Map();
136
-
137
- /**
138
- * If true, the system clamps body y >= groundY and writes
139
- * state.grounded itself. Turn off when wiring a real physics layer.
140
- * @type {boolean}
141
- */
142
- this.useBuiltInFlatGround = true;
143
-
144
- /**
145
- * The flat-ground y for the built-in resolver. Ignored when
146
- * useBuiltInFlatGround is false.
147
- * @type {number}
148
- */
149
- this.groundY = 0;
150
- }
151
-
152
- /**
153
- * @param {FirstPersonPlayerController} controller
154
- * @param {Transform} bodyTransform
155
- * @param {number} entity
156
- */
157
- link(controller, bodyTransform, entity) {
158
- const ecd = this.entityManager.dataset;
159
-
160
- const runtime = new PerEntityRuntime();
161
- this.runtime.set(entity, runtime);
162
-
163
- // Derive gravity + jump impulse from designer-friendly params
164
- const derived = { gravity: 0, initialVelocity: 0 };
165
- computeJumpFromApex(controller.config.jump.peakHeight, controller.config.jump.timeToApex, derived);
166
- runtime.gravity = derived.gravity;
167
- runtime.jumpInitialVy = derived.initialVelocity;
168
-
169
- // Seed yaw from the starting body rotation. `toEulerAnglesYXZ`
170
- // returns (pitch, yaw, roll) — we only care about y.
171
- bodyTransform.rotation.toEulerAnglesYXZ(SCRATCH_V3_A);
172
- runtime.bodyYaw = SCRATCH_V3_A.y;
173
- runtime.eyePitch = 0;
174
-
175
- // Initialize springs to standing-eye-height baseline
176
- runtime.eyeHeightSpring.value = controller.config.body.height;
177
- runtime.fovSpring.value = controller.config.fov.base;
178
- controller.state.eyeHeight = controller.config.body.height;
179
-
180
- // Create eye entity if one wasn't supplied
181
- if (controller.eyeEntity === -1 || !ecd.entityExists(controller.eyeEntity)) {
182
- const eye = new Entity();
183
-
184
- const eyeTransform = new Transform();
185
- const baseEyePos = SCRATCH_V3_A.copy(bodyTransform.position);
186
- baseEyePos.y += controller.config.body.height;
187
- eyeTransform.position.copy(baseEyePos);
188
-
189
- const camera = new Camera();
190
- camera.active.set(true);
191
- camera.fov.set(controller.config.fov.base);
192
- camera.clip_near = 0.05;
193
- camera.clip_far = 1000;
194
- camera.autoClip = false;
195
-
196
- eye.add(eyeTransform);
197
- eye.add(camera);
198
- eye.add(SerializationMetadata.Transient);
199
-
200
- eye.build(ecd);
201
-
202
- controller.eyeEntity = eye.id;
203
- }
204
-
205
- runtime.eyeEntity = controller.eyeEntity;
206
- }
207
-
208
- /**
209
- * @param {FirstPersonPlayerController} controller
210
- * @param {Transform} bodyTransform
211
- * @param {number} entity
212
- */
213
- unlink(controller, bodyTransform, entity) {
214
- const ecd = this.entityManager.dataset;
215
-
216
- if (controller.eyeEntity !== -1 && ecd.entityExists(controller.eyeEntity)) {
217
- ecd.removeEntity(controller.eyeEntity);
218
- controller.eyeEntity = -1;
219
- }
220
-
221
- this.runtime.delete(entity);
222
- }
223
-
224
- /**
225
- * Deterministic simulation step L1 + L2 + L4.
226
- * @param {number} dt
227
- */
228
- fixedUpdate(dt) {
229
- const ecd = this.entityManager.dataset;
230
- if (ecd === null) return;
231
-
232
- this._currentDt = dt;
233
- ecd.traverseComponents(FirstPersonPlayerController, this._tickEntity, this);
234
- }
235
-
236
- /**
237
- * Variable-rate camera composition L3.
238
- * @param {number} dt
239
- */
240
- update(dt) {
241
- const ecd = this.entityManager.dataset;
242
- if (ecd === null) return;
243
-
244
- this._currentRenderDt = dt;
245
- ecd.traverseComponents(FirstPersonPlayerController, this._composeEye, this);
246
- }
247
-
248
- /**
249
- * @private
250
- * @param {FirstPersonPlayerController} controller
251
- * @param {number} entity
252
- */
253
- _tickEntity(controller, entity) {
254
- const ecd = this.entityManager.dataset;
255
- const runtime = this.runtime.get(entity);
256
- if (runtime === undefined) return;
257
-
258
- const dt = this._currentDt;
259
- const cfg = controller.config;
260
- const intent = controller.intent;
261
- const state = controller.state;
262
- const sig = controller.signals;
263
-
264
- const bodyTransform = ecd.getComponent(entity, Transform);
265
- if (bodyTransform === undefined) return;
266
-
267
- // -- L1.a: Consume look delta -----------------------------------
268
- // intent.look is zeroed after consume so accumulated input doesn't
269
- // re-apply on the next fixed step.
270
- //
271
- // Conventions (with raw mouse delta as the source — movementX/Y both
272
- // positive when moving right/down):
273
- // look.x > 0 ("mouse right") turn right
274
- // look.y > 0 ("mouse down") → look down (flipped by invertY)
275
- //
276
- // The yaw sign is negated because the engine uses left-handed
277
- // coordinates with +Z as forward; a positive Y-axis rotation takes
278
- // +Z toward +X, which presents to the player as a LEFT turn through
279
- // the Three.js camera (`quaternion_invert_orientation`). Negating
280
- // here gives the player-intuitive "mouse right turn right".
281
- const yawDelta = -intent.look.x;
282
- const pitchSign = cfg.look.invertY ? -1 : 1;
283
- const pitchDelta = intent.look.y * pitchSign;
284
- intent.look.set(0, 0);
285
-
286
- runtime.bodyYaw += yawDelta;
287
- // keep yaw bounded (purely cosmetic — sin/cos handle wraparound fine)
288
- if (runtime.bodyYaw > Math.PI) runtime.bodyYaw -= TWO_PI;
289
- else if (runtime.bodyYaw < -Math.PI) runtime.bodyYaw += TWO_PI;
290
-
291
- runtime.eyePitch = clamp(
292
- runtime.eyePitch + pitchDelta,
293
- cfg.look.pitchMinDeg * DEG_TO_RAD,
294
- cfg.look.pitchMaxDeg * DEG_TO_RAD,
295
- );
296
-
297
- // Write body yaw back to transform (pure yaw, no pitch on body)
298
- bodyTransform.rotation.fromAxisAngle(Vector3.up, runtime.bodyYaw);
299
-
300
- // -- L1.b: Speed selection --------------------------------------
301
- const isSprintIntent = intent.sprint && intent.move.y > 0.5 && state.grounded;
302
- const isCrouchActive = this._resolveCrouchHeld(controller, runtime);
303
-
304
- let targetSpeed;
305
- if (isCrouchActive) {
306
- targetSpeed = cfg.motion.crouchSpeed;
307
- } else if (isSprintIntent) {
308
- targetSpeed = cfg.motion.sprintSpeed;
309
- } else {
310
- targetSpeed = cfg.motion.walkSpeed;
311
- }
312
-
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).
324
- //
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);
329
-
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;
336
-
337
- const desiredVx = sinYaw * nmvY + -cosYaw * nmvX;
338
- const desiredVz = cosYaw * nmvY + sinYaw * nmvX;
339
-
340
- const desiredHorizontalVx = desiredVx * targetSpeed;
341
- const desiredHorizontalVz = desiredVz * targetSpeed;
342
-
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;
355
- }
356
-
357
- const maxStep = horizAccel * dt;
358
- runtime.velocityX = stepTowards(runtime.velocityX, desiredHorizontalVx, maxStep);
359
- runtime.velocityZ = stepTowards(runtime.velocityZ, desiredHorizontalVz, maxStep);
360
-
361
- // -- L1.e: Jump (edge-triggered, buffered, coyote-graced) -------
362
- const jumpPressedEdge = intent.jump && !runtime.prevJumpHeld;
363
- const jumpReleasedEdge = !intent.jump && runtime.prevJumpHeld;
364
- runtime.prevJumpHeld = intent.jump;
365
-
366
- if (jumpPressedEdge) {
367
- state.jumpBufferRemaining = cfg.jump.bufferTime;
368
- }
369
- state.jumpBufferRemaining = Math.max(0, state.jumpBufferRemaining - dt);
370
-
371
- const canJumpNow =
372
- (state.grounded || state.timeSinceGrounded < cfg.jump.coyoteTime)
373
- && state.jumpBufferRemaining > 0
374
- && !state.inJumpAnticipation
375
- && !runtime.midJump;
376
-
377
- if (canJumpNow) {
378
- // Begin anticipation squash; impulse fires after duration elapses
379
- state.inJumpAnticipation = true;
380
- runtime.anticipationRemaining = cfg.jump.anticipation.duration;
381
- state.jumpBufferRemaining = 0; // claimed
382
- }
383
-
384
- // Variable-height cut: only valid during ascent and once jump has launched
385
- if (jumpReleasedEdge && runtime.midJump && runtime.velocityY > 0) {
386
- state.isVariableJumpCut = true;
387
- }
388
-
389
- // Anticipation timer; impulse on completion
390
- if (state.inJumpAnticipation) {
391
- // If the entity goes airborne mid-anticipation (ground rug-pulled),
392
- // abandon the queued impulse fire onLeaveGround{fall} instead.
393
- if (!state.grounded) {
394
- state.inJumpAnticipation = false;
395
- runtime.anticipationRemaining = 0;
396
- } else {
397
- runtime.anticipationRemaining -= dt;
398
- if (runtime.anticipationRemaining <= 0) {
399
- runtime.velocityY = runtime.jumpInitialVy;
400
- runtime.midJump = true;
401
- runtime.apexFired = false;
402
- runtime.peakAltitude = bodyTransform.position.y;
403
- state.inJumpAnticipation = false;
404
- state.isVariableJumpCut = false;
405
- state.isAscending = true;
406
- controller.state.exertion = clamp(controller.state.exertion + cfg.exertion.jumpRise, 0, 1);
407
-
408
- sig.onJumpStart.send1({ peakHeight: cfg.jump.peakHeight });
409
- sig.onLeaveGround.send1({ reason: "jump" });
410
- }
411
- }
412
- }
413
-
414
- // -- L1.f: Gravity ---------------------------------------------
415
- let gMag = runtime.gravity;
416
- if (runtime.velocityY <= 0) {
417
- gMag *= cfg.jump.fallGravityMult;
418
- state.isAscending = false;
419
- } else if (state.isVariableJumpCut) {
420
- gMag *= cfg.jump.cutGravityMult;
421
- }
422
-
423
- runtime.velocityY -= gMag * dt;
424
-
425
- // -- L1.g: Integrate position ----------------------------------
426
- bodyTransform.position._add(
427
- runtime.velocityX * dt,
428
- runtime.velocityY * dt,
429
- runtime.velocityZ * dt,
430
- );
431
-
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);
436
-
437
- if (!state.grounded) {
438
- // Land
439
- const impactVy = -runtime.velocityY; // positive magnitude
440
- const kind = impactVy >= cfg.landing.hardThreshold ? "hard"
441
- : (impactVy >= cfg.landing.softThreshold ? "soft" : "soft");
442
- sig.onLand.send1({ verticalSpeed: impactVy, kind });
443
-
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;
448
-
449
- runtime.midJump = false;
450
- state.isAscending = false;
451
- state.isVariableJumpCut = false;
452
- state.fallDistance = 0;
453
- }
454
-
455
- state.grounded = true;
456
- state.verticalSpeed = 0;
457
- runtime.velocityY = 0;
458
- state.airborneTime = 0;
459
- state.timeSinceGrounded = 0;
460
- } else {
461
- if (state.grounded) {
462
- sig.onLeaveGround.send1({ reason: runtime.midJump ? "jump" : "fall" });
463
- runtime.takeoffVy = runtime.velocityY;
464
- runtime.peakAltitude = bodyTransform.position.y;
465
- }
466
- state.grounded = false;
467
- state.verticalSpeed = runtime.velocityY;
468
- state.airborneTime += dt;
469
- state.timeSinceGrounded += dt;
470
- state.fallDistance += Math.max(0, -runtime.velocityY * dt);
471
- }
472
- } else {
473
- // External physics is expected to maintain state.grounded /
474
- // state.verticalSpeed; we still track airborne timer.
475
- if (state.grounded) {
476
- state.timeSinceGrounded = 0;
477
- state.airborneTime = 0;
478
- } else {
479
- state.timeSinceGrounded += dt;
480
- state.airborneTime += dt;
481
- }
482
- }
483
-
484
- // Detect jump apex
485
- if (runtime.midJump && !runtime.apexFired) {
486
- if (bodyTransform.position.y > runtime.peakAltitude) {
487
- runtime.peakAltitude = bodyTransform.position.y;
488
- } else if (runtime.velocityY <= 0) {
489
- sig.onJumpApex.send0();
490
- runtime.apexFired = true;
491
- }
492
- }
493
-
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);
498
-
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";
508
- } else {
509
- state.moveMode = "Idle";
510
- }
511
-
512
- if (state.moveMode === "Sprint" && prevMoveMode !== "Sprint") {
513
- sig.onSprintStart.send0();
514
- } else if (prevMoveMode === "Sprint" && state.moveMode !== "Sprint") {
515
- sig.onSprintStop.send0();
516
- }
517
-
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)
534
-
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 });
541
- }
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);
551
- }
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
- }
566
- }
567
-
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;
573
-
574
- if (isCrouchActive !== state.crouchActive) {
575
- state.crouchActive = isCrouchActive;
576
- if (isCrouchActive) sig.onCrouchEnter.send0();
577
- else sig.onCrouchExit.send0();
578
- }
579
-
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;
591
- }
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
-
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
- );
605
-
606
- // -- L2.h: Publish pose channels --------------------------------
607
- const pose = controller.pose;
608
- pose.rootPosition.copy(bodyTransform.position);
609
- pose.rootYawRad = runtime.bodyYaw;
610
- pose.headYawRad = runtime.bodyYaw;
611
- pose.headPitchRad = runtime.eyePitch;
612
- pose.headRollRad = state.leanRollRad;
613
- pose.locomotionPhase = state.stridePhase;
614
- pose.locomotionSpeed = horizSpeed;
615
- // 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)
618
- / Math.max(cfg.motion.sprintSpeed, 1e-3);
619
- pose.actionState =
620
- state.inJumpAnticipation ? FirstPersonActionState.Anticipating
621
- : !state.grounded ? FirstPersonActionState.Airborne
622
- : (Math.abs(runtime.landSpring.value) > 0.01 ? FirstPersonActionState.Landing
623
- : FirstPersonActionState.Grounded);
624
- const crouchSpan = Math.max(cfg.body.height - cfg.body.crouchHeight, 1e-3);
625
- pose.crouchAmount = clamp((cfg.body.height - state.eyeHeight) / crouchSpan, 0, 1);
626
- pose.aimPitch = runtime.eyePitch;
627
- }
628
-
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;
646
- }
647
- // "hold" mode
648
- runtime.prevCrouchHeld = intent.crouch;
649
- return intent.crouch;
650
- }
651
-
652
- /**
653
- * Compose the eye transform from body + state-driven offsets.
654
- * @private
655
- * @param {FirstPersonPlayerController} controller
656
- * @param {number} entity
657
- */
658
- _composeEye(controller, entity) {
659
- const ecd = this.entityManager.dataset;
660
- const runtime = this.runtime.get(entity);
661
- if (runtime === undefined) return;
662
-
663
- const dt = this._currentRenderDt;
664
- const cfg = controller.config;
665
- const state = controller.state;
666
-
667
- const bodyTransform = ecd.getComponent(entity, Transform);
668
- if (bodyTransform === undefined) return;
669
-
670
- if (controller.eyeEntity === -1) return;
671
- const eyeTransform = ecd.getComponent(controller.eyeEntity, Transform);
672
- const camera = ecd.getComponent(controller.eyeEntity, Camera);
673
- if (eyeTransform === undefined || camera === undefined) return;
674
-
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
681
- 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);
687
- }
688
-
689
- // Breath sine + tiny noise
690
- const breathOffset = -state.breathAmplitudeM
691
- * Math.sin(state.breathPhase * TWO_PI)
692
- * (1 + cfg.breath.noiseAmount * (Math.sin(state.breathPhase * 13.7) * 0.5));
693
- eyeLocal.y += breathOffset;
694
-
695
- // Landing spring dip
696
- eyeLocal.y += runtime.landSpring.value;
697
-
698
- // Jump anticipation dip (linear ramp during anticipation)
699
- if (state.inJumpAnticipation) {
700
- 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;
704
- }
705
-
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);
709
- worldOffset.applyQuaternion(bodyTransform.rotation);
710
-
711
- eyeTransform.position.copy(bodyTransform.position);
712
- eyeTransform.position._add(worldOffset.x, worldOffset.y, worldOffset.z);
713
-
714
- // -- Eye rotation: body yaw × eye pitch × roll -------------------
715
- // Bob roll mixes in for a subtle head sway (in phase with lateral bob).
716
- // Breath pitch is a small extra nod 90° out of phase with vertical
717
- // breath; merged into the main pitch so we don't pay an extra quat
718
- // multiply and the composition stays trivially correct.
719
- let rollTotal = state.leanRollRad;
720
- if (state.grounded && state.speed > cfg.bob.minStepSpeed) {
721
- 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);
724
- }
725
-
726
- const breathPitch = lerp(cfg.breath.pitchAmpRestDeg, cfg.breath.pitchAmpMaxDeg, state.exertion)
727
- * DEG_TO_RAD
728
- * Math.cos(state.breathPhase * TWO_PI);
729
- const pitchTotal = runtime.eyePitch + breathPitch;
730
-
731
- // composition: yaw * pitch * roll
732
- // pitch around world X yaw applied after, so effective axis is camera-local right
733
- // roll around world Z — yaw and pitch applied after, so effective axis is camera-local forward
734
- const qYaw = SCRATCH_Q_A.fromAxisAngle(Vector3.up, runtime.bodyYaw);
735
- const qPitch = SCRATCH_Q_B.fromAxisAngle(Vector3.right, pitchTotal);
736
- const qRoll = SCRATCH_Q_C.fromAxisAngle(Vector3.forward, rollTotal);
737
-
738
- eyeTransform.rotation.multiplyQuaternions(qYaw, qPitch);
739
- eyeTransform.rotation.multiply(qRoll);
740
-
741
- // -- FOV ---------------------------------------------------------
742
- let fovTarget = cfg.fov.base;
743
- 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;
748
- }
749
- if (state.crouchActive) fovTarget += cfg.fov.crouchAdd;
750
-
751
- criticallyDampedSpringStep(runtime.fovSpring, fovTarget, cfg.fov.smoothHalfLife, dt);
752
- // Write directly to the underlying Three.js camera. Going through
753
- // camera.fov.set() fires onChanged which triggers a full camera
754
- // rebuild in CameraSystemfar too expensive to do per frame.
755
- // The CameraSystem's visibility-construction hook calls
756
- // updateProjectionMatrix() each frame anyway.
757
- if (camera.object !== null) {
758
- camera.object.fov = runtime.fovSpring.value;
759
- }
760
- }
761
- }
762
-
763
- // ---------------------------------------------------------------------------
764
- // helpers
765
- // ---------------------------------------------------------------------------
766
-
767
- /**
768
- * Exponential approach with half-life parameterization.
769
- * @param {number} current
770
- * @param {number} target
771
- * @param {number} halfLife
772
- * @param {number} dt
773
- * @returns {number}
774
- */
775
- function exponentialApproach(current, target, halfLife, dt) {
776
- if (halfLife <= 0) return target;
777
- const alpha = 1 - Math.exp(-LN2 * dt / halfLife);
778
- return current + (target - current) * alpha;
779
- }
780
-
781
- /**
782
- * Detect that phase value crossed a boundary in [0,1) between two ticks.
783
- * Handles the wraparound case where phase jumps from e.g. 0.95 to 0.05.
784
- *
785
- * @param {number} prev previous phase in [0,1)
786
- * @param {number} next current phase in [0,1)
787
- * @param {number} boundary in [0,1)
788
- * @returns {boolean}
789
- */
790
- function phaseCrossed(prev, next, boundary) {
791
- if (next >= prev) {
792
- // no wrap
793
- return prev < boundary && next >= boundary;
794
- } else {
795
- // wrapped past 1.0
796
- return prev < boundary || next >= boundary;
797
- }
798
- }
799
-
1
+ import { assert } from "../../../core/assert.js";
2
+ import Quaternion from "../../../core/geom/Quaternion.js";
3
+ import Vector3 from "../../../core/geom/Vector3.js";
4
+ import { clamp } from "../../../core/math/clamp.js";
5
+ import { DEG_TO_RAD } from "../../../core/math/DEG_TO_RAD.js";
6
+ import { lerp } from "../../../core/math/lerp.js";
7
+ import { ResourceAccessKind } from "../../../core/model/ResourceAccessKind.js";
8
+ import { ResourceAccessSpecification } from "../../../core/model/ResourceAccessSpecification.js";
9
+ import { SerializationMetadata } from "../../ecs/components/SerializationMetadata.js";
10
+ import Entity from "../../ecs/Entity.js";
11
+ import { System } from "../../ecs/System.js";
12
+ import { Transform } from "../../ecs/transform/Transform.js";
13
+ import { Camera } from "../../graphics/ecs/camera/Camera.js";
14
+ import { EyeOffsetStack } from "./composer/EyeOffsetStack.js";
15
+ import { Ray3 } from "../../../core/geom/3d/ray/Ray3.js";
16
+ import { CapsuleShape3D } from "../../../core/geom/3d/shape/CapsuleShape3D.js";
17
+ import { TransformedShape3D } from "../../../core/geom/3d/shape/TransformedShape3D.js";
18
+ import { BodyKind } from "../../physics/ecs/BodyKind.js";
19
+ import { Collider } from "../../physics/ecs/Collider.js";
20
+ import { PhysicsSystem } from "../../physics/ecs/PhysicsSystem.js";
21
+ import { RigidBody } from "../../physics/ecs/RigidBody.js";
22
+ import { PhysicsSurfacePoint } from "../../physics/queries/PhysicsSurfacePoint.js";
23
+ import { FirstPersonPlayerController } from "./FirstPersonPlayerController.js";
24
+ import { DecisionPoint } from "./mastery/DecisionPoint.js";
25
+ import { computeJumpFromApex } from "./math/computeJumpFromApex.js";
26
+ import { computeLRCBreathRate } from "./math/computeLRCBreathRate.js";
27
+ import { computeMassRatios } from "./math/computeMassRatios.js";
28
+ import { Spring } from "./math/Spring.js";
29
+ import { stepTowards } from "./math/stepTowards.js";
30
+ import { FirstPersonActionState, FirstPersonLocomotionMode } from "./pose/FirstPersonPose.js";
31
+ import { FirstPersonPosture } from "./pose/FirstPersonPosture.js";
32
+ import { FirstPersonSensors } from "./sensors/FirstPersonSensors.js";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Scratch allocations reused per frame to avoid GC pressure
36
+ // ---------------------------------------------------------------------------
37
+ const SCRATCH_V3_A = new Vector3();
38
+ const SCRATCH_V3_B = new Vector3();
39
+ const SCRATCH_V3_C = new Vector3();
40
+ const SCRATCH_Q_A = new Quaternion();
41
+ const SCRATCH_Q_B = new Quaternion();
42
+ const SCRATCH_Q_C = new Quaternion();
43
+
44
+ const TWO_PI = Math.PI * 2;
45
+ const LN2 = Math.log(2);
46
+
47
+ /**
48
+ * Build a posture-sized player capsule: a {@link CapsuleShape3D} of
49
+ * `radius` and the appropriate cylinder height, wrapped in a
50
+ * {@link TransformedShape3D} whose Y offset puts the capsule's bottom
51
+ * exactly at the wrapped shape's local origin. The entity's
52
+ * `transform.position` then represents the player's feet and a
53
+ * posture-driven shrink doesn't yank the feet up the way a centred
54
+ * capsule would, nor dip them below the floor.
55
+ *
56
+ * The capsule's lowest point in its own local frame is at
57
+ * `-(cylinderHeight/2 + radius) = -max(totalHeight/2, radius)`.
58
+ * Offsetting the wrapper by the magnitude of that puts the bottom at
59
+ * Y = 0:
60
+ * - Stand (`H = 1.8`, `r = 0.34`): cylHeight = 1.12, offset = 0.9.
61
+ * Bottom = -0.9 + 0.9 = 0. Top = +0.9 + 0.9 = 1.8.
62
+ * - Crouch (`H = 0.8`, `r = 0.34`): cylHeight = 0.12, offset = 0.4.
63
+ * Bottom = -0.4 + 0.4 = 0. Top = +0.4 + 0.4 = 0.8.
64
+ * - Prone (`H = 0.4`, `r = 0.34`): cylHeight = 0 (capsule collapses
65
+ * to a sphere of radius), offset = max(0.2, 0.34) = 0.34.
66
+ * Bottom = -0.34 + 0.34 = 0. Top = +0.34 + 0.34 = 0.68. The
67
+ * `totalHeight = 0.4` value is honoured for the offset budget
68
+ * but the actual Y extent floors at `2·radius`.
69
+ *
70
+ * Picking `totalHeight/2` blindly (the obvious choice) would put the
71
+ * Prone capsule's bottom at `0.2 - 0.34 = -0.14` — dipping below the
72
+ * feet, and into any physics floor that's flush with feet level. On
73
+ * a physics ground slab, every horizontal shape_cast from inside the
74
+ * floor returns t = 0, `advance = max(0, t - SKIN) = 0`, and the
75
+ * slide freezes in place see SlideMotion.spec.js for the
76
+ * regression test that pins this.
77
+ *
78
+ * @param {number} radius — capsule radius in metres
79
+ * @param {number} totalHeight desired full Y extent; ignored below
80
+ * `2·radius` (the capsule's intrinsic minimum extent)
81
+ * @returns {TransformedShape3D}
82
+ */
83
+ function makePostureCapsule(radius, totalHeight) {
84
+ const cylinderHeight = Math.max(0, totalHeight - 2 * radius);
85
+ const yOffset = Math.max(totalHeight / 2, radius);
86
+ return TransformedShape3D.from_translation(
87
+ CapsuleShape3D.from(radius, cylinderHeight),
88
+ [0, yOffset, 0],
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Per-entity runtime state the system maintains internally — too transient
94
+ * even for {@link FirstPersonPlayerController}'s `state` member, because it
95
+ * encodes input-edge bookkeeping and timer values the public surface should
96
+ * never see directly.
97
+ */
98
+ class PerEntityRuntime {
99
+ constructor() {
100
+ /**
101
+ * Co-attached kinematic body. Set by {@link FirstPersonPlayerControllerSystem.link}
102
+ * after asserting it's present. The controller writes Transform.position
103
+ * directly (existing motion logic); physics derives the body's velocity
104
+ * from the per-step delta. Other physics systems (raycasts, contact
105
+ * events) see the player through this body.
106
+ * @type {RigidBody|null}
107
+ */
108
+ this.rigidBody = null;
109
+
110
+ /**
111
+ * Co-attached collider, cached at link. Same source the physics
112
+ * narrowphase uses, so move-and-slide casts the player's
113
+ * actual collision shape against the world.
114
+ * @type {Collider|null}
115
+ */
116
+ this.collider = null;
117
+
118
+ /**
119
+ * Pre-allocated move-and-slide scratch — Ray3 and PhysicsSurfacePoint
120
+ * reused per cast so the controller doesn't churn the allocator
121
+ * each fixed step. Lazily filled by {@link _moveAndSlide}.
122
+ * @private
123
+ * @type {Ray3|null}
124
+ */
125
+ this.slideRay = null;
126
+ /** @private @type {PhysicsSurfacePoint|null} */
127
+ this.slideHit = null;
128
+
129
+ /**
130
+ * Pre-built capsule colliders, one per posture. Cached at link
131
+ * from `config.body.{height, crouchHeight, proneHeight, radius}`
132
+ * so {@link _syncColliderShape} can swap the collider's shape on
133
+ * a posture change with zero per-tick allocation. Hang reuses
134
+ * Stand (the player's body is full-extent, just hanging below
135
+ * the ledge — the rig animates the arms-up pose). Sentinel
136
+ * `lastPosture = -1` forces a sync on the first tick after
137
+ * link, so the initial shape always matches Stand.
138
+ * @private
139
+ * @type {TransformedShape3D|null}
140
+ */
141
+ this.colliderShapeStand = null;
142
+ /** @private @type {TransformedShape3D|null} */
143
+ this.colliderShapeCrouch = null;
144
+ /** @private @type {TransformedShape3D|null} */
145
+ this.colliderShapeProne = null;
146
+ /** @private */
147
+ this.lastPosture = -1;
148
+
149
+ /** Eye pitch in radians, clamped to config.look limits. */
150
+ this.eyePitch = 0;
151
+ /** Body yaw in radians (around world up). */
152
+ this.bodyYaw = 0;
153
+ /** Yaw rate (rad/s) computed in look consumption — for evaluators. */
154
+ this.yawRateRadPerSec = 0;
155
+
156
+ /** Horizontal+vertical velocity. We integrate these inside the system
157
+ * when no external physics layer is attached. */
158
+ this.velocityX = 0;
159
+ this.velocityY = 0;
160
+ this.velocityZ = 0;
161
+
162
+ /** Previous-tick jump intent — for rising/falling edge detection. */
163
+ this.prevJumpHeld = false;
164
+ /** Previous-tick crouch intent for toggle-mode edge detection. */
165
+ this.prevCrouchHeld = false;
166
+ /** True while crouch toggle is latched on (used only in toggle mode). */
167
+ this.crouchLatched = false;
168
+
169
+ /** Remaining time in jump anticipation, or <= 0 if not anticipating. */
170
+ this.anticipationRemaining = 0;
171
+ /** Cached derived gravity (m/s^2) from peakHeight + timeToApex. */
172
+ this.gravity = 9.81;
173
+ /** Cached derived jump impulse (m/s upward), post-mass-scaling. */
174
+ this.jumpInitialVy = 5.0;
175
+ /**
176
+ * Cached mass scaling factors — computed once at link. See
177
+ * {@link computeMassRatios}. Heavier ⇒ lower jumpV0Scale, lower
178
+ * groundAccelScale, higher landingDipScale + exertionRiseScale.
179
+ */
180
+ this.massRatios = null;
181
+
182
+ /** Spring for landing dip (under-damped → rings after impact). */
183
+ this.landSpring = new Spring();
184
+ /** Spring for FOV (critically damped). */
185
+ this.fovSpring = new Spring(70);
186
+ /** Spring for eye height (crouch transition). */
187
+ this.eyeHeightSpring = new Spring(1.80);
188
+ /** Spring for lean roll (radians) — banks into lateral acceleration. */
189
+ this.leanSpring = new Spring();
190
+ /**
191
+ * Lean target this tick (radians). Always set; L2.f spring-steps
192
+ * toward this value. Whoever owned motion this tick wrote it:
193
+ * base writes the lat-accel + look-lean derived value at the end
194
+ * of {@link _runBaseLocomotion}; abilities that want to override
195
+ * (WallRun → tilt-into-wall, Slide/Mantle/LedgeGrab → zero) write
196
+ * their own value in tick. Uniform channel — no null sentinel.
197
+ */
198
+ this.leanTargetRad = 0;
199
+
200
+ /** Previous horizontal velocity — for lateral acceleration → lean. */
201
+ this.prevVelocityX = 0;
202
+ this.prevVelocityZ = 0;
203
+
204
+ /** Previous-tick grounded for edge detection. */
205
+ this.prevGrounded = true;
206
+ /** Vertical speed at moment of last "leave ground". */
207
+ this.takeoffVy = 0;
208
+ /** Max vertical position since last takeoff — for jump apex detection. */
209
+ this.peakAltitude = 0;
210
+ /** Set true once a jump has been launched; cleared on land. */
211
+ this.midJump = false;
212
+ /** Apex already fired for this airborne segment? */
213
+ this.apexFired = false;
214
+
215
+ /** Stride phase from previous fixed step — for footstep edge detection. */
216
+ this.prevStridePhase = 0;
217
+ /** Breath phase from previous fixed step — for inhale/exhale edge detection. */
218
+ this.prevBreathPhase = 0;
219
+ /** Which foot fires next — flipped on each footstep signal. */
220
+ this.nextFootSide = "R";
221
+ /**
222
+ * Which foot is currently bearing the body's weight (the foot that
223
+ * most recently landed). Drives the lateral-bob direction: at R
224
+ * midstance the COM is over the right foot, so the head shifts
225
+ * laterally toward screen-right; at L midstance the opposite.
226
+ * Coupled to the same signal the footstep emits, so anything that
227
+ * listens to onFootStep.side will see the bob agree.
228
+ * Initialized "L" so the very first footstep fires "R" and the
229
+ * standingFoot updates to "R" — putting the head laterally right
230
+ * during the first half-stride, as expected.
231
+ */
232
+ this.standingFoot = "L";
233
+
234
+ /**
235
+ * [0..1] How "backward" the player is currently moving. Derived in
236
+ * fixedUpdate from velocity · screen-forward, normalized to sprint
237
+ * speed. Drives the gait wobble amplifier on the L3 camera-composition
238
+ * pass. Stored on runtime (rather than state) because it's a render-
239
+ * side input — downstream observers should look at velocity directly.
240
+ */
241
+ this.backwardness = 0;
242
+
243
+ /**
244
+ * Smoothed bob amplitude envelope. Target = max(speedNormalized,
245
+ * backwardness) when grounded, 0 airborne. Spring decay prevents
246
+ * the whiplash where stopping motion would snap the bob to neutral.
247
+ */
248
+ this.bobIntensitySpring = new Spring();
249
+
250
+ /**
251
+ * Vertical impact spring — kicked downward at each footfall, decays
252
+ * with a slight under-damped overshoot. Produces the impact-arrest +
253
+ * leg-push curve. value units: meters (added directly to eyeLocal.y).
254
+ */
255
+ this.verticalImpactSpring = new Spring();
256
+
257
+ /**
258
+ * Sprint-posture spring — eye pitches forward as the player commits
259
+ * to a sprint, returns to neutral when they slow. Value is in
260
+ * radians; slower half-life than other springs so it feels like
261
+ * a posture change rather than an input twitch. See cfg.posture.
262
+ */
263
+ this.sprintPostureSpring = new Spring();
264
+
265
+ /**
266
+ * Head-droop spring — additional forward pitch as exertion rises.
267
+ * Sells fatigue subtly. Target tracks exertion-driven max droop
268
+ * angle; spring lag keeps the transition slow and physical.
269
+ */
270
+ this.headDroopSpring = new Spring();
271
+
272
+ /**
273
+ * [0..1] sprintness — how much of the walksprint speed range the
274
+ * body is currently in. Computed in fixedUpdate, read by L3 for FOV
275
+ * and the sprint-posture pitch / forward-shift offset.
276
+ */
277
+ this.sprintness = 0;
278
+
279
+ /**
280
+ * Cached sin/cos of current body yaw written once per fixedUpdate
281
+ * after look intent is consumed, read by every downstream step
282
+ * (locomotion, backwardness, lean look-rate, pose channels). Avoids
283
+ * recomputing the trig 3+ times per tick.
284
+ */
285
+ this.sinYaw = 0;
286
+ this.cosYaw = 1;
287
+
288
+ /** Cached horizontal speed (m/s) for this tick — written in derived-state. */
289
+ this.horizSpeed = 0;
290
+
291
+ /** Cached stride frequency (Hz) for this tick — written in breath block, read by stride. */
292
+ this.strideFreqHz = 0;
293
+
294
+ /**
295
+ * Additive accumulator for body-local eye-position offsets. The
296
+ * system pushes its own contributions (bob, breath, landing,
297
+ * sprint posture) each render frame; external systems can push
298
+ * recoil/shake/knockback contributions via the same interface.
299
+ */
300
+ this.eyeOffsetStack = new EyeOffsetStack();
301
+
302
+ /**
303
+ * Spatial-query results populated by {@link FirstPersonSensorsSystem}
304
+ * (when present). Abilities and the locomotion FSM read this.
305
+ * Lives on runtime so other systems can populate it without
306
+ * touching the controller component's public surface.
307
+ */
308
+ this.sensors = new FirstPersonSensors();
309
+
310
+ /** Cached eye entity ID. -1 until link assigns it. */
311
+ this.eyeEntity = -1;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Drives a first-person camera + body from intent fields. See sibling
317
+ * DESIGN.md for goals, architecture, and the five processing layers (L0..L4).
318
+ *
319
+ * - fixedUpdate runs L1 (locomotion), L2 (pose state), and L4 (events) so
320
+ * the simulation remains deterministic.
321
+ * - update runs L3 (camera composition) at render rate so the eye is never
322
+ * smoother than the screen.
323
+ *
324
+ * The system itself integrates a simple flat-floor at y = `config.gravity.magnitude > 0
325
+ * ? state.groundY : -Infinity` for the prototype. A real physics layer should
326
+ * write `state.grounded`/`state.groundNormal` from outside instead; the
327
+ * built-in resolver is just a convenience to keep the controller usable
328
+ * without dependencies.
329
+ *
330
+ * @author Alex Goldring
331
+ * @copyright Company Named Limited (c) 2026
332
+ */
333
+ export class FirstPersonPlayerControllerSystem extends System {
334
+ constructor() {
335
+ super();
336
+
337
+ // Dependencies kept to (controller, transform) so we can ASSERT on
338
+ // RigidBody at link time and emit a clear error if missing. If
339
+ // RigidBody were a hard dep, entities lacking one would silently
340
+ // never link the controller would appear inert with no
341
+ // diagnostic. The assert below catches the missing-body case
342
+ // explicitly.
343
+ this.dependencies = [FirstPersonPlayerController, Transform];
344
+
345
+ this.components_used = [
346
+ ResourceAccessSpecification.from(Transform, ResourceAccessKind.Write),
347
+ ResourceAccessSpecification.from(Camera, ResourceAccessKind.Write),
348
+ ResourceAccessSpecification.from(RigidBody, ResourceAccessKind.Write),
349
+ ];
350
+
351
+ /**
352
+ * Per-entity runtime, keyed by entity id.
353
+ * @type {Map<number, PerEntityRuntime>}
354
+ */
355
+ this.runtime = new Map();
356
+
357
+ /**
358
+ * If true, the system clamps body y >= groundY and writes
359
+ * state.grounded itself. Turn off when wiring a real physics layer.
360
+ * @type {boolean}
361
+ */
362
+ this.useBuiltInFlatGround = true;
363
+
364
+ /**
365
+ * The flat-ground y for the built-in resolver. Ignored when
366
+ * useBuiltInFlatGround is false.
367
+ * @type {number}
368
+ */
369
+ this.groundY = 0;
370
+
371
+ /**
372
+ * Optional callback that returns the surface Y under the player
373
+ * for ground resolution. Called each tick with the player's
374
+ * current (x, y, z); returns the world-Y of the ground below,
375
+ * or null if no ground is below (gap / void).
376
+ *
377
+ * Combines with `useBuiltInFlatGround`: the effective ground for
378
+ * the tick is `max(this.groundY when enabled, resolver(...))`.
379
+ * Set both off (`useBuiltInFlatGround=false`, `groundResolver=null`)
380
+ * to defer to external physics entirely.
381
+ *
382
+ * Designed for prototypes / gyms that need elevated platforms
383
+ * without a full physics layer. Production should wire a real
384
+ * physics system instead.
385
+ *
386
+ * @type {((x:number, y:number, z:number) => number|null) | null}
387
+ */
388
+ this.groundResolver = null;
389
+
390
+ /**
391
+ * PhysicsSystem reference used by {@link _moveAndSlide}. Auto-
392
+ * acquired at startup; can be overridden by the caller. When
393
+ * null (no physics in the world), move-and-slide degrades to a
394
+ * direct position add — useful for spec setups that don't wire
395
+ * physics.
396
+ * @type {PhysicsSystem|null}
397
+ */
398
+ this.physicsSystem = null;
399
+ }
400
+
401
+ async startup(entityManager) {
402
+ this.entityManager = entityManager;
403
+ if (this.physicsSystem === null) {
404
+ const ps = entityManager.getSystem(PhysicsSystem);
405
+ if (ps !== null) this.physicsSystem = ps;
406
+ }
407
+ }
408
+
409
+ /**
410
+ * @param {FirstPersonPlayerController} controller
411
+ * @param {Transform} bodyTransform
412
+ * @param {number} entity
413
+ */
414
+ link(controller, bodyTransform, entity) {
415
+ const ecd = this.entityManager.dataset;
416
+
417
+ // The controller assumes a kinematic-position RigidBody is co-
418
+ // attached on this entity. The body is the spatial proxy used
419
+ // for sensor raycasts and physics-side observers (other entities
420
+ // raycasting against the player, dynamic bodies colliding with
421
+ // the capsule, etc.). The controller writes Transform directly,
422
+ // physics derives velocity from the per-step delta. If a body is
423
+ // missing the controller could still drive the camera, but the
424
+ // physics integration silently breaks — assert here so the
425
+ // misconfiguration is caught at link time.
426
+ const rigidBody = ecd.getComponent(entity, RigidBody);
427
+ assert.ok(rigidBody !== undefined,
428
+ "FirstPersonPlayerController entity must have a co-attached RigidBody "
429
+ + "(kinematic capsule). See prototype_first_person_controller.js for setup.");
430
+ assert.equal(rigidBody.kind, BodyKind.KinematicPosition,
431
+ "FirstPersonPlayerController RigidBody must be BodyKind.KinematicPosition; "
432
+ + "the controller owns the Transform and physics derives velocity.");
433
+ // Collider is also required — _moveAndSlide casts its shape
434
+ // against the world to prevent tunneling. Asserted here so a
435
+ // missing collider surfaces at link rather than producing a
436
+ // null-deref at the first cast attempt.
437
+ const collider = ecd.getComponent(entity, Collider);
438
+ assert.ok(collider !== undefined,
439
+ "FirstPersonPlayerController entity must have a co-attached Collider. "
440
+ + "The controller's move-and-slide casts this shape to detect blockers.");
441
+
442
+ const runtime = new PerEntityRuntime();
443
+ runtime.rigidBody = rigidBody;
444
+ runtime.collider = collider;
445
+ runtime.slideRay = new Ray3();
446
+ runtime.slideHit = new PhysicsSurfacePoint();
447
+
448
+ // Pre-build one capsule per posture from cfg.body. Eye-height
449
+ // doubles as collider-top by convention here — the prototype's
450
+ // `buildPlayerEntity` uses the same approximation (`totalHeight =
451
+ // bodyCfg.height`). The +Y offset puts the capsule bottom at
452
+ // transform.position so the player's "feet" stay anchored across
453
+ // posture changes; only the head drops/rises.
454
+ const radius = controller.config.body.radius;
455
+ runtime.colliderShapeStand = makePostureCapsule(radius, controller.config.body.height);
456
+ runtime.colliderShapeCrouch = makePostureCapsule(radius, controller.config.body.crouchHeight);
457
+ runtime.colliderShapeProne = makePostureCapsule(radius, controller.config.body.proneHeight);
458
+ // Force a shape sync on the first tick: even though the caller
459
+ // built a Stand-sized collider, we rebuild it from cfg here so a
460
+ // post-link config tweak (e.g. crouchHeight changed for a unit
461
+ // test) is reflected on the live collider without a relink.
462
+ runtime.lastPosture = -1;
463
+
464
+ this.runtime.set(entity, runtime);
465
+
466
+ // Derive gravity + jump impulse from designer-friendly params, then
467
+ // mass-scale the initial velocity (heavier ⇒ lower jump).
468
+ runtime.massRatios = computeMassRatios(
469
+ controller.config.body.mass,
470
+ controller.config.body.referenceMass,
471
+ controller.config.body.massCouplingStrength,
472
+ );
473
+ const derived = { gravity: 0, initialVelocity: 0 };
474
+ computeJumpFromApex(controller.config.jump.peakHeight, controller.config.jump.timeToApex, derived);
475
+ runtime.gravity = derived.gravity;
476
+ runtime.jumpInitialVy = derived.initialVelocity * runtime.massRatios.jumpV0Scale;
477
+
478
+ // Seed yaw from the starting body rotation. `toEulerAnglesYXZ`
479
+ // returns (pitch, yaw, roll) — we only care about y.
480
+ bodyTransform.rotation.toEulerAnglesYXZ(SCRATCH_V3_A);
481
+ runtime.bodyYaw = SCRATCH_V3_A.y;
482
+ runtime.eyePitch = 0;
483
+
484
+ // Initialize springs to standing-eye-height baseline
485
+ runtime.eyeHeightSpring.settle(controller.config.body.height);
486
+ runtime.fovSpring.settle(controller.config.fov.base);
487
+ controller.state.eyeHeight = controller.config.body.height;
488
+
489
+ // Create eye entity if one wasn't supplied
490
+ if (controller.eyeEntity === -1 || !ecd.entityExists(controller.eyeEntity)) {
491
+ const eye = new Entity();
492
+
493
+ const eyeTransform = new Transform();
494
+ const baseEyePos = SCRATCH_V3_A.copy(bodyTransform.position);
495
+ baseEyePos.y += controller.config.body.height;
496
+ eyeTransform.position.copy(baseEyePos);
497
+
498
+ const camera = new Camera();
499
+ camera.active.set(true);
500
+ camera.fov.set(controller.config.fov.base);
501
+ camera.clip_near = 0.05;
502
+ camera.clip_far = 1000;
503
+ camera.autoClip = false;
504
+
505
+ eye.add(eyeTransform);
506
+ eye.add(camera);
507
+ eye.add(SerializationMetadata.Transient);
508
+
509
+ eye.build(ecd);
510
+
511
+ controller.eyeEntity = eye.id;
512
+ }
513
+
514
+ runtime.eyeEntity = controller.eyeEntity;
515
+ }
516
+
517
+ /**
518
+ * @param {FirstPersonPlayerController} controller
519
+ * @param {Transform} bodyTransform
520
+ * @param {number} entity
521
+ */
522
+ unlink(controller, bodyTransform, entity) {
523
+ const ecd = this.entityManager.dataset;
524
+
525
+ if (controller.eyeEntity !== -1 && ecd.entityExists(controller.eyeEntity)) {
526
+ ecd.removeEntity(controller.eyeEntity);
527
+ controller.eyeEntity = -1;
528
+ }
529
+
530
+ this.runtime.delete(entity);
531
+ }
532
+
533
+ /**
534
+ * Look up the per-entity runtime for an entity that has this
535
+ * controller. Used by cross-system code (sensors system, future
536
+ * ability-driven systems) to reach internal state without leaking
537
+ * it onto the controller component itself.
538
+ *
539
+ * @param {number} entity
540
+ * @returns {PerEntityRuntime|undefined} undefined if entity is not linked
541
+ */
542
+ getRuntime(entity) {
543
+ return this.runtime.get(entity);
544
+ }
545
+
546
+ /**
547
+ * Deterministic simulation step L1 + L2 + L4.
548
+ * @param {number} dt
549
+ */
550
+ fixedUpdate(dt) {
551
+ const ecd = this.entityManager.dataset;
552
+ if (ecd === null) return;
553
+
554
+ this._currentDt = dt;
555
+ ecd.traverseComponents(FirstPersonPlayerController, this._tickEntity, this);
556
+ }
557
+
558
+ /**
559
+ * Variable-rate camera composition — L3.
560
+ * @param {number} dt
561
+ */
562
+ update(dt) {
563
+ const ecd = this.entityManager.dataset;
564
+ if (ecd === null) return;
565
+
566
+ this._currentRenderDt = dt;
567
+ ecd.traverseComponents(FirstPersonPlayerController, this._composeEye, this);
568
+ }
569
+
570
+ /**
571
+ * @private
572
+ * @param {FirstPersonPlayerController} controller
573
+ * @param {number} entity
574
+ */
575
+ _tickEntity(controller, entity) {
576
+ const ecd = this.entityManager.dataset;
577
+ const runtime = this.runtime.get(entity);
578
+ if (runtime === undefined) return;
579
+
580
+ const dt = this._currentDt;
581
+ const cfg = controller.config;
582
+ const intent = controller.intent;
583
+ const state = controller.state;
584
+ const sig = controller.signals;
585
+
586
+ const bodyTransform = ecd.getComponent(entity, Transform);
587
+ if (bodyTransform === undefined) return;
588
+
589
+ // Decay the mastery score's EMA. Doing this once per tick keeps the
590
+ // score's time-window characteristic stable regardless of how many
591
+ // evaluators fire (they each *record* a sample, the decay
592
+ // independently ages all samples).
593
+ controller.mastery.tick(dt);
594
+
595
+ // -- L1.a: Consume look delta -----------------------------------
596
+ // intent.look is zeroed after consume so accumulated input doesn't
597
+ // re-apply on the next fixed step.
598
+ //
599
+ // Conventions (with raw mouse delta as the source — movementX/Y both
600
+ // positive when moving right/down):
601
+ // look.x > 0 ("mouse right") → turn right
602
+ // look.y > 0 ("mouse down") → look down (flipped by invertY)
603
+ //
604
+ // The yaw sign is negated because the engine uses left-handed
605
+ // coordinates with +Z as forward; a positive Y-axis rotation takes
606
+ // +Z toward +X, which presents to the player as a LEFT turn through
607
+ // the Three.js camera (`quaternion_invert_orientation`). Negating
608
+ // here gives the player-intuitive "mouse right → turn right".
609
+ const yawDelta = -intent.look.x;
610
+ const pitchSign = cfg.look.invertY ? -1 : 1;
611
+ const pitchDelta = intent.look.y * pitchSign;
612
+ intent.look.set(0, 0);
613
+
614
+ // Cache yaw rate for mastery evaluators (look-lean, foot-asymmetry-
615
+ // turn, etc.). Rad/s, signed (negative = turning right in our
616
+ // convention matches yawDelta).
617
+ runtime.yawRateRadPerSec = yawDelta / Math.max(dt, 1e-4);
618
+
619
+ runtime.bodyYaw += yawDelta;
620
+ // keep yaw bounded (purely cosmetic — sin/cos handle wraparound fine)
621
+ if (runtime.bodyYaw > Math.PI) runtime.bodyYaw -= TWO_PI;
622
+ else if (runtime.bodyYaw < -Math.PI) runtime.bodyYaw += TWO_PI;
623
+
624
+ runtime.eyePitch = clamp(
625
+ runtime.eyePitch + pitchDelta,
626
+ cfg.look.pitchMinDeg * DEG_TO_RAD,
627
+ cfg.look.pitchMaxDeg * DEG_TO_RAD,
628
+ );
629
+
630
+ // Write body yaw back to transform (pure yaw, no pitch on body)
631
+ bodyTransform.rotation.fromAxisAngle(Vector3.up, runtime.bodyYaw);
632
+
633
+ // -- Shared flags. Computed BEFORE the ability tick so abilities
634
+ // can read them. `isCrouchActive` is deliberately computed
635
+ // AFTER the ability tick because `_resolveCrouchHeld` mutates
636
+ // `runtime.prevCrouchHeld` abilities like Slide need to see
637
+ // the previous-tick value to detect a rising edge on the
638
+ // crouch press.
639
+ const isSprintIntent = intent.sprint && intent.move.y > 0.5 && state.grounded;
640
+ const isBackwardIntent = intent.move.y < 0;
641
+ runtime.sinYaw = Math.sin(runtime.bodyYaw);
642
+ runtime.cosYaw = Math.cos(runtime.bodyYaw);
643
+ // L2 observers read sinYaw/cosYaw as locals — destructure once.
644
+ const { sinYaw, cosYaw } = runtime;
645
+
646
+ // -- Ability layer: at most one active ability owns motion. The
647
+ // set returns true when no ability owned the tick, in which
648
+ // case base L1.b-h runs below; false means an ability fully
649
+ // handled this tick (it called the system's helpers for any
650
+ // standard work it wanted to keep, e.g. gravity).
651
+ const runBaseLocomotion = controller.abilities.tick(
652
+ controller, runtime, bodyTransform, runtime.sensors, dt, this,
653
+ );
654
+
655
+ // Now resolve crouch (updates prevCrouchHeld) — used by base and L2.
656
+ const isCrouchActive = this._resolveCrouchHeld(controller, runtime);
657
+
658
+ if (runBaseLocomotion) {
659
+ this._runBaseLocomotion(
660
+ controller, runtime, bodyTransform, dt,
661
+ isCrouchActive, isSprintIntent, isBackwardIntent,
662
+ );
663
+ }
664
+
665
+ // (everything below this line runs every tick — L2 observers don't
666
+ // care who owned motion)
667
+
668
+ // -- L2.a: speed / moveMode ------------------------------------
669
+ // -- L2.a: speed / moveMode ------------------------------------
670
+ const horizSpeed = Math.hypot(runtime.velocityX, runtime.velocityZ);
671
+ runtime.horizSpeed = horizSpeed;
672
+ state.speed = horizSpeed;
673
+ state.speedNormalized = clamp(horizSpeed / Math.max(cfg.motion.sprintSpeed, 1e-3), 0, 1);
674
+
675
+ // Backwardness: 0 = moving forward (or sideways), 1 = moving directly
676
+ // backward at the back-pedal speed ceiling. Derived from the actual
677
+ // velocity (not the intent) so external knockback or stuck states
678
+ // also register as "moving backward" and the gait wobble reflects it.
679
+ //
680
+ // Reference speed is the *achievable* backward max walkSpeed ×
681
+ // backwardSpeedFactor NOT the sprint speed. Backward can never
682
+ // reach sprint, so normalizing against sprint would cap backwardness
683
+ // at ~0.3 and the wobble multipliers below would barely apply.
684
+ const screenFwdVel = runtime.velocityX * sinYaw + runtime.velocityZ * cosYaw;
685
+ const maxBackwardSpeed = Math.max(cfg.motion.walkSpeed * cfg.motion.backwardSpeedFactor, 1e-3);
686
+ runtime.backwardness = clamp(-screenFwdVel / maxBackwardSpeed, 0, 1);
687
+
688
+ // Locomotion mode is the *intent-driven* horizontal mode. Airborne
689
+ // state is tracked separately on pose.actionState — they're
690
+ // orthogonal facets (you can be Sprint+Airborne after a jump).
691
+ const prevLocomotionMode = state.locomotionMode;
692
+ if (isCrouchActive) {
693
+ state.locomotionMode = FirstPersonLocomotionMode.Crouch;
694
+ } else if (isSprintIntent && horizSpeed > 0.1) {
695
+ state.locomotionMode = FirstPersonLocomotionMode.Sprint;
696
+ } else if (horizSpeed > 0.1) {
697
+ state.locomotionMode = FirstPersonLocomotionMode.Walk;
698
+ } else {
699
+ state.locomotionMode = FirstPersonLocomotionMode.Idle;
700
+ }
701
+
702
+ if (state.locomotionMode === FirstPersonLocomotionMode.Sprint
703
+ && prevLocomotionMode !== FirstPersonLocomotionMode.Sprint) {
704
+ sig.onSprintStart.send0();
705
+ } else if (prevLocomotionMode === FirstPersonLocomotionMode.Sprint
706
+ && state.locomotionMode !== FirstPersonLocomotionMode.Sprint) {
707
+ sig.onSprintStop.send0();
708
+ }
709
+
710
+ // -- L2.b: Exertion --------------------------------------------
711
+ // Heavier bodies tire faster — sprint rise scales with massRatios.exertionRiseScale.
712
+ const exertionRise = isSprintIntent
713
+ ? cfg.exertion.sprintRiseRate * runtime.massRatios.exertionRiseScale
714
+ : 0;
715
+ const exertionFall = exertionRise > 0 ? 0 : cfg.exertion.idleDecayRate;
716
+ state.exertion = clamp(state.exertion + (exertionRise - exertionFall) * dt, 0, 1);
717
+
718
+ // -- L2.c: Breath ----------------------------------------------
719
+ // breathRate and breathAmplitude lag exertion through separate
720
+ // exponential decays. Rate hangs around longer than amplitude.
721
+ const metabolicRate = lerp(cfg.breath.rateRestHz, cfg.breath.rateMaxHz, state.exertion);
722
+ const targetAmp = lerp(cfg.breath.amplitudeRestM, cfg.breath.amplitudeMaxM, state.exertion);
723
+
724
+ // Locomotor-respiratory coupling — see math/computeLRCBreathRate.
725
+ // The pure function is unit-tested; this site just provides inputs.
726
+ //
727
+ // Gait is gated on a "feet strike the ground" posture (Stand /
728
+ // Crouch). Prone (slide) and Hang (ledge-grab) have no stride —
729
+ // the body's feet are not making contact in a walking pattern,
730
+ // so stride frequency drops to zero and downstream gait
731
+ // signals (footsteps, bob intensity) go quiet.
732
+ const feetStriking = state.posture === FirstPersonPosture.Stand
733
+ || state.posture === FirstPersonPosture.Crouch;
734
+ const strideFreqHz = feetStriking && state.grounded && horizSpeed > cfg.bob.minStepSpeed
735
+ ? cfg.bob.stepFreqAtWalk * Math.pow(
736
+ Math.max(horizSpeed, 1e-3) / Math.max(cfg.motion.walkSpeed, 1e-3),
737
+ cfg.bob.stepFreqExp,
738
+ )
739
+ : 0;
740
+ const targetRate = computeLRCBreathRate(
741
+ metabolicRate,
742
+ strideFreqHz,
743
+ state.exertion,
744
+ cfg.breath.locomotorCouplingMax,
745
+ cfg.breath.couplingMinStrideFreqHz,
746
+ );
747
+ state.breathRateHz = exponentialApproach(state.breathRateHz, targetRate, cfg.exertion.rateDecayHalfLife, dt);
748
+ state.breathAmplitudeM = exponentialApproach(state.breathAmplitudeM, targetAmp, cfg.exertion.ampDecayHalfLife, dt);
749
+
750
+ runtime.prevBreathPhase = state.breathPhase;
751
+ state.breathPhase += state.breathRateHz * dt;
752
+ state.breathPhase -= Math.floor(state.breathPhase); // wrap [0,1)
753
+
754
+ // Breath edge detectioninhale at 0.25, exhale at 0.75
755
+ if (phaseCrossed(runtime.prevBreathPhase, state.breathPhase, 0.25)) {
756
+ sig.onBreathIn.send1({ amplitude: state.breathAmplitudeM, rateHz: state.breathRateHz });
757
+ }
758
+ if (phaseCrossed(runtime.prevBreathPhase, state.breathPhase, 0.75)) {
759
+ sig.onBreathOut.send1({ amplitude: state.breathAmplitudeM, rateHz: state.breathRateHz });
760
+ }
761
+
762
+ // -- L2.d: Stride ----------------------------------------------
763
+ // strideFreqHz computed above in the breath block; reused here.
764
+ runtime.prevStridePhase = state.stridePhase;
765
+ if (strideFreqHz > 0) {
766
+ // 1 full stride cycle = 2 footfalls; phase advances at freq/2 of cycle
767
+ state.stridePhase += (strideFreqHz * 0.5) * dt;
768
+ state.stridePhase -= Math.floor(state.stridePhase);
769
+ }
770
+ // Footstep on phase wraparound past 0 (R) or past 0.5 (L). Same
771
+ // posture gate as stride advance — feet must be striking.
772
+ if (feetStriking && state.grounded && horizSpeed > cfg.bob.minStepSpeed) {
773
+ const fireFootstep = () => {
774
+ state.stepCount++;
775
+ const side = runtime.nextFootSide;
776
+ runtime.nextFootSide = side === "R" ? "L" : "R";
777
+ // The foot that just fired is now the one bearing weight
778
+ // through the upcoming half-stride. Drives lateral-bob sign.
779
+ runtime.standingFoot = side;
780
+ sig.onFootStep.send1({ side, speed: horizSpeed, surfaceTag: state.surfaceTag });
781
+ // Kick the vertical impact spring DOWNWARD. The kick magnitude
782
+ // is the per-step desired peak dip × impactKickMultiplier; the
783
+ // multiplier is empirical (depends on impact spring params) so
784
+ // that "verticalAmpAtWalk" still corresponds approximately to
785
+ // the visible peak dip depth. Scaled by bobIntensity so a
786
+ // mid-deceleration footstep doesn't deliver a full-strength
787
+ // impulse.
788
+ const massBoost = (cfg.body.mass - 80) * cfg.bob.ampMassScale;
789
+ const ampVMult = 1 + (cfg.bob.backwardVerticalAmpFactor - 1) * runtime.backwardness;
790
+ const peakDip = (cfg.bob.verticalAmpAtWalk + massBoost) * runtime.bobIntensitySpring.value * ampVMult;
791
+ runtime.verticalImpactSpring.kick(-peakDip * cfg.bob.impactKickMultiplier);
792
+ };
793
+ if (phaseCrossed(runtime.prevStridePhase, state.stridePhase, 0)) {
794
+ fireFootstep();
795
+ }
796
+ if (phaseCrossed(runtime.prevStridePhase, state.stridePhase, 0.5)) {
797
+ fireFootstep();
798
+ }
799
+ }
800
+
801
+ // -- L2.d.bob-intensity & impact -------------------------------
802
+ // Smoothed bob amplitude envelope: when the player starts/stops
803
+ // moving the visible bob fades in/out rather than cutting on/off.
804
+ // Target = the "natural" amp scale (max of speed and backwardness)
805
+ // while grounded, zero while airborne so the bob disappears mid-jump.
806
+ const naturalBobIntensity = Math.max(state.speedNormalized, runtime.backwardness);
807
+ // Bob fades to zero whenever feet aren't striking (airborne, or
808
+ // Prone/Hang posture). The verticalImpactSpring (separate
809
+ // channel) still carries any entry/landing kicks through to the
810
+ // camera, but no recurring step bob.
811
+ const targetBobIntensity = (state.grounded && feetStriking) ? naturalBobIntensity : 0;
812
+ runtime.bobIntensitySpring.stepTo(targetBobIntensity, cfg.bob.intensityHalfLife, 1.0, dt);
813
+
814
+ // Vertical impact spring — damped decay toward 0, with the under-
815
+ // damped overshoot that produces the recovery + leg-push curve.
816
+ runtime.verticalImpactSpring.stepTo(0, cfg.bob.impactSpringHalfLife, cfg.bob.impactSpringZeta, dt);
817
+
818
+ // Sprint posture — head pitches forward as commitment to sprint
819
+ // builds. Driven by "sprintness" — how much of the gap between
820
+ // walk and sprint speed the player is *currently* in (0..1). The
821
+ // pitch target is multiplied by sprintness, then critically damped.
822
+ // Only applies while grounded — pitching into airborne motion looks weird.
823
+ const sprintness = clamp(
824
+ (state.speed - cfg.motion.walkSpeed)
825
+ / Math.max(cfg.motion.sprintSpeed - cfg.motion.walkSpeed, 1e-3),
826
+ 0, 1,
827
+ );
828
+ const targetSprintPitch = state.grounded
829
+ ? cfg.posture.sprintForwardPitchDeg * DEG_TO_RAD * sprintness
830
+ : 0;
831
+ runtime.sprintPostureSpring.stepTo(
832
+ targetSprintPitch,
833
+ cfg.posture.sprintForwardPitchHalfLife,
834
+ 1.0, dt,
835
+ );
836
+ runtime.sprintness = sprintness;
837
+
838
+ // Head droop — exertion drives a subtle additional forward pitch.
839
+ // Combines with sprintPostureSpring (sprint = head down to commit)
840
+ // so a fatigued sprinter has BOTH effects layered.
841
+ const targetDroopRad = cfg.exertion.headDroopAtMaxDeg * DEG_TO_RAD * state.exertion;
842
+ runtime.headDroopSpring.stepTo(targetDroopRad, cfg.exertion.headDroopHalfLife, 1.0, dt);
843
+
844
+ // -- L2.e: Posture → eye height --------------------------------
845
+ // Posture is set by whichever layer owned motion this tick: base
846
+ // writes Stand / Crouch from isCrouchActive (see end of
847
+ // _runBaseLocomotion); active abilities write Prone (Slide) or
848
+ // Hang (LedgeGrab) in their tick. Mapping is one switch — adding
849
+ // a new posture is one enum value + one case.
850
+ let targetEyeH;
851
+ switch (state.posture) {
852
+ case FirstPersonPosture.Prone: targetEyeH = cfg.body.proneHeight; break;
853
+ case FirstPersonPosture.Crouch: targetEyeH = cfg.body.crouchHeight; break;
854
+ case FirstPersonPosture.Hang: targetEyeH = cfg.body.height; break;
855
+ case FirstPersonPosture.Stand:
856
+ default: targetEyeH = cfg.body.height; break;
857
+ }
858
+ const crouchHalfLife = cfg.crouch.transitionTime / 4; // halfLife is ~quarter of full transition
859
+ runtime.eyeHeightSpring.stepTo(targetEyeH, crouchHalfLife, 1.0, dt);
860
+ state.eyeHeight = runtime.eyeHeightSpring.value;
861
+
862
+ if (isCrouchActive !== state.crouchActive) {
863
+ state.crouchActive = isCrouchActive;
864
+ if (isCrouchActive) {
865
+ sig.onCrouchEnter.send0();
866
+ // Impulse: dropping into a crouch grips the knees. Small
867
+ // bump — we don't want crouch-spamming to instantly tire.
868
+ state.exertion = clamp(
869
+ state.exertion + cfg.exertion.crouchEnterRise * runtime.massRatios.exertionRiseScale,
870
+ 0, 1,
871
+ );
872
+ } else {
873
+ sig.onCrouchExit.send0();
874
+ }
875
+ }
876
+
877
+ // -- L2.f: Lean spring → camera roll ---------------------------
878
+ // The TARGET for this tick was written by whichever layer owned
879
+ // motion: base writes the lat-accel + look-lean derived value at
880
+ // the end of _runBaseLocomotion; abilities override (WallRun
881
+ // tilts toward the wall; Slide / LedgeGrab / Mantle force zero).
882
+ // L2.f is now a flat spring-step + commit — no branching, no
883
+ // null sentinel.
884
+ runtime.prevVelocityX = runtime.velocityX;
885
+ runtime.prevVelocityZ = runtime.velocityZ;
886
+ runtime.leanSpring.stepTo(runtime.leanTargetRad, cfg.lean.spring.halfLife, cfg.lean.spring.zeta, dt);
887
+ state.leanRollRad = runtime.leanSpring.value;
888
+
889
+ // -- L2.g: Land spring decay (drives the landing recovery dip) -
890
+ // Target is 0; under-damped (cfg zeta < 1) so it rings.
891
+ runtime.landSpring.stepTo(0, cfg.landing.recovery.spring.halfLife, cfg.landing.recovery.spring.zeta, dt);
892
+
893
+ // -- L2.h: Publish pose channels --------------------------------
894
+ this._publishPose(controller, runtime, bodyTransform);
895
+
896
+ // -- L2.i: Sync collider shape to posture -----------------------
897
+ // All posture-writers (base locomotion + any active ability)
898
+ // have run for this tick. Swap the collider's shape to the
899
+ // pre-built capsule matching the final posture so downstream
900
+ // physics queries (move-and-slide cast, sensors, overlap from
901
+ // outside) see the right volume. No-op when posture is
902
+ // unchanged.
903
+ this._syncColliderShape(runtime, state.posture);
904
+ }
905
+
906
+ /**
907
+ * @private
908
+ * @param {FirstPersonPlayerController} controller
909
+ * @param {PerEntityRuntime} runtime
910
+ * @returns {boolean}
911
+ */
912
+ /**
913
+ * Swap {@link Collider.shape} to the pre-built capsule that matches
914
+ * the player's current posture. Cheap — just a reference swap when
915
+ * the posture changed, no-op otherwise. The pre-built shapes live
916
+ * on the runtime (see {@link PerEntityRuntime.colliderShapeStand}
917
+ * etc.) so this method allocates nothing per tick.
918
+ *
919
+ * Hang posture reuses Stand: the player's body is full-extent,
920
+ * hanging below the ledge — the rig handles the arms-up animation,
921
+ * but the collision volume is unchanged. If a game ever wants a
922
+ * narrower hang silhouette (e.g. wedging into a chimney) it can
923
+ * add a `colliderShapeHang` and route here.
924
+ *
925
+ * @private
926
+ */
927
+ _syncColliderShape(runtime, posture) {
928
+ if (posture === runtime.lastPosture) return;
929
+ let next;
930
+ if (posture === FirstPersonPosture.Crouch) {
931
+ next = runtime.colliderShapeCrouch;
932
+ } else if (posture === FirstPersonPosture.Prone) {
933
+ next = runtime.colliderShapeProne;
934
+ } else {
935
+ // Stand and Hang share the full-extent capsule.
936
+ next = runtime.colliderShapeStand;
937
+ }
938
+ runtime.collider.shape = next;
939
+ runtime.lastPosture = posture;
940
+ }
941
+
942
+ _resolveCrouchHeld(controller, runtime) {
943
+ const cfg = controller.config;
944
+ const intent = controller.intent;
945
+
946
+ if (cfg.crouch.mode === "toggle") {
947
+ // Edge: rising press flips the latch
948
+ if (intent.crouch && !runtime.prevCrouchHeld) {
949
+ runtime.crouchLatched = !runtime.crouchLatched;
950
+ }
951
+ runtime.prevCrouchHeld = intent.crouch;
952
+ return runtime.crouchLatched;
953
+ }
954
+ // "hold" mode
955
+ runtime.prevCrouchHeld = intent.crouch;
956
+ return intent.crouch;
957
+ }
958
+
959
+ /**
960
+ * Jump finite-state-machine: button-edge detection, buffer + coyote
961
+ * grace, anticipation timer, impulse on completion. Variable-height
962
+ * cut is captured here as a `state.isVariableJumpCut` flag that the
963
+ * gravity step in `_integrateVerticalAndResolveGround` consumes.
964
+ *
965
+ * @private
966
+ * @param {FirstPersonPlayerController} controller
967
+ * @param {PerEntityRuntime} runtime
968
+ * @param {Transform} bodyTransform
969
+ * @param {number} dt
970
+ */
971
+ _advanceJumpFsm(controller, runtime, bodyTransform, dt) {
972
+ const cfg = controller.config;
973
+ const intent = controller.intent;
974
+ const state = controller.state;
975
+ const sig = controller.signals;
976
+
977
+ const jumpPressedEdge = intent.jump && !runtime.prevJumpHeld;
978
+ const jumpReleasedEdge = !intent.jump && runtime.prevJumpHeld;
979
+ runtime.prevJumpHeld = intent.jump;
980
+
981
+ if (jumpPressedEdge) {
982
+ state.jumpBufferRemaining = cfg.jump.bufferTime;
983
+ }
984
+ state.jumpBufferRemaining = Math.max(0, state.jumpBufferRemaining - dt);
985
+
986
+ const canJumpNow =
987
+ (state.grounded || state.timeSinceGrounded < cfg.jump.coyoteTime)
988
+ && state.jumpBufferRemaining > 0
989
+ && !state.inJumpAnticipation
990
+ && !runtime.midJump;
991
+
992
+ if (canJumpNow) {
993
+ // Begin anticipation — squash; impulse fires after duration elapses
994
+ state.inJumpAnticipation = true;
995
+ runtime.anticipationRemaining = cfg.jump.anticipation.duration;
996
+ state.jumpBufferRemaining = 0; // claimed
997
+ }
998
+
999
+ // Variable-height cut: only valid during ascent, post-launch.
1000
+ if (jumpReleasedEdge && runtime.midJump && runtime.velocityY > 0) {
1001
+ state.isVariableJumpCut = true;
1002
+ }
1003
+
1004
+ // Anticipation timer; impulse on completion.
1005
+ //
1006
+ // Anticipation completes regardless of grounded state. The reason
1007
+ // we DON'T cancel on `!grounded`: the canonical coyote-jump path
1008
+ // depends on it. The player walks off a ledge (grounded → false),
1009
+ // presses jump within the coyote window, canJumpNow accepts on
1010
+ // the coyote branch and starts anticipation. If we cancelled
1011
+ // anticipation here on !grounded, the impulse would never fire
1012
+ // and "coyote time" would be silently dead — the FSM's own next-
1013
+ // statement contradicting the canJumpNow gate three lines up.
1014
+ //
1015
+ // The same logic handles the rug-pull case (player on a moving
1016
+ // platform that slides out mid-anticipation): the player
1017
+ // committed to the jump, they get the jump. A future
1018
+ // knockback / stagger system can explicitly clear
1019
+ // inJumpAnticipation if it wants to override that commitment.
1020
+ if (state.inJumpAnticipation) {
1021
+ runtime.anticipationRemaining -= dt;
1022
+ if (runtime.anticipationRemaining <= 0) {
1023
+ // Mastery: gather a multiplier from all evaluators
1024
+ // registered for JumpImpulse. Default (no evaluators)
1025
+ // returns 1.0 → unchanged behaviour.
1026
+ const masteryMul = controller.mastery.evaluate(
1027
+ DecisionPoint.JumpImpulse, controller, runtime,
1028
+ );
1029
+ runtime.velocityY = runtime.jumpInitialVy * masteryMul;
1030
+ runtime.midJump = true;
1031
+ runtime.apexFired = false;
1032
+ runtime.peakAltitude = bodyTransform.position.y;
1033
+ state.inJumpAnticipation = false;
1034
+ state.isVariableJumpCut = false;
1035
+ state.isAscending = true;
1036
+ state.exertion = clamp(
1037
+ state.exertion + cfg.exertion.jumpRise * runtime.massRatios.exertionRiseScale,
1038
+ 0, 1,
1039
+ );
1040
+
1041
+ sig.onJumpStart.send1({ peakHeight: cfg.jump.peakHeight });
1042
+ sig.onLeaveGround.send1({ reason: "jump" });
1043
+ }
1044
+ }
1045
+ }
1046
+
1047
+ /**
1048
+ * Sweep the player's collider along (dx, dy, dz) via
1049
+ * {@link PhysicsSystem.shapeCast} and translate the Transform up to
1050
+ * (but not past) the first contact. Prevents tunneling through
1051
+ * static geometry and creep-penetration over many ticks.
1052
+ *
1053
+ * v1 limitations:
1054
+ * - The broadphase shape-cast returns the back-along-the-sweep
1055
+ * normal (`−direction`), not the true surface normal. With
1056
+ * that, the principled "slide along the surface" residual is
1057
+ * `delta -= dot(delta, n)·n = 0` — i.e. the player stops at
1058
+ * contact instead of sliding tangent to the wall. Once
1059
+ * narrowphase refinement lands and `result.normal` becomes the
1060
+ * true surface normal, the same residual computation will
1061
+ * naturally produce sliding without an API change.
1062
+ * - SKIN clearance (5 mm) keeps the player just shy of the wall
1063
+ * so the next cast doesn't start with the capsule already in
1064
+ * contact. Picking this too small risks GJK reporting `t = 0`
1065
+ * and the player getting stuck; too large is visible as a gap.
1066
+ *
1067
+ * Falls through to a direct position add when the host hasn't
1068
+ * wired a {@link PhysicsSystem} — useful for spec setups that
1069
+ * don't bring physics up.
1070
+ *
1071
+ * @private
1072
+ * @param {PerEntityRuntime} runtime
1073
+ * @param {Transform} bodyTransform
1074
+ * @param {number} deltaX
1075
+ * @param {number} deltaY
1076
+ * @param {number} deltaZ
1077
+ * @returns {boolean} true if a contact occurred (and the sweep was
1078
+ * truncated); false on a clean full advance.
1079
+ */
1080
+ _moveAndSlide(runtime, bodyTransform, deltaX, deltaY, deltaZ) {
1081
+ if (this.physicsSystem === null) {
1082
+ // No physics in this world — treat the cast as a free path
1083
+ // and just advance.
1084
+ if (deltaX !== 0 || deltaY !== 0 || deltaZ !== 0) {
1085
+ bodyTransform.position._add(deltaX, deltaY, deltaZ);
1086
+ }
1087
+ return false;
1088
+ }
1089
+
1090
+ // Sweep + slide along the contact tangent, iterating to handle
1091
+ // multi-contact corners. PhysicsSystem.shapeCast returns the true
1092
+ // surface normal (narrowphase-refined), so the canonical
1093
+ // projection `residual -= dot(residual, n)·n` lands cleanly.
1094
+ //
1095
+ // Up to MAX_ITERS iterations: first contact stops at the wall and
1096
+ // projects the leftover motion onto the wall's tangent; the
1097
+ // second iteration sweeps that tangent through any second wall
1098
+ // (corner case) and projects again; etc. With axis-aligned
1099
+ // walls a corner needs ≤2 iterations. The cap defends against
1100
+ // pathological geometry (a player in a cone of inward-pointing
1101
+ // walls).
1102
+ const ownCollider = runtime.collider;
1103
+ const filter = (_e, c) => c !== ownCollider;
1104
+ const CAST_STEP_HEIGHT = 0.05;
1105
+ const SKIN = 0.005;
1106
+ const MAX_ITERS = 4;
1107
+
1108
+ let remX = deltaX, remY = deltaY, remZ = deltaZ;
1109
+ let didHit = false;
1110
+
1111
+ for (let iter = 0; iter < MAX_ITERS; iter++) {
1112
+ const len = Math.hypot(remX, remY, remZ);
1113
+ if (len < 1e-6) break;
1114
+
1115
+ const inv = 1 / len;
1116
+ const ndx = remX * inv;
1117
+ const ndy = remY * inv;
1118
+ const ndz = remZ * inv;
1119
+
1120
+ const ray = runtime.slideRay;
1121
+ ray.setOrigin(
1122
+ bodyTransform.position.x,
1123
+ bodyTransform.position.y + CAST_STEP_HEIGHT,
1124
+ bodyTransform.position.z,
1125
+ );
1126
+ ray.setDirection(ndx, ndy, ndz);
1127
+ ray.tMax = len;
1128
+
1129
+ const hit = this.physicsSystem.shapeCast(
1130
+ ray,
1131
+ runtime.collider.shape,
1132
+ bodyTransform.rotation,
1133
+ runtime.slideHit,
1134
+ filter,
1135
+ );
1136
+
1137
+ if (!hit) {
1138
+ bodyTransform.position._add(remX, remY, remZ);
1139
+ break;
1140
+ }
1141
+
1142
+ didHit = true;
1143
+ const advance = Math.max(0, runtime.slideHit.t - SKIN);
1144
+ if (advance > 0) {
1145
+ bodyTransform.position._add(ndx * advance, ndy * advance, ndz * advance);
1146
+ }
1147
+
1148
+ // Project the residual onto the contact tangent. `len - t`
1149
+ // is what we still wanted to travel; the SKIN slice (the
1150
+ // gap between (t - SKIN) and t) is lost as clearance.
1151
+ const leftoverLen = len - runtime.slideHit.t;
1152
+ if (leftoverLen <= 0) break;
1153
+
1154
+ const nx = runtime.slideHit.normal.x;
1155
+ const ny = runtime.slideHit.normal.y;
1156
+ const nz = runtime.slideHit.normal.z;
1157
+ const dotD = ndx * nx + ndy * ny + ndz * nz;
1158
+ const tx = ndx - dotD * nx;
1159
+ const ty = ndy - dotD * ny;
1160
+ const tz = ndz - dotD * nz;
1161
+ remX = tx * leftoverLen;
1162
+ remY = ty * leftoverLen;
1163
+ remZ = tz * leftoverLen;
1164
+
1165
+ // Project velocity too, but only the into-wall component.
1166
+ // Moving away from the wall (dotV > 0 with the outward
1167
+ // normal) is left alone.
1168
+ const dotV = runtime.velocityX * nx + runtime.velocityY * ny + runtime.velocityZ * nz;
1169
+ if (dotV < 0) {
1170
+ runtime.velocityX -= dotV * nx;
1171
+ runtime.velocityY -= dotV * ny;
1172
+ runtime.velocityZ -= dotV * nz;
1173
+ }
1174
+ }
1175
+ return didHit;
1176
+ }
1177
+
1178
+ /**
1179
+ * Gravity (with fall and cut multipliers), vertical integration,
1180
+ * built-in flat-floor resolution (land event + impulse), and jump-apex
1181
+ * detection. The full vertical phase of one fixed step.
1182
+ *
1183
+ * The built-in flat-floor branch only runs when `useBuiltInFlatGround`
1184
+ * is true (the prototype's standalone mode); with an external physics
1185
+ * layer attached the system relies on the layer to set `state.grounded`
1186
+ * and only maintains airborne/grounded timers here.
1187
+ *
1188
+ * @private
1189
+ * @param {FirstPersonPlayerController} controller
1190
+ * @param {PerEntityRuntime} runtime
1191
+ * @param {Transform} bodyTransform
1192
+ * @param {number} dt
1193
+ */
1194
+ _integrateVerticalAndResolveGround(controller, runtime, bodyTransform, dt) {
1195
+ const cfg = controller.config;
1196
+ const state = controller.state;
1197
+ const sig = controller.signals;
1198
+
1199
+ // Gravity with fall/cut multipliers.
1200
+ let gMag = runtime.gravity;
1201
+ if (runtime.velocityY <= 0) {
1202
+ gMag *= cfg.jump.fallGravityMult;
1203
+ state.isAscending = false;
1204
+ } else if (state.isVariableJumpCut) {
1205
+ gMag *= cfg.jump.cutGravityMult;
1206
+ }
1207
+ runtime.velocityY -= gMag * dt;
1208
+
1209
+ // Horizontal sweep — `_moveAndSlide` casts the player's capsule
1210
+ // along (vx, 0, vz) * dt and stops at first contact, so the
1211
+ // player can't tunnel into walls. Vertical is integrated as a
1212
+ // direct add below; the ground resolver handles floor contact
1213
+ // and the move-and-slide is intentionally NOT 3D to avoid the
1214
+ // SKIN-clearance-vs-floor-snap conflict (a small SKIN backoff
1215
+ // would land the player a few mm above the floor, which the
1216
+ // resolver would then re-flag as airborne).
1217
+ this._moveAndSlide(
1218
+ runtime, bodyTransform,
1219
+ runtime.velocityX * dt, 0, runtime.velocityZ * dt,
1220
+ );
1221
+
1222
+ // Vertical integration — direct add; ground resolution below
1223
+ // does the snap on contact.
1224
+ bodyTransform.position._add(0, runtime.velocityY * dt, 0);
1225
+
1226
+ // Ground resolution.
1227
+ // Effective ground = max(built-in flat ground, optional resolver).
1228
+ // - useBuiltInFlatGround=true gives a baseline floor at groundY.
1229
+ // - groundResolver lets the host scene raise the floor under
1230
+ // platforms / terrain. Returns the surface Y under the player,
1231
+ // or null when no ground is below (gap / void).
1232
+ // If both are off, the original "external physics" branch
1233
+ // (else-block below) just tracks timers and leaves grounded
1234
+ // alone — the host's physics layer is expected to set it.
1235
+ if (this.useBuiltInFlatGround || this.groundResolver !== null) {
1236
+ let testY = this.useBuiltInFlatGround ? this.groundY : Number.NEGATIVE_INFINITY;
1237
+ if (this.groundResolver !== null) {
1238
+ const resolved = this.groundResolver(
1239
+ bodyTransform.position.x,
1240
+ bodyTransform.position.y,
1241
+ bodyTransform.position.z,
1242
+ );
1243
+ if (resolved !== null && resolved > testY) testY = resolved;
1244
+ }
1245
+ const haveGround = testY !== Number.NEGATIVE_INFINITY;
1246
+ if (haveGround && bodyTransform.position.y <= testY) {
1247
+ bodyTransform.position.setY(testY);
1248
+
1249
+ if (!state.grounded) {
1250
+ // Land — apply all state changes first, then fire the
1251
+ // signal LAST so handlers see the fully-reacted state.
1252
+ const impactVy = -runtime.velocityY;
1253
+ const kind = impactVy >= cfg.landing.hardThreshold ? "hard"
1254
+ : (impactVy >= cfg.landing.softThreshold ? "soft" : "soft");
1255
+
1256
+ const massScaledDip = impactVy * cfg.landing.recovery.dipPerVy
1257
+ * runtime.massRatios.landingDipScale;
1258
+ const dip = clamp(massScaledDip, 0, cfg.landing.recovery.dipMax);
1259
+ runtime.landSpring.settle(-dip);
1260
+
1261
+ const landImpulse = clamp(
1262
+ impactVy * cfg.exertion.landImpulsePerVy * runtime.massRatios.exertionRiseScale,
1263
+ 0,
1264
+ cfg.exertion.landImpulseMax,
1265
+ );
1266
+ state.exertion = clamp(state.exertion + landImpulse, 0, 1);
1267
+
1268
+ runtime.midJump = false;
1269
+ state.isAscending = false;
1270
+ state.isVariableJumpCut = false;
1271
+ state.fallDistance = 0;
1272
+
1273
+ sig.onLand.send1({ verticalSpeed: impactVy, kind });
1274
+ }
1275
+
1276
+ state.grounded = true;
1277
+ state.verticalSpeed = 0;
1278
+ runtime.velocityY = 0;
1279
+ state.airborneTime = 0;
1280
+ state.timeSinceGrounded = 0;
1281
+ } else {
1282
+ if (state.grounded) {
1283
+ sig.onLeaveGround.send1({ reason: runtime.midJump ? "jump" : "fall" });
1284
+ runtime.takeoffVy = runtime.velocityY;
1285
+ runtime.peakAltitude = bodyTransform.position.y;
1286
+ }
1287
+ state.grounded = false;
1288
+ state.verticalSpeed = runtime.velocityY;
1289
+ state.airborneTime += dt;
1290
+ state.timeSinceGrounded += dt;
1291
+ state.fallDistance += Math.max(0, -runtime.velocityY * dt);
1292
+ }
1293
+ } else {
1294
+ // External physics maintains state.grounded; just track timers.
1295
+ if (state.grounded) {
1296
+ state.timeSinceGrounded = 0;
1297
+ state.airborneTime = 0;
1298
+ } else {
1299
+ state.timeSinceGrounded += dt;
1300
+ state.airborneTime += dt;
1301
+ }
1302
+ }
1303
+
1304
+ // Jump apex detection.
1305
+ if (runtime.midJump && !runtime.apexFired) {
1306
+ if (bodyTransform.position.y > runtime.peakAltitude) {
1307
+ runtime.peakAltitude = bodyTransform.position.y;
1308
+ } else if (runtime.velocityY <= 0) {
1309
+ sig.onJumpApex.send0();
1310
+ runtime.apexFired = true;
1311
+ }
1312
+ }
1313
+ }
1314
+
1315
+ /**
1316
+ * Run the base (no-ability) L1 locomotion phases: speed selection,
1317
+ * desired-velocity computation, accel/decel, jump FSM, gravity, body
1318
+ * integration, ground resolution. Only invoked when no ability owns
1319
+ * the tick (see {@link AbilitySet.tick}).
1320
+ *
1321
+ * @private
1322
+ * @param {FirstPersonPlayerController} controller
1323
+ * @param {PerEntityRuntime} runtime
1324
+ * @param {Transform} bodyTransform
1325
+ * @param {number} dt
1326
+ * @param {boolean} isCrouchActive
1327
+ * @param {boolean} isSprintIntent
1328
+ * @param {boolean} isBackwardIntent
1329
+ */
1330
+ _runBaseLocomotion(controller, runtime, bodyTransform, dt,
1331
+ isCrouchActive, isSprintIntent, isBackwardIntent) {
1332
+ const cfg = controller.config;
1333
+ const intent = controller.intent;
1334
+ const state = controller.state;
1335
+
1336
+ // -- L1.b: Speed selection ------------------------------------
1337
+ let targetSpeed;
1338
+ if (isCrouchActive) {
1339
+ targetSpeed = cfg.motion.crouchSpeed;
1340
+ } else if (isSprintIntent) {
1341
+ targetSpeed = cfg.motion.sprintSpeed;
1342
+ } else {
1343
+ targetSpeed = cfg.motion.walkSpeed;
1344
+ }
1345
+ if (isBackwardIntent) {
1346
+ targetSpeed *= cfg.motion.backwardSpeedFactor;
1347
+ }
1348
+
1349
+ // Airborne momentum floor — preserve whatever horizontal speed
1350
+ // the player carried into the jump. Without this, a sprint
1351
+ // jump (9 m/s) decays toward walkSpeed (4.5 m/s) at
1352
+ // airAccel = 14 m/s², losing all sprint momentum in ~0.32 s —
1353
+ // well before the apex of a `peakHeight = 1.8 m` jump arc. The
1354
+ // air-control band (Mirror's Edge, Titanfall, modern CoD) and
1355
+ // the long-jump biomechanics literature both say the same
1356
+ // thing: there's no thrust source in flight, so horizontal
1357
+ // velocity is conserved across the arc and air "control" is
1358
+ // for steering (direction) — not for changing speed magnitude.
1359
+ // Raising the target to the current speed makes `stepTowards`
1360
+ // a no-op when the player keeps pressing forward, while
1361
+ // releasing the stick still lets `airAccel` decelerate to
1362
+ // `walkSpeed` (the user CAN bleed off speed, just not have it
1363
+ // bled off for them).
1364
+ if (!state.grounded) {
1365
+ const horizSpeed = Math.hypot(runtime.velocityX, runtime.velocityZ);
1366
+ if (horizSpeed > targetSpeed) targetSpeed = horizSpeed;
1367
+ }
1368
+
1369
+ // -- L1.c: Move intent → desired horizontal velocity ----------
1370
+ // screen_forward(θ) = ( sin θ, 0, cos θ )
1371
+ // screen_right (θ) = (-cos θ, 0, sin θ )
1372
+ const { sinYaw, cosYaw } = runtime;
1373
+ const mvX = intent.move.x;
1374
+ const mvY = intent.move.y;
1375
+ const mvMag = Math.hypot(mvX, mvY);
1376
+ const nmvX = mvMag > 1 ? mvX / mvMag : mvX;
1377
+ const nmvY = mvMag > 1 ? mvY / mvMag : mvY;
1378
+ const desiredVx = sinYaw * nmvY + -cosYaw * nmvX;
1379
+ const desiredVz = cosYaw * nmvY + sinYaw * nmvX;
1380
+ const desiredHorizontalVx = desiredVx * targetSpeed;
1381
+ const desiredHorizontalVz = desiredVz * targetSpeed;
1382
+
1383
+ // -- L1.d: Accel/decel toward desired velocity ----------------
1384
+ //
1385
+ // Three regimes — air control, grounded decel-to-stop, grounded
1386
+ // accel-to-target — each with its own model:
1387
+ //
1388
+ // • Air control: constant-rate `stepTowards`. No ground
1389
+ // reaction force in flight; air control is a steering
1390
+ // budget, not a thrust curve. Constant accel matches the
1391
+ // player mental model of "fixed mid-air authority".
1392
+ //
1393
+ // • Grounded decel (no intent): constant-rate `stepTowards`
1394
+ // toward zero. Friction is approximately constant for a
1395
+ // biped on level ground — Coulomb friction. Faster than
1396
+ // accel because the body's own resistance + active
1397
+ // decel-foot-plants combine into a sharper deceleration.
1398
+ //
1399
+ // • Grounded accel (intent active): mono-exponential
1400
+ // approach (Hill 1927; Furusawa-Hill 1928). dv/dt is
1401
+ // proportional to (v_target − v), so accel is highest at
1402
+ // low speed and tapers as v approaches v_target. Matches
1403
+ // human sprint biomechanics — modern sprint-profiling
1404
+ // work (Morin & Samozino 2016) fits this same mono-exp
1405
+ // curve to empirical force-plate data.
1406
+ //
1407
+ // The mass + mastery + backward scalars compose multiplicatively
1408
+ // on the EFFECTIVE half-life (heavier ⇒ longer half-life ⇒
1409
+ // slower ramp; mastery accel-bonus ⇒ shorter half-life ⇒
1410
+ // faster ramp). See FirstPersonPlayerControllerConfig.js's
1411
+ // `groundAccelHalfLife` doc for the literature and the
1412
+ // SprintAcceleration.spec.js for the model assertions.
1413
+ const intentLen = Math.hypot(nmvX, nmvY);
1414
+ if (!state.grounded) {
1415
+ const maxStep = cfg.motion.airAccel * dt;
1416
+ runtime.velocityX = stepTowards(runtime.velocityX, desiredHorizontalVx, maxStep);
1417
+ runtime.velocityZ = stepTowards(runtime.velocityZ, desiredHorizontalVz, maxStep);
1418
+ } else if (intentLen < 1e-4) {
1419
+ let decel = cfg.motion.groundDecel * runtime.massRatios.groundAccelScale;
1420
+ decel *= controller.mastery.evaluate(DecisionPoint.GroundAccel, controller, runtime);
1421
+ const maxStep = decel * dt;
1422
+ runtime.velocityX = stepTowards(runtime.velocityX, 0, maxStep);
1423
+ runtime.velocityZ = stepTowards(runtime.velocityZ, 0, maxStep);
1424
+ } else {
1425
+ // Mono-exponential approach. Scale half-life by the
1426
+ // inverse of the accel scalars so that "more accel" (large
1427
+ // groundAccelScale, mastery > 1.0) translates to a shorter
1428
+ // half-life (faster ramp). Backward intent slows things
1429
+ // down — backwardAccelFactor < 1 ⇒ longer half-life.
1430
+ let halfLife = cfg.motion.groundAccelHalfLife
1431
+ / runtime.massRatios.groundAccelScale
1432
+ / controller.mastery.evaluate(DecisionPoint.GroundAccel, controller, runtime);
1433
+ if (isBackwardIntent) halfLife /= cfg.motion.backwardAccelFactor;
1434
+ runtime.velocityX = exponentialApproach(runtime.velocityX, desiredHorizontalVx, halfLife, dt);
1435
+ runtime.velocityZ = exponentialApproach(runtime.velocityZ, desiredHorizontalVz, halfLife, dt);
1436
+ }
1437
+
1438
+ // -- L1.e/f/g/h: jump FSM + vertical integration --------------
1439
+ this._advanceJumpFsm(controller, runtime, bodyTransform, dt);
1440
+ this._integrateVerticalAndResolveGround(controller, runtime, bodyTransform, dt);
1441
+
1442
+ // -- Publish posture for L2 consumers (eye height, gait gating).
1443
+ // Base owns posture when no ability is active: Crouch if the
1444
+ // crouch intent is resolved active, otherwise Stand. Abilities
1445
+ // that need a different posture (slide → Prone, ledge-grab →
1446
+ // Hang) set state.posture themselves in their tick.
1447
+ controller.state.posture = isCrouchActive
1448
+ ? FirstPersonPosture.Crouch
1449
+ : FirstPersonPosture.Stand;
1450
+
1451
+ // -- Publish lean target for L2.f. Base writes the natural
1452
+ // (lat-accel + look-lean) value; abilities override in their
1453
+ // own tick. L2.f spring-steps toward whatever's here.
1454
+ runtime.leanTargetRad = this._computeNaturalLeanTarget(controller, runtime, dt);
1455
+ }
1456
+
1457
+ /**
1458
+ * Compute the natural camera lean for this tick: lat-accel-driven
1459
+ * roll into a turn, plus a yaw-rate look-lean contribution, both
1460
+ * clamped. The result is the target the lean spring chases each
1461
+ * tick when no ability has opinions.
1462
+ *
1463
+ * Pure-ish helper — reads `controller`, `runtime`, `dt`; returns a
1464
+ * number. Extracted so both base and any future ability that wants
1465
+ * to compose its lean on top of the natural value can call it.
1466
+ *
1467
+ * @private
1468
+ * @param {FirstPersonPlayerController} controller
1469
+ * @param {PerEntityRuntime} runtime
1470
+ * @param {number} dt
1471
+ * @returns {number} target roll in radians
1472
+ */
1473
+ _computeNaturalLeanTarget(controller, runtime, dt) {
1474
+ const cfg = controller.config;
1475
+ const state = controller.state;
1476
+ if (!cfg.lean.enabled) return 0;
1477
+
1478
+ const sinYaw = runtime.sinYaw;
1479
+ const cosYaw = runtime.cosYaw;
1480
+
1481
+ // Lateral acceleration projected onto screen-right.
1482
+ // accel_world = (vel - prevVel) / dt; screen_right = (-cos θ, 0, sin θ).
1483
+ const accWorldX = (runtime.velocityX - runtime.prevVelocityX) / Math.max(dt, 1e-4);
1484
+ const accWorldZ = (runtime.velocityZ - runtime.prevVelocityZ) / Math.max(dt, 1e-4);
1485
+ const latAccel = accWorldX * (-cosYaw) + accWorldZ * sinYaw;
1486
+ const normalized = clamp(latAccel / 9.81, -2, 2);
1487
+ //
1488
+ // Sign convention for the roll (the eye composes the rotation
1489
+ // as qYaw * qPitch * qRoll, where qRoll is around (0,0,1)).
1490
+ // After the engine's camera-invert pipeline:
1491
+ // φ > 0 → camera-up tilts toward screen-right (−X) → HEAD TILTS RIGHT
1492
+ // φ < 0 → camera-up tilts toward screen-left (+X) → HEAD TILTS LEFT
1493
+ //
1494
+ // For the "bank into the turn" feel (Apex / Titanfall / Mirror's
1495
+ // Edge): accelerating right (latAccel > 0) should tilt the head
1496
+ // RIGHT, i.e. positive φ. So leanTargetRad has the SAME sign
1497
+ // as latAccel.
1498
+ let leanTargetRad = normalized * cfg.lean.maxRollDeg * DEG_TO_RAD;
1499
+
1500
+ // Look-lean: yaw-rate-driven banking. runtime.yawRateRadPerSec
1501
+ // was cached at L1.a — negative is the "turn right" convention.
1502
+ // For "bank into the turn": turning right → head tilts right →
1503
+ // positive engine roll. So lookLean = -yawRate * scale matches
1504
+ // sign.
1505
+ //
1506
+ // Crouched players are in a low, stable, low-momentum stance —
1507
+ // banking the head from a mouse turn reads as unmotivated. We
1508
+ // scale the contribution down (default to 0) while crouched.
1509
+ // Lat-accel lean is left alone: its magnitude naturally tracks
1510
+ // the (lower) crouch acceleration, so it stays motivated.
1511
+ if (cfg.lean.lookLeanEnabled) {
1512
+ const yawRate = clamp(
1513
+ runtime.yawRateRadPerSec,
1514
+ -cfg.lean.lookLeanYawRateClamp,
1515
+ cfg.lean.lookLeanYawRateClamp,
1516
+ );
1517
+ const crouchFactor = state.crouchActive ? cfg.lean.crouchLookLeanFactor : 1.0;
1518
+ leanTargetRad += -yawRate * cfg.lean.lookLeanDegPerRadPerSec * DEG_TO_RAD * crouchFactor;
1519
+ }
1520
+
1521
+ // Final clamp on the sum: cap the combined target to ±2 ×
1522
+ // maxRollDeg (matches the latAccel normalized clamp range) so
1523
+ // even simultaneous max-strafe-accel + max-yaw-flick produces a
1524
+ // sane upper bound.
1525
+ const maxTotal = cfg.lean.maxRollDeg * DEG_TO_RAD * 2;
1526
+ return clamp(leanTargetRad, -maxTotal, maxTotal);
1527
+ }
1528
+
1529
+ /**
1530
+ * Snapshot the per-tick "what is the body doing" information into the
1531
+ * pose channels for downstream consumption (skeleton, sound, AI).
1532
+ * Read-only with respect to controller state — this is purely a publish
1533
+ * step.
1534
+ *
1535
+ * @private
1536
+ * @param {FirstPersonPlayerController} controller
1537
+ * @param {PerEntityRuntime} runtime
1538
+ * @param {Transform} bodyTransform
1539
+ */
1540
+ _publishPose(controller, runtime, bodyTransform) {
1541
+ const cfg = controller.config;
1542
+ const state = controller.state;
1543
+ const pose = controller.pose;
1544
+
1545
+ pose.rootPosition.copy(bodyTransform.position);
1546
+ pose.rootYawRad = runtime.bodyYaw;
1547
+ pose.headYawRad = runtime.bodyYaw;
1548
+ pose.headPitchRad = runtime.eyePitch;
1549
+ pose.headRollRad = state.leanRollRad;
1550
+ pose.locomotionPhase = state.stridePhase;
1551
+ pose.locomotionSpeed = runtime.horizSpeed;
1552
+ // Strafe component: project velocity onto screen-right (-cos θ, 0, sin θ).
1553
+ // Positive = moving to the player's right.
1554
+ pose.locomotionStrafe = (runtime.velocityX * (-runtime.cosYaw) + runtime.velocityZ * runtime.sinYaw)
1555
+ / Math.max(cfg.motion.sprintSpeed, 1e-3);
1556
+ pose.actionState =
1557
+ state.inJumpAnticipation ? FirstPersonActionState.Anticipating
1558
+ : !state.grounded ? FirstPersonActionState.Airborne
1559
+ : (Math.abs(runtime.landSpring.value) > 0.01 ? FirstPersonActionState.Landing
1560
+ : FirstPersonActionState.Grounded);
1561
+ pose.locomotionMode = state.locomotionMode;
1562
+ const crouchSpan = Math.max(cfg.body.height - cfg.body.crouchHeight, 1e-3);
1563
+ pose.crouchAmount = clamp((cfg.body.height - state.eyeHeight) / crouchSpan, 0, 1);
1564
+
1565
+ // Posture channel for downstream animation: which body shape +
1566
+ // how far the body is into it from the standing neutral.
1567
+ //
1568
+ // `posture` is the enum (Stand / Crouch / Prone / Hang) — picks
1569
+ // the animation track. `postureAmount` is the [0..1] blend
1570
+ // weight from standing toward that posture, derived from the
1571
+ // eye-height spring so the value transitions smoothly across
1572
+ // changes (matches the visible camera motion).
1573
+ pose.posture = state.posture;
1574
+ let postureTargetH;
1575
+ switch (state.posture) {
1576
+ case FirstPersonPosture.Prone: postureTargetH = cfg.body.proneHeight; break;
1577
+ case FirstPersonPosture.Crouch: postureTargetH = cfg.body.crouchHeight; break;
1578
+ case FirstPersonPosture.Hang: postureTargetH = cfg.body.height; break;
1579
+ case FirstPersonPosture.Stand:
1580
+ default: postureTargetH = cfg.body.height; break;
1581
+ }
1582
+ const postureSpan = Math.max(cfg.body.height - postureTargetH, 1e-3);
1583
+ pose.postureAmount = clamp((cfg.body.height - state.eyeHeight) / postureSpan, 0, 1);
1584
+
1585
+ pose.aimPitch = runtime.eyePitch;
1586
+ }
1587
+
1588
+ /**
1589
+ * Compose the eye transform from body + state-driven offsets.
1590
+ * @private
1591
+ * @param {FirstPersonPlayerController} controller
1592
+ * @param {number} entity
1593
+ */
1594
+ _composeEye(controller, entity) {
1595
+ const ecd = this.entityManager.dataset;
1596
+ const runtime = this.runtime.get(entity);
1597
+ if (runtime === undefined) return;
1598
+
1599
+ const dt = this._currentRenderDt;
1600
+ const cfg = controller.config;
1601
+ const state = controller.state;
1602
+
1603
+ const bodyTransform = ecd.getComponent(entity, Transform);
1604
+ if (bodyTransform === undefined) return;
1605
+
1606
+ if (controller.eyeEntity === -1) return;
1607
+ const eyeTransform = ecd.getComponent(controller.eyeEntity, Transform);
1608
+ const camera = ecd.getComponent(controller.eyeEntity, Camera);
1609
+ if (eyeTransform === undefined || camera === undefined) return;
1610
+
1611
+ // -- Body-local eye offset, composed via the additive stack ----
1612
+ // The base (0, eyeHeight, 0) is the standing/crouched neutral; each
1613
+ // additional contribution (bob, breath, landing, anticipation,
1614
+ // sprint posture) goes through the stack so external systems can
1615
+ // push their own contributions on the same channel.
1616
+ const stack = runtime.eyeOffsetStack;
1617
+ stack.clear();
1618
+ stack.push("eyeHeight", 0, state.eyeHeight, 0);
1619
+
1620
+ // Bob — gated on grounded only (the impact spring decays naturally
1621
+ // even at rest, so the bob fade-out is smooth; lateral amp uses the
1622
+ // bob-intensity envelope which spring-decays after stopping).
1623
+ if (state.grounded) {
1624
+ const phase = state.stridePhase * TWO_PI;
1625
+ const massBoost = (cfg.body.mass - 80) * cfg.bob.ampMassScale;
1626
+ const intensity = runtime.bobIntensitySpring.value;
1627
+
1628
+ // Back-pedal amp boost — lateral grows more than vertical because
1629
+ // backward gait has worse side-to-side balance than vertical compression.
1630
+ // Exertion adds a smaller boost on top: tired = wobbly gait.
1631
+ const ampLMult = 1 + (cfg.bob.backwardLateralAmpFactor - 1) * runtime.backwardness;
1632
+ const exertionBoost = 1 + cfg.exertion.bobLateralBoostAtMax * state.exertion;
1633
+ const ampL = (cfg.bob.lateralAmpAtWalk + massBoost) * intensity * ampLMult * exertionBoost;
1634
+
1635
+ // Vertical: read directly from the impact spring (footfall kicks,
1636
+ // under-damped recovery → trough + leg-push overshoot).
1637
+ stack.push("bob.impact", 0, runtime.verticalImpactSpring.value, 0);
1638
+
1639
+ // Lateral: head shifts toward the foot bearing weight. Polarity
1640
+ // sourced from runtime.standingFoot — the same signal the
1641
+ // footstep emits — so bob direction and footstep side agree.
1642
+ // |sin(phase)| is the non-negative "midstance envelope".
1643
+ const lateralPolarity = runtime.standingFoot === "R" ? -1 : 1;
1644
+ stack.push("bob.lateral", ampL * lateralPolarity * Math.abs(Math.sin(phase)), 0, 0);
1645
+ }
1646
+
1647
+ // Breath — sine + tiny noise riding the rate spring.
1648
+ const breathOffset = -state.breathAmplitudeM
1649
+ * Math.sin(state.breathPhase * TWO_PI)
1650
+ * (1 + cfg.breath.noiseAmount * (Math.sin(state.breathPhase * 13.7) * 0.5));
1651
+ stack.push("breath", 0, breathOffset, 0);
1652
+
1653
+ // Landing spring dip (under-damped — overshoots once on recovery).
1654
+ stack.push("landing", 0, runtime.landSpring.value, 0);
1655
+
1656
+ // Jump anticipation dip (eased ramp during the squash window).
1657
+ if (state.inJumpAnticipation) {
1658
+ const t = 1 - clamp(runtime.anticipationRemaining / Math.max(cfg.jump.anticipation.duration, 1e-3), 0, 1);
1659
+ const eased = t * (2 - t); // ease-out quad
1660
+ stack.push("anticipation", 0, -cfg.jump.anticipation.dipAmount * eased, 0);
1661
+ }
1662
+
1663
+ // Sprint posture: head leans slightly forward as commitment builds.
1664
+ // Pitch part is in the rotation block below; the +Z position shift
1665
+ // sells "head leading the hips" (Mirror's Edge), tied to the same
1666
+ // spring envelope so they move together.
1667
+ const sprintPitch = runtime.sprintPostureSpring.value;
1668
+ const sprintShiftFraction =
1669
+ cfg.posture.sprintForwardPitchDeg > 0
1670
+ ? sprintPitch / (cfg.posture.sprintForwardPitchDeg * DEG_TO_RAD)
1671
+ : 0;
1672
+ stack.push("posture.sprintShift", 0, 0, cfg.posture.sprintForwardShiftM * sprintShiftFraction);
1673
+
1674
+ // Transform body-local accumulated offset into world space.
1675
+ const worldOffset = SCRATCH_V3_B.copy(stack.offset);
1676
+ worldOffset.applyQuaternion(bodyTransform.rotation);
1677
+
1678
+ eyeTransform.position.copy(bodyTransform.position);
1679
+ eyeTransform.position._add(worldOffset.x, worldOffset.y, worldOffset.z);
1680
+
1681
+ // -- Eye rotation: body yaw × eye pitch × roll -------------------
1682
+ // Bob roll mixes in for a subtle head sway (in phase with lateral bob).
1683
+ // Breath pitch is a small extra nod 90° out of phase with vertical
1684
+ // breath; merged into the main pitch so we don't pay an extra quat
1685
+ // multiply and the composition stays trivially correct.
1686
+ let rollTotal = state.leanRollRad;
1687
+ if (state.grounded) {
1688
+ // Roll: head tilts toward the standing foot, in phase with the
1689
+ // lateral sway. Polarity sourced from runtime.standingFoot for
1690
+ // consistency with the lateral bob. Positive engine roll = head
1691
+ // tilts RIGHT (camera-invert convention), so R-foot midstance =
1692
+ // positive roll, L-foot midstance = negative roll.
1693
+ const phase = state.stridePhase * TWO_PI;
1694
+ const rollBackMult = 1 + (cfg.bob.backwardRollFactor - 1) * runtime.backwardness;
1695
+ const ampRoll = cfg.bob.rollAtWalkDeg * DEG_TO_RAD * runtime.bobIntensitySpring.value * rollBackMult;
1696
+ const rollPolarity = runtime.standingFoot === "R" ? 1 : -1;
1697
+ const rollEnvelope = Math.abs(Math.sin(phase));
1698
+ const bobRollSigned = ampRoll * rollPolarity * rollEnvelope;
1699
+
1700
+ // Lean × bob coupling: excursions in the lean direction get
1701
+ // amplified, opposite excursions attenuated. Lean is normalized
1702
+ // against maxRollDeg so the coupling magnitude stays bounded
1703
+ // regardless of how aggressively lean is configured.
1704
+ const maxLeanRad = Math.max(cfg.lean.maxRollDeg * DEG_TO_RAD, 1e-6);
1705
+ const leanFraction = clamp(state.leanRollRad / maxLeanRad, -1, 1);
1706
+ // sign(bobRollSigned) matches lean? amplify; else attenuate.
1707
+ const sameSign = (bobRollSigned * leanFraction) >= 0;
1708
+ const couplingMag = cfg.bob.leanCouplingFactor * Math.abs(leanFraction);
1709
+ const couplingScale = sameSign ? (1 + couplingMag) : (1 - couplingMag);
1710
+ rollTotal += bobRollSigned * couplingScale;
1711
+ }
1712
+
1713
+ const breathPitch = lerp(cfg.breath.pitchAmpRestDeg, cfg.breath.pitchAmpMaxDeg, state.exertion)
1714
+ * DEG_TO_RAD
1715
+ * Math.cos(state.breathPhase * TWO_PI);
1716
+ // Combined pitch contributions: player input + breath nod + sprint
1717
+ // commitment + fatigue droop. All in the same "positive = look-down"
1718
+ // convention so they sum cleanly.
1719
+ const pitchTotal = runtime.eyePitch
1720
+ + breathPitch
1721
+ + runtime.sprintPostureSpring.value
1722
+ + runtime.headDroopSpring.value;
1723
+
1724
+ // composition: yaw * pitch * roll
1725
+ // pitch around world X — yaw applied after, so effective axis is camera-local right
1726
+ // roll around world Z — yaw and pitch applied after, so effective axis is camera-local forward
1727
+ const qYaw = SCRATCH_Q_A.fromAxisAngle(Vector3.up, runtime.bodyYaw);
1728
+ const qPitch = SCRATCH_Q_B.fromAxisAngle(Vector3.right, pitchTotal);
1729
+ const qRoll = SCRATCH_Q_C.fromAxisAngle(Vector3.forward, rollTotal);
1730
+
1731
+ eyeTransform.rotation.multiplyQuaternions(qYaw, qPitch);
1732
+ eyeTransform.rotation.multiply(qRoll);
1733
+
1734
+ // -- FOV ---------------------------------------------------------
1735
+ let fovTarget = cfg.fov.base;
1736
+ if (cfg.fov.sprintAdd !== 0) {
1737
+ fovTarget += cfg.fov.sprintAdd * runtime.sprintness;
1738
+ }
1739
+ if (state.crouchActive) fovTarget += cfg.fov.crouchAdd;
1740
+
1741
+ runtime.fovSpring.stepTo(fovTarget, cfg.fov.smoothHalfLife, 1.0, dt);
1742
+ // Write directly to the underlying Three.js camera. Going through
1743
+ // camera.fov.set() fires onChanged which triggers a full camera
1744
+ // rebuild in CameraSystem — far too expensive to do per frame.
1745
+ // The CameraSystem's visibility-construction hook calls
1746
+ // updateProjectionMatrix() each frame anyway.
1747
+ if (camera.object !== null) {
1748
+ camera.object.fov = runtime.fovSpring.value;
1749
+ }
1750
+ }
1751
+ }
1752
+
1753
+ // ---------------------------------------------------------------------------
1754
+ // helpers
1755
+ // ---------------------------------------------------------------------------
1756
+
1757
+ /**
1758
+ * Exponential approach with half-life parameterization.
1759
+ * @param {number} current
1760
+ * @param {number} target
1761
+ * @param {number} halfLife
1762
+ * @param {number} dt
1763
+ * @returns {number}
1764
+ */
1765
+ function exponentialApproach(current, target, halfLife, dt) {
1766
+ if (halfLife <= 0) return target;
1767
+ const alpha = 1 - Math.exp(-LN2 * dt / halfLife);
1768
+ return current + (target - current) * alpha;
1769
+ }
1770
+
1771
+ /**
1772
+ * Detect that phase value crossed a boundary in [0,1) between two ticks.
1773
+ * Handles the wraparound case where phase jumps from e.g. 0.95 to 0.05.
1774
+ *
1775
+ * @param {number} prev previous phase in [0,1)
1776
+ * @param {number} next current phase in [0,1)
1777
+ * @param {number} boundary in [0,1)
1778
+ * @returns {boolean}
1779
+ */
1780
+ function phaseCrossed(prev, next, boundary) {
1781
+ if (next >= prev) {
1782
+ // no wrap
1783
+ return prev < boundary && next >= boundary;
1784
+ } else {
1785
+ // wrapped past 1.0
1786
+ return prev < boundary || next >= boundary;
1787
+ }
1788
+ }
1789
+