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