@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
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "description": "Pure JavaScript game engine. Fully featured and production ready.",
7
7
  "type": "module",
8
8
  "author": "Alexander Goldring",
9
- "version": "2.143.0",
9
+ "version": "2.144.0",
10
10
  "main": "build/meep.module.js",
11
11
  "module": "build/meep.module.js",
12
12
  "exports": {
@@ -9,6 +9,7 @@ export class PointShape3D extends AbstractShape3D {
9
9
  nearest_point_on_surface(result: any, reference: any): void;
10
10
  sample_random_point_in_volume(result: any, result_offset: any, random: any): void;
11
11
  support(result: any, result_offset: any, direction_x: any, direction_y: any, direction_z: any): void;
12
+ equals(other: any): boolean;
12
13
  }
13
14
  import { AbstractShape3D } from "./AbstractShape3D.js";
14
15
  //# sourceMappingURL=PointShape3D.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"PointShape3D.d.ts","sourceRoot":"","sources":["../../../../../../src/core/geom/3d/shape/PointShape3D.js"],"names":[],"mappings":"AAGA;;GAEG;AACH;IA4CI,8BAAqC;IAnCrC,wCAOC;IAED,6CAEC;IAED,oCAEC;IAED,4DAIC;IAED,kFAIC;IAED,qGAIC;CAGJ;gCAlD+B,sBAAsB"}
1
+ {"version":3,"file":"PointShape3D.d.ts","sourceRoot":"","sources":["../../../../../../src/core/geom/3d/shape/PointShape3D.js"],"names":[],"mappings":"AAGA;;GAEG;AACH;IAuDI,8BAAqC;IA9CrC,wCAOC;IAED,6CAEC;IAED,oCAEC;IAED,4DAIC;IAED,kFAIC;IAED,qGAIC;IAED,4BAGC;CASJ;gCA7D+B,sBAAsB"}
@@ -48,5 +48,16 @@ export class PointShape3D extends AbstractShape3D {
48
48
  result[result_offset + 2] = 0;
49
49
  }
50
50
 
51
+ equals(other) {
52
+ // Parameterless: identity is the type alone (a point has no fields).
53
+ return super.equals(other);
54
+ }
55
+
56
+ hash() {
57
+ // Constant — every PointShape3D is identical. Distinct from the
58
+ // base-class 0 so it doesn't collide with a hypothetical default shape.
59
+ return 0x504f494e; // "POIN"
60
+ }
61
+
51
62
  static INSTANCE = new PointShape3D();
52
63
  }
