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