@@ -32,6 +32,7 @@ export class SphereShape3D extends AbstractShape3D {
32
32
  signed_distance_at_point(point: any): number;
33
33
  contains_point(point: any): boolean;
34
34
  sample_random_point_in_volume(result: any, result_offset: any, random: any): void;
35
+ equals(other: any): boolean;
35
36
  /**
36
37
  * Fast type-check marker. Lets the physics narrowphase short-circuit
37
38
  * sphere-involved pairs to closed-form solvers (reading `radius`) rather than
@@ -1 +1 @@
1
- {"version":3,"file":"SphereShape3D.d.ts","sourceRoot":"","sources":["../../../../../../src/core/geom/3d/shape/SphereShape3D.js"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;GAcG;AACH;IAUI;;;;OAIG;IACH,oBAHW,MAAM,GACJ,aAAa,CAMzB;IAhBG;;;OAGG;IACH,QAFU,MAAM,CAED;IAwBnB,qGAQC;IAED,wCAQC;IAED,4DAaC;IAED,mEAEC;IAED,6CAEC;IAED,oCAOC;IAED,kFAMC;IAOL;;;;;;;;OAQG;IACH,0BAFU,OAAO,CAEsB;CAXtC;gCAhH+B,sBAAsB"}
1
+ {"version":3,"file":"SphereShape3D.d.ts","sourceRoot":"","sources":["../../../../../../src/core/geom/3d/shape/SphereShape3D.js"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;GAcG;AACH;IAUI;;;;OAIG;IACH,oBAHW,MAAM,GACJ,aAAa,CAMzB;IAhBG;;;OAGG;IACH,QAFU,MAAM,CAED;IAwBnB,qGAQC;IAED,wCAQC;IAED,4DAaC;IAED,mEAEC;IAED,6CAEC;IAED,oCAOC;IAED,kFAMC;IAED,4BAEC;IAOL;;;;;;;;OAQG;IACH,0BAFU,OAAO,CAEsB;CAXtC;gCApH+B,sBAAsB"}
@@ -110,6 +110,10 @@ export class SphereShape3D extends AbstractShape3D {
110
110
  result[result_offset + 2] *= r;
111
111
  }
112
112
 
113
+ equals(other) {
114
+ return super.equals(other) && this.radius === other.radius;
115
+ }
116
+
113
117
  hash() {
114
118
  return computeHashFloat(this.radius);
115
119
  }
@@ -1,255 +1,302 @@
1
- # Collision handling — construction plan
1
+ # Collision handling — ground-up construction plan
2
2
 
3
- Companion to DESIGN.md (base controller) and TODO.md. This is the
4
- roadmap for taking the controller's collision response from the current
5
- 2.5D split to a unified, slope-aware, anti-tunnelling mover modelled on
6
- the Quake → Source `TryPlayerMove` / `CategorizePosition` lineage —
7
- using the now-accurate `shapeCast` / `raycast` the physics engine
8
- gained over the P1–P6 narrowphase work.
3
+ Companion to DESIGN.md (base controller) and TODO.md.
9
4
 
10
- Every phase is **guard-test-first**: write the failing spec that pins
11
- the behaviour, then implement until green, then run the full
12
- first-person suite. Phases are ordered so the controller stays shippable
13
- and tested after each one no flag day.
5
+ **Premise.** The control layer is excellent and stays. The collision
6
+ layer is being **replaced wholesale** the current
7
+ `_moveAndSlide` + `_integrateVerticalAndResolveGround` + scalar
8
+ ground-resolver regime is not a foundation to build on; it's scaffolding
9
+ to delete once the replacement reaches parity. We keep relying on
10
+ `PhysicsSystem` for all the heavy lifting (`shapeCast`, `raycast`,
11
+ `overlap`, `compute_penetration`) — this plan does *more* with it, not
12
+ less.
13
+
14
+ Every phase is **guard-test-first**, and ordered so the controller stays
15
+ shippable and green after each one. The new mover is built alongside,
16
+ proven against ported + new specs, switched over, and only then is the
17
+ old code deleted.
14
18
 
15
19
  ---
16
20
 
17
- ## 1. Goal & non-goals
21
+ ## 1. The seam: control produces motion, collision resolves it
22
+
23
+ The clean split that makes a ground-up rebuild safe — the control layer
24
+ already does its half today:
25
+
26
+ ```
27
+ CONTROL (sacred, unchanged) COLLISION (rebuilt)
28
+ ────────────────────────── ──────────────────
29
+ intent → desired velocity move(pose, shape, velocity, dt)
30
+ accel curves, jump FSM, ──v──▶ → resolved position
31
+ gravity/impulse, abilities, → corrected velocity
32
+ mastery, posture → ground state {grounded, normal}
33
+ ▲ │
34
+ └────────── grounded, groundNormal ◀─────────┘
35
+ (read next tick by jump FSM,
36
+ gravity gating, slope logic)
37
+ ```
18
38
 
19
- **Goal.** A single swept move-and-slide that handles walls, floors,
20
- ceilings, ramps, and getting unstuck with true surface normals — so
21
- abilities (Slide, WallRun, Mantle) inherit correct collision for free
22
- by routing their motion through it.
39
+ The control layer's output each tick is a **desired velocity** (it
40
+ already computes thisintent+accel horizontally, gravity+jump
41
+ vertically). The collision layer's *only* job is: given that velocity,
42
+ the capsule pose, and the world, produce the actual resulting position,
43
+ the velocity corrected for what it hit, and the ground state. It never
44
+ invents motion; it only constrains it.
23
45
 
24
- **Non-goals.**
25
- - Bunny-hop / strafe-jump emergent acceleration. We deliberately
26
- *preserve* momentum (Mirror's Edge / modern-CoD model), not *amplify*
27
- it (Quake / Titanfall model). See the momentum note in DESIGN.md.
28
- - A full dynamics solver for the player. The player stays a
29
- `KinematicPosition` body; the controller owns the Transform.
30
- - Replacing the abilities' bespoke motion models — only giving them a
31
- correct shared mover to call.
46
+ One cleanup this forces (Phase 4): **gravity + jump-impulse application
47
+ move out of the deleted collision method into a small motor helper**
48
+ the base loop and abilities call. The solver stays purely about
49
+ collision.
32
50
 
33
51
  ---
34
52
 
35
- ## 2. Current state (one paragraph)
53
+ ## 2. What we keep / replace / delete
54
+
55
+ **Keep (untouched):** the whole control stack — intent, mono-exp accel,
56
+ jump FSM, posture→collider sizing, abilities (Slide/WallRun/Mantle/
57
+ LedgeGrab), mastery, camera composition, sensors.
36
58
 
37
- `_integrateVerticalAndResolveGround` does a **2.5D split**: gravity
38
- horizontal-only swept slide (`_moveAndSlide`, 4-iteration collide-and-
39
- slide, `SKIN = 0.005`, `CAST_STEP_HEIGHT = 0.05`) → a *direct* vertical
40
- position add → a downward-ray ground resolver that snaps a scalar Y.
41
- The slide projects against true narrowphase normals (good), but: the
42
- ground resolver discards the surface normal (no slopes), vertical motion
43
- isn't swept (tunnels through thin floors, no ceiling detection), corners
44
- re-project planes independently (no crease/dead-stop), and there's no
45
- depenetration. Full analysis in the review that preceded this doc.
59
+ **Replace:** the regime that *consumes* velocity
60
+ `_moveAndSlide` (horizontal-only swept slide), the direct vertical
61
+ position add, and the downward-ray scalar-Y ground snap.
62
+
63
+ **Delete (Phase 4, after parity):** `_moveAndSlide`,
64
+ `_integrateVerticalAndResolveGround`'s collision body, the
65
+ `CAST_STEP_HEIGHT` implicit-step hack, and the scalar `groundResolver`
66
+ *as the grounding authority* (it survives only as a no-physics fallback
67
+ inside the new ground probe).
46
68
 
47
69
  ---
48
70
 
49
- ## 3. Physics capabilities we can now lean on
71
+ ## 3. Physics capabilities we build on
50
72
 
51
- The P1–P6 narrowphase + query work made the two primitives we depend on
52
- narrowphase-exact. This plan is only feasible because of it.
73
+ The P1–P6 narrowphase/query work is the enabling foundation the
74
+ rebuild is only feasible because these are now narrowphase-exact.
53
75
 
54
76
  | Capability | Status | Source |
55
77
  |---|---|---|
56
- | `raycast` exact surface **normal** (sphere/box/capsule/mesh/heightmap) | ✅ landed | refine_ray_hit P1–P3 (`6f931b4`, `5cc2e3e`, `1a2a602`, `4c2292c`) |
57
- | `shapeCast` true contact **normal at TOI** via MPR (not broadphase −dir) | ✅ landed | MPR normal-recovery in `shape_cast.js` |
58
- | `shapeCast` **start-in-contact** → returns `t=0` + valid normal | ✅ landed | `shape_cast.js:195`, normal probe `:350` |
59
- | `shapeCast` analytic slab-narrowing (long-sweep accuracy/perf) | ✅ landed | `shape_cast.js:203` |
60
- | EPA adaptive tolerance (no silent sub-mm failure) | ✅ landed | P2.2 (`8ff15a6`) |
61
- | MPR fallback when EPA doesn't converge | ✅ landed | P3.1 (`419b94a`) |
62
- | box-box edge-edge closest-pair (not centre-midpoint) | ✅ landed | P3.2 (`419b94a`) |
63
- | capsule-vs-triangle closed-form multi-point manifold | ✅ landed | P1.1c (`eaa75fa`) |
64
- | GJK separating-axis cache (cheap warm casts) | ✅ landed | P2.1 (`60ca759`) |
65
- | `overlap(shape,pos,rot,out,off,filter)` body-id list | landed | `overlap_shape.js` |
66
- | `compute_penetration(...)` depth + B→A direction | ⚠️ **internal-only** | `compute_penetration.js` — not on PhysicsSystem |
67
-
68
- Query contract notes the plan relies on:
69
- - `shapeCast` `result.t` is a **world distance** when the ray direction
70
- is unit-length and `tMax = sweepLength` (how `_moveAndSlide` already
71
- uses it). New casts must keep that convention.
72
- - `result.position` is the **swept shape centre**, not the surface
73
- contact point. We never need the contact point in this plan; flagged
74
- in case edge/foot placement ever does.
75
- - `result.normal` is the **target's outward** surface normal.
78
+ | `raycast` exact surface **normal** (sphere/box/capsule/mesh/heightmap) | ✅ | refine_ray_hit P1–P3 (`6f931b4`…`4c2292c`) |
79
+ | `shapeCast` true contact **normal at TOI** (MPR, not broadphase −dir) | ✅ | `shape_cast.js` normal-recovery |
80
+ | `shapeCast` **start-in-contact** → `t=0` + valid normal | ✅ | `shape_cast.js:195`, `:350` |
81
+ | `shapeCast` analytic slab-narrowing (long-sweep accuracy) | ✅ | `shape_cast.js:203` |
82
+ | `compute_penetration(out, A,pa,ra, B,pb,rb) depth`, `out`=unit B→A | ✅ **public** | `compute_penetration.js:138` |
83
+ | `overlap(shape,pos,rot,out,off,filter)` body-id list | ✅ | `overlap_shape.js` |
84
+ | EPA adaptive tol / MPR fallback / box-box edge / capsule-tri manifold / GJK cache | ✅ | P2.2, P3.1, P3.2, P1.1c, P2.1 |
85
+
86
+ Contracts the plan honours:
87
+ - `shapeCast` `result.t` is a **world distance** when direction is
88
+ unit-length and `tMax = sweepLength`. New casts keep that.
89
+ - `compute_penetration` accepts the convex player capsule vs any
90
+ shape (only concave-vs-concave throws); `out_direction` is the unit
91
+ **B→A** separation, depth is the scalar return. Push the capsule (A)
92
+ out along `+out_direction · depth`.
93
+ - `shapeCast` `result.position` is the swept **centre**, not the
94
+ contact point — we derive ground-surface Y from the cast `t` + the
95
+ capsule's bottom offset, so we never need a contact point.
76
96
 
77
97
  ---
78
98
 
79
- ## 4. Target architecture
99
+ ## 4. Target architecture — a dedicated `KinematicMover`
100
+
101
+ Extract the collision solver into its own module (name placeholder —
102
+ `KinematicMover` / `CharacterMover`), testable in isolation against a
103
+ real `PhysicsSystem`, not buried in the 1500-line system file. Matches
104
+ the repo's extraction taste (Spring, EyeOffsetStack, FirstPersonSensors).
80
105
 
81
- Replace the 2.5D split with a **unified swept solve + ground
82
- categorize**, the Source pattern:
106
+ It knows nothing about the controller just a capsule, a velocity, the
107
+ world, and an `up` axis. Single entry point:
83
108
 
84
109
  ```
85
- move(dt):
86
- applyGravityToVelocity() # vy -= g·dt
87
- delta = velocity · dt # full 3D, gravity folded in
88
- collideAndSlide(delta) # swept, all axes, crease-aware
89
- categorizeGround() # short down-probe → grounded + normal
90
- if grounded and walkable: stayOnGround() # snap to surface, zero vy
110
+ move(transform, shape, velocity, dt, filter) → MoveResult
111
+ // mutates transform.position; returns:
112
+ // { velocityX/Y/Z (corrected), grounded, groundNormal, hit }
91
113
  ```
92
114
 
93
- Three pieces:
94
-
95
- 1. **`collideAndSlide(delta)`**the existing 4-iteration loop,
96
- generalised to 3D and made crease-aware (two-plane seam projection,
97
- three-plane dead-stop). Gravity is part of `delta`, so floors and
98
- ceilings are just contact planes anti-tunnelling falls out.
99
-
100
- 2. **`categorizeGround()`** a short **downward `shapeCast`** (the
101
- capsule swept down by `SKIN + groundProbeBand`, e.g. ~6 cm). Returns
102
- `{grounded, surfaceY, normal}`. Replaces the scalar-Y resolver as the
103
- authoritative ground source when physics is present; the host
104
- resolver / flat-ground stays as the no-physics fallback, behind one
105
- `_probeGround()` function so callers never branch (uniform flow).
106
-
107
- 3. **`stayOnGround()`**when categorize says grounded on a walkable
108
- plane, snap `position.y` to `surfaceY` and zero `vy`. This is what
109
- kills the SKIN-vs-snap **bounce** (the bug we hit earlier): grounded
110
- is a *band test + active snap*, not a strict `y <= testY`
111
- inequality, so landing at `floor + SKIN` doesn't re-flag airborne.
112
-
113
- Walkability gate: `groundNormal.y >= MIN_WALK_NORMAL` (≈0.7, ~45°). On a
114
- steeper face the player isn't grounded they slide, because the slope
115
- is just another clip plane in `collideAndSlide` and gravity carries them
116
- down it.
115
+ Modelled on modern kinematic character controllers (Jolt
116
+ `CharacterVirtual`, Source `TryPlayerMove`+`CategorizePosition`), the
117
+ move is an explicit sequence **recover slide → (stairs) → ground →
118
+ settle**:
119
+
120
+ 1. **Recover (depenetration).** Before moving, get clear. `overlap()`
121
+ the capsule; for each body, `compute_penetration` → push the capsule
122
+ out by `depth · dir`; iterate a few times (deepest-first) to
123
+ convergence. Handles spawned-in-geometry, world-crushed-into-us, and
124
+ the start-solid case a pure sweep *cannot*. This is now a **core
125
+ step**, available from Phase 1 not a bolt-on — because
126
+ `compute_penetration` is public.
127
+
128
+ 2. **Sweep-and-slide (unified 3D).** Sweep the *full* velocity·dt
129
+ (gravity folded in no H/V split) via `shapeCast`; stop at TOI,
130
+ clip velocity onto the contact tangent, project the residual, repeat
131
+ up to N iterations. **Crease-aware**: a second plane re-violating the
132
+ first project onto the seam `normalize(n₁×n₂)`; a third plane in
133
+ one move dead-stop. Walls, floors, ceilings are all just contact
134
+ planes, so anti-tunnelling (H and V) falls out of one loop.
135
+
136
+ 3. **Stairs (explicit, optional per move).** When grounded and blocked
137
+ by a low step: sweep up by `stepHeight`, sweep the residual forward,
138
+ sweep back down — the Jolt/Source step pattern. Replaces the 5 cm
139
+ implicit-cast hack with real stair climbing, gated so walls taller
140
+ than `stepHeight` still block.
141
+
142
+ 4. **Ground categorize + stick.** Short downward `shapeCast` (capsule
143
+ swept down a `SKIN + band`); if it hits within the band with
144
+ `normal.y ≥ MIN_WALK_NORMAL` (~0.7), set `grounded`, capture the
145
+ normal, snap to the surface, and kill the into-ground velocity
146
+ component. Band-test + active snap (not a strict `y ≤ testY`) is what
147
+ structurally kills the landing **bounce**. Too-steep normal →
148
+ not grounded → the slope was already a slide plane in step 2, so the
149
+ player slides down it.
150
+
151
+ 5. **Settle.** One final `compute_penetration` recover pass guarantees
152
+ the tick ends penetration-free (resting-contact float drift
153
+ insurance; usually a no-op).
154
+
155
+ Slope handling, multi-body resting contact, and unstick all fall out of
156
+ steps 1/2/4 — no special cases. Abilities get correct collision for free
157
+ by routing their motion through the same `move()`.
117
158
 
118
159
  ---
119
160
 
120
- ## 5. Phased plan
121
-
122
- ### Phase 1 — Ground normal + slope policy
123
- *Review gap #1. Highest feel/realism leverage; lowest risk.*
124
-
125
- - Add `_probeGround(runtime, bodyTransform) {grounded, surfaceY, normal}`.
126
- Physics path: downward `shapeCast` of the capsule, read `hit.normal`.
127
- No-physics path: existing resolver / flat-ground, normal = `(0,1,0)`.
128
- - Write `state.groundNormal`. Add `MIN_WALK_NORMAL` to config.
129
- - Reproject grounded horizontal velocity onto the ground plane
130
- (`v -= (v·n)·n`) so ramps don't launch (downhill) or clip (uphill).
131
- - Steep facenot grounded slides under gravity.
132
-
133
- **Guard tests** (new `GroundSlope.spec.js`): walk up a 30° ramp keeps
134
- speed and stays grounded; stand on shallow slope = grounded with correct
135
- normal; 60° face = not grounded, player slides downhill, not glued.
136
-
137
- **Risk:** low. Self-contained; resolver fallback unchanged for existing
138
- tests.
139
-
140
- ### Phase 2 Crease / corner handling
141
- *Review gap #3. Cheap correctness win in the existing loop.*
142
-
143
- - Track planes hit within one `_moveAndSlide` call. On a second plane
144
- that re-violates the first, project the residual onto the seam
145
- `normalize(n₁ × n₂)`. On a third plane in one frame, zero the residual
146
- (and the into-wall velocity) — Quake's dead-stop.
147
-
148
- **Guard tests** (extend `MoveAndSlide.spec.js`): oblique (non-axis)
149
- inside corner stops cleanly with no residual leak and no tick-to-tick
150
- chatter; narrow wedge dead-stops instead of squirting through.
151
-
152
- **Risk:** low–medium. Pure addition to the slide loop; existing
153
- single-plane tests must stay green.
154
-
155
- ### Phase 3 — Unify vertical into the swept solve
156
- *Review gap #2 (anti-tunnel). The structural change; do it after 1 & 2
157
- so slope + crease are already trustworthy.*
158
-
159
- - Fold gravity-driven vertical into the swept `delta`; remove the direct
160
- `position.y += vy·dt` add.
161
- - Replace the strict-inequality resolver snap with
162
- `categorizeGround()` + `stayOnGround()` (§4).
163
- - Route Slide / WallRun vertical through the same mover (TODO's "ability
164
- motion routing" they stop hand-integrating gravity).
165
-
166
- **Guard tests** (new `VerticalSweep.spec.js`): drop from height onto a
167
- thin physics slab no tunnelling, lands on top; jump straight into a
168
- ceiling stops, doesn't pass through; **the stationary-on-floor case
169
- stays at y≈0 across 120 ticks** (generalise the earlier no-bounce repro,
170
- now without an escape pass to mask it).
171
-
172
- **Risk:** medium–high. Changes the contract `_integrate…` exposes to
173
- abilities; they're updated in the same phase. This is the phase most
174
- likely to surface a physics gap (see §6).
175
-
176
- ### Phase 4 — Depenetration safety net
177
- *Review gap #4. Minimal, not the rolled-back ring-buffer escape.*
178
-
179
- - One bounded nudge: if the capsule starts a tick overlapping (detect
180
- via `shapeCast` `t=0`, or `overlap()`), push out along the contact
181
- normal by `depth + SKIN`, capped at ~4 iterations. No ring buffer, no
182
- teleport, no rescue signal just don't stay solid.
183
- - **Blocked on §6 requirement #1** for a clean depth. Without it, the
184
- fallback is a fixed-step nudge along the normal (no exact depth),
185
- which is cruder but unblocks the phase.
186
-
187
- **Guard tests** (new `Depenetration.spec.js`): spawn the player
188
- straddling a box surface within a few ticks they're outside; a body
189
- that spawns on top of a standing player → player ends up clear; deep
190
- burial terminates (no infinite loop), doesn't teleport.
191
-
192
- **Risk:** low if requirement #1 lands; medium otherwise (cruder nudge
193
- needs more tuning).
194
-
195
- ### Phase 5 Real step-up *(optional / deferrable)*
196
- *Review gap #5.*
197
-
198
- - Source-style `raise move → trace-down` for stairs above the 5 cm
199
- implicit step, OR keep stairs as the Mantle ability's job and document
200
- the curb ceiling. Likely **defer** — Mantle already covers ledges; a
201
- decision, not necessarily code.
161
+ ## 5. Phased plan (rebuild, not patch)
162
+
163
+ ### Phase 1 — `KinematicMover`: recover + unified sweep-and-slide
164
+ Stand up the module. Implement **recover** (overlap +
165
+ compute_penetration) and **unified 3D sweep-and-slide** (crease-aware).
166
+ The system calls `move()` for the combined velocity·dt, replacing both
167
+ `_moveAndSlide` and the direct vertical add. Grounding stays on the old
168
+ resolver as a short-lived interim shim (removed in Phase 2) so this
169
+ phase isolates "does the new mover move correctly."
170
+
171
+ **Guard tests** (`KinematicMover.spec.js`, against a real PhysicsSystem):
172
+ spawn straddling a box recovered outside within a tick; wall stop;
173
+ slide-along axis-aligned + oblique wall; **vertical** anti-tunnel (drop
174
+ through a thin slab → lands on top); jump into ceiling stops; corner
175
+ crease clean stop, no leak/chatter. Port the existing
176
+ `MoveAndSlide.spec.js` scenarios through the new path.
177
+
178
+ **Risk:** medium. New module, but isolated; old grounding still in place.
179
+
180
+ ### Phase 2 — Ground categorize + slope + stick (inside the mover)
181
+ Add steps 4 (categorize/stick) to the mover. Down-`shapeCast` →
182
+ `{grounded, normal}`; `MIN_WALK_NORMAL` gate; snap + kill into-ground
183
+ velocity; reproject grounded velocity onto the slope. **Remove the
184
+ interim grounding shim and the scalar resolver authority** (resolver
185
+ demoted to no-physics fallback *inside* the probe, behind one function
186
+ uniform flow). Feed `grounded`/`groundNormal` back to control.
187
+
188
+ **Guard tests** (`GroundSlope.spec.js`): walk up a 30° ramp keeps speed,
189
+ stays grounded, correct normal; stationary-on-floor stays at y≈0 across
190
+ 120 ticks (the no-bounce repro, now with nothing masking it); 60° face
191
+ not grounded, slides down, not glued.
192
+
193
+ **Risk:** medium. Changes grounding semantics; the bounce reconciliation
194
+ lives here.
195
+
196
+ ### Phase 3 — Stairs ✅ **landed**
197
+ Real stair climbing, gated by `stepHeight` (default 0.3 m, just under
198
+ the capsule radius).
199
+
200
+ The implementation diverged from the planned explicit "up-forward-down"
201
+ step sweep — that was tried and removed. The capsule's *round bottom*
202
+ already rolls up a riser shorter than its radius (the slide moves the
203
+ player up positionally), and an explicit step-up fought it (incremental
204
+ wall-climbing, edge-wedging). What was actually needed was **honest
205
+ ground categorization on a step edge**, which two probes split cleanly:
206
+
207
+ - **Step-DOWN** = the ground-stick reach is `stepHeight`: walking off
208
+ a drop stepHeight snaps onto the lower surface (stays grounded);
209
+ a larger drop goes airborne.
210
+ - **Step-UP** = categorize takes **walkability from a centre raycast**
211
+ (sees the real planar surface; ignores a step's convex top edge and
212
+ a wall's side face — so a steep *slope* is correctly not-grounded)
213
+ and **rest height from a footprint shapecast** (raises onto a step
214
+ the leading edge overhangs, capped at `stepHeight`). A single probe
215
+ can't tell a climbable step edge from a steep slope both are
216
+ steep-normal contacts — which is why the two are split.
217
+
218
+ This is also what fixed the "launch off every low step" jank: with the
219
+ player correctly grounded through the roll, the per-tick vertical
220
+ velocity is zeroed and never accumulates.
221
+
222
+ **Guard tests** (`collision/Stairs.spec.js`, all green): climb a 5-step
223
+ staircase staying grounded; clear a single curb without stalling; a
224
+ 0.5 m riser (> stepHeight) blocks; descend a staircase grounded the
225
+ whole way (no launch off each lip).
226
+
227
+ ### Phase 4 — Motor seam + delete old code ✅ **landed**
228
+ The mover is now the **only** collision path when a `PhysicsSystem` is
229
+ present the opt-in flag is gone; dispatch is on physics presence, not
230
+ a flag.
231
+
232
+ What shipped:
233
+ - `_integrateVerticalAndResolveGround` is now a thin dispatcher:
234
+ `_applyGravity` (the motor) → `_moveViaMover` (physics) **or**
235
+ `_moveFlatGround` (no physics) → `_detectJumpApex`.
236
+ - **Gravity** extracted to `_applyGravity`; **land / leave-ground**
237
+ extracted to `_onLand` / `_onLeaveGround`, shared by both move paths
238
+ (the duplicated scaffolding from Phase 1's wiring is gone).
239
+ - **Deleted** `_moveAndSlide` (+ its `CAST_STEP_HEIGHT` / `SKIN`
240
+ constants and the `slideRay` / `slideHit` scratch), the legacy ground-
241
+ resolution body, and the `useKinematicMover` flag.
242
+ - `MoveAndSlide.spec.js` deleted — it tested `_moveAndSlide`; its
243
+ scenarios (wall-stop, axis + oblique slide, anti-tunnel) are covered
244
+ by `collision/KinematicMover.spec.js`.
245
+
246
+ What stayed (deliberately):
247
+ - `useBuiltInFlatGround` / `groundY` / `groundResolver` remain — they're
248
+ the **no-physics** fallback (`_moveFlatGround`) for headless and
249
+ control-layer unit tests, and the resolver is still spec-covered. With
250
+ physics present they're unused (the mover probes the world).
251
+ - **WallRun still self-integrates** its reduced-gravity model rather than
252
+ routing through `move()`. Slide already routes through the mover (it
253
+ calls `_integrateVerticalAndResolveGround`); WallRun's custom model is
254
+ left as a future enhancement, not required for the legacy deletion.
255
+
256
+ **Result:** 29 suites / 236 tests green; the entire ability + jump +
257
+ momentum + posture suite passes through the consolidated path.
202
258
 
203
259
  ---
204
260
 
205
- ## 6. Unsatisfied physics requirements
206
-
207
- Things I'd want from the physics engine to build §5 cleanly. None block
208
- Phases 1–3; #1 shapes Phase 4.
209
-
210
- 1. **Public penetration/contact query `PhysicsSystem.computePenetration`
211
- (or `contact`).** `compute_penetration(out_dir, shapeA, posA, rotA,
212
- shapeB, posB, rotB) depth` already exists and is exactly right, but
213
- it's an **internal narrowphase utility, not exposed** on
214
- `PhysicsSystem`. Phase 4 depenetration wants `{normal, depth}` for the
215
- player capsule vs each overlapping body. Today I can get the *fact* of
216
- overlap (`overlap()` ids) and a *normal* (`shapeCast` `t=0`), but
217
- **not the depth** `shapeCast` discards it at `t=0`. Wiring this
218
- through is small (the function is written and tested) and is the
219
- single highest-value API add for the controller. **Priority: HIGH.**
220
-
221
- 2. **`overlap()` that can optionally emit per-body `{normal, depth}`,
222
- not just ids.** Subsumed by #1 if I call `computePenetration` per
223
- returned id acceptable. Only worth doing natively if profiling
224
- shows the per-body re-query matters. **Priority: LOW** (covered by #1).
225
-
226
- 3. **Contact *point* (not just swept-centre) from `shapeCast`.** Not
227
- needed for this plan, but any future foot-placement / ledge-edge or
228
- multi-point ground balancing wants the world contact point.
229
- `result.position` is the swept centre today. **Priority: LOW.**
230
-
231
- 4. **Multi-contact ground manifold from one query.** `shapeCast` returns
232
- the single closest-`t` contact. A capsule straddling the seam between
233
- two boxes gets one normal, not both — fine for v1 grounded snap, a
234
- limitation for precise edge-balancing. **Priority: LOW.**
235
-
236
- If only one thing gets built: **#1**. It turns Phase 4 from "tune a
237
- blind nudge" into "push out by the exact reported depth," and it's
238
- mostly plumbing over code that already exists and is tested.
261
+ ## 6. Physics requirements — status
262
+
263
+ **Resolved.** My one HIGH ask from the review a public penetration
264
+ query is **satisfied**: `compute_penetration` is public and is exactly
265
+ the right shape (unit B→A direction + scalar depth, capsule-vs-anything).
266
+ It graduates from "deferred safety net" to the **core recover/settle
267
+ primitive** the whole mover leans on (steps 1 & 5). Confirmed it works:
268
+ yes, this is precisely what's needed thank you for keeping it decoupled
269
+ and public.
270
+
271
+ **No remaining blocking requirements.** Phases 1–4 need nothing further
272
+ from the physics engine. The two low-priority niceties from the review
273
+ turned out not to bite:
274
+ - *Contact point from `shapeCast`* not needed; ground-surface Y is
275
+ derivable from the cast `t` + the capsule bottom offset.
276
+ - *Multi-contact manifold from one query* — covered for resting contact
277
+ by iterating `compute_penetration` over **every** `overlap()` body in
278
+ the recover pass; the single-sweep corner case is handled by the slide
279
+ iteration instead. So no native multi-contact query is required.
280
+
281
+ If anything *would* be a future nicety (not needed now): a swept query
282
+ returning the *set* of TOI-simultaneous contacts (for one-pass corner
283
+ creases instead of iterating). Strictly an optimisation; the iterative
284
+ slide is correct without it.
239
285
 
240
286
  ---
241
287
 
242
288
  ## 7. Constants (ours ← reference lineage)
243
289
 
244
- | Constant | Plan value | Reference | Note |
290
+ | Constant | Plan value | Reference | Phase |
245
291
  |---|---|---|---|
246
- | slide iterations | 4 | Quake `numbumps` 4 | unchanged |
247
- | `SKIN` | 0.005 m | Fauerby `veryCloseDistance` ~0.005 | unchanged |
248
- | `MIN_WALK_NORMAL` | ~0.7 (≈45°) | Quake3 `MIN_WALK_NORMAL` 0.7 / Source `normal.z ≥ 0.7` | **new, Phase 1** |
249
- | ground-probe band | ~0.06 m | Source `StayOnGround` ~2 u down-snap | **new, Phase 3** |
250
- | crease dead-stop | zero on 3rd plane | Quake `SV_FlyMove` | **new, Phase 2** |
251
- | step height | 0.05 m (implicit) | Source `sv_stepsize` 18 u (~0.34 m) | Phase 5 decision |
252
-
253
- Overbounce: we keep the clip at effective `1.0` gated on into-wall
254
- (`v·n < 0`), relying on `SKIN` for clearance, rather than Quake3's
255
- `OVERCLIP 1.001` nudge. No change planned — equivalent in practice.
292
+ | slide iterations | 4 | Quake `numbumps` 4 | 1 |
293
+ | `SKIN` | 0.005 m | Fauerby `veryCloseDistance` ~0.005 | 1 |
294
+ | recover max iters | ~4 | (convergence cap) | 1 |
295
+ | `MIN_WALK_NORMAL` | ~0.7 (≈45°) | Quake3 0.7 / Source `normal.z 0.7` | 2 |
296
+ | ground-probe band | ~0.06 m | Source `StayOnGround` ~2 u | 2 |
297
+ | crease dead-stop | zero on 3rd plane | Quake `SV_FlyMove` | 1 |
298
+ | `stepHeight` | ~0.3 m (tunable) | Source `sv_stepsize` 18 u (~0.34 m) | 3 |
299
+
300
+ Clip stays at effective overbounce `1.0` gated on into-wall (`v·n < 0`),
301
+ relying on `SKIN` for clearance — equivalent in practice to Quake3's
302
+ `OVERCLIP 1.001` nudge; no change planned.