@woosh/meep-engine 2.145.0 → 2.146.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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts +33 -3
  3. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -1
  4. package/src/core/geom/3d/shape/HeightMapShape3D.js +486 -451
  5. package/src/engine/control/first-person/DESIGN_COLLISION.md +365 -352
  6. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts +1 -3
  7. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts.map +1 -1
  8. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts +12 -2
  9. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts.map +1 -1
  10. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.js +7 -2
  11. package/src/engine/control/first-person/TODO.md +13 -11
  12. package/src/engine/control/first-person/abilities/WallJump.d.ts.map +1 -1
  13. package/src/engine/control/first-person/abilities/WallJump.js +11 -3
  14. package/src/engine/control/first-person/abilities/WallRun.d.ts.map +1 -1
  15. package/src/engine/control/first-person/abilities/WallRun.js +12 -0
  16. package/src/engine/control/first-person/collision/KinematicMover.d.ts.map +1 -1
  17. package/src/engine/control/first-person/collision/KinematicMover.js +634 -592
  18. package/src/engine/control/first-person/prototype_first_person_controller.js +1003 -901
  19. package/src/engine/physics/PLAN.md +943 -809
  20. package/src/engine/physics/body/BodyStorage.d.ts +9 -0
  21. package/src/engine/physics/body/BodyStorage.d.ts.map +1 -1
  22. package/src/engine/physics/body/BodyStorage.js +23 -0
  23. package/src/engine/physics/broadphase/generate_pairs.d.ts.map +1 -1
  24. package/src/engine/physics/broadphase/generate_pairs.js +7 -0
  25. package/src/engine/physics/ccd/linear_sweep.d.ts +97 -0
  26. package/src/engine/physics/ccd/linear_sweep.d.ts.map +1 -0
  27. package/src/engine/physics/ccd/linear_sweep.js +238 -0
  28. package/src/engine/physics/ecs/PhysicsSystem.d.ts +18 -3
  29. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  30. package/src/engine/physics/ecs/PhysicsSystem.js +59 -8
  31. package/src/engine/physics/ecs/RigidBodyFlags.d.ts +6 -0
  32. package/src/engine/physics/ecs/RigidBodyFlags.d.ts.map +1 -1
  33. package/src/engine/physics/ecs/RigidBodyFlags.js +6 -0
  34. package/src/engine/physics/narrowphase/box_triangle_contact.js +811 -811
  35. package/src/engine/physics/narrowphase/compute_penetration.d.ts.map +1 -1
  36. package/src/engine/physics/narrowphase/compute_penetration.js +325 -323
  37. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts +27 -8
  38. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts.map +1 -1
  39. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.js +235 -204
  40. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  41. package/src/engine/physics/narrowphase/narrowphase_step.js +70 -13
  42. package/src/engine/physics/queries/overlap_shape.d.ts.map +1 -1
  43. package/src/engine/physics/queries/overlap_shape.js +185 -183
  44. package/src/engine/simulation/Ticker.d.ts +14 -0
  45. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  46. package/src/engine/simulation/Ticker.js +136 -1
@@ -1,592 +1,634 @@
1
- import Vector3 from "../../../../core/geom/Vector3.js";
2
- import { Ray3 } from "../../../../core/geom/3d/ray/Ray3.js";
3
- import { Transform } from "../../../ecs/transform/Transform.js";
4
- import { Collider } from "../../../physics/ecs/Collider.js";
5
- import { compute_penetration } from "../../../physics/narrowphase/compute_penetration.js";
6
- import { PhysicsSurfacePoint } from "../../../physics/queries/PhysicsSurfacePoint.js";
7
-
8
- /**
9
- * Maximum simultaneous clip planes tracked within a single slide,
10
- * matching Quake's `MAX_CLIP_PLANES`. A move that contacts more than
11
- * this many distinct planes in one tick is in pathological geometry
12
- * (a cone of inward-pointing walls); we dead-stop rather than thrash.
13
- * @type {number}
14
- */
15
- const MAX_CLIP_PLANES = 5;
16
-
17
- /** Below this speed/length we treat the remaining motion as spent. */
18
- const MIN_MOVE = 1e-6;
19
-
20
- /**
21
- * Kinematic character collision solver — Phase 1 (recover +
22
- * unified sweep-and-slide). See DESIGN_COLLISION.md.
23
- *
24
- * The mover is controller-agnostic: it knows about a capsule pose, a
25
- * desired velocity, and the physics world. Its job is to take the
26
- * velocity the control layer wants and return the position it actually
27
- * reaches plus the velocity corrected for whatever it hit. It invents
28
- * no motion — gravity / jump impulses live in the control layer and
29
- * arrive folded into `velocity`.
30
- *
31
- * One move is a sequence (Phase 1 implements steps 1-2; ground
32
- * categorize / stairs / settle land in later phases):
33
- *
34
- * 1. RECOVER — depenetration via `overlap()` + `compute_penetration`,
35
- * run unconditionally so the move starts clear even from a
36
- * start-solid state a pure sweep can't escape.
37
- * 2. SLIDE — unified 3D collide-and-slide via `shapeCast`, clipping
38
- * velocity onto true narrowphase contact normals. Crease-aware
39
- * (Quake `SV_FlyMove`): a second plane re-violating the first is
40
- * handled by sliding along their seam; a third (or a velocity
41
- * reversal) dead-stops. Floors / ceilings / walls are all just
42
- * contact planes, so vertical anti-tunnelling is automatic.
43
- *
44
- * @author Alex Goldring
45
- * @copyright Company Named Limited (c) 2026
46
- */
47
- export class KinematicMover {
48
- /**
49
- * @param {import("../../../physics/ecs/PhysicsSystem.js").PhysicsSystem} physicsSystem
50
- * @param {import("../../../ecs/EntityComponentDataset.js").EntityComponentDataset} ecd
51
- * used to resolve an overlapping `body_id` (via
52
- * `physicsSystem.entityOf`) back to its {@link Transform} +
53
- * {@link Collider} for `compute_penetration`.
54
- * @param {object} [options]
55
- * @param {number} [options.skin=0.005] clearance left after each
56
- * sweep stop / push-out so the next query doesn't start in contact.
57
- * @param {number} [options.maxSlideIterations=4] slide "bumps" per
58
- * move (Quake `numbumps`).
59
- * @param {number} [options.maxRecoverIterations=4] depenetration
60
- * passes before giving up (deepest body pushed out per pass).
61
- * @param {number} [options.minWalkNormal=0.7] minimum ground-normal Y
62
- * to count as standable (~45.6°). Below it the surface is "too
63
- * steep" — the player slides instead of grounding. Matches Quake3
64
- * `MIN_WALK_NORMAL` / Source `normal.z ≥ 0.7`.
65
- * @param {number} [options.stepHeight=0.3] maximum step the player
66
- * traverses, both up and down:
67
- * - UP: a riser no taller than this is climbed — by the explicit
68
- * step-up ({@link _tryStepUp}, up-forward-down, which works
69
- * regardless of forward momentum) and by `_categorizeGround`
70
- * mounting the player onto a surface within `stepHeight` of the
71
- * feet. A taller riser blocks (the slide stops the player; the
72
- * step-up's forward clearance cast detects the wall and aborts).
73
- * - DOWN: the ground-stick reach — walking off a drop no larger
74
- * than this snaps the player onto the lower surface (stays
75
- * grounded); a larger drop goes airborne (a real ledge).
76
- * Source `sv_stepsize` is ~0.34 m; the default is just under the
77
- * capsule radius so low ledges don't feel "magnetic".
78
- */
79
- constructor(physicsSystem, ecd, options = {}) {
80
- this.physicsSystem = physicsSystem;
81
- this.ecd = ecd;
82
- this.skin = options.skin !== undefined ? options.skin : 0.005;
83
- this.maxSlideIterations = options.maxSlideIterations !== undefined ? options.maxSlideIterations : 4;
84
- this.maxRecoverIterations = options.maxRecoverIterations !== undefined ? options.maxRecoverIterations : 4;
85
- this.minWalkNormal = options.minWalkNormal !== undefined ? options.minWalkNormal : 0.7;
86
- this.stepHeight = options.stepHeight !== undefined ? options.stepHeight : 0.3;
87
-
88
- // ── Scratch — reused per move, no per-call allocation ──────────
89
- this._ray = new Ray3();
90
- this._hit = new PhysicsSurfacePoint();
91
- this._overlapBuf = new Uint32Array(16);
92
- this._penDir = new Float64Array(3); // compute_penetration out
93
- this._planes = new Float64Array(MAX_CLIP_PLANES * 3);
94
- this._cand = new Float64Array(3); // clipped-velocity candidate
95
- this._support = new Float64Array(3); // shape support point (forward extent)
96
-
97
- /**
98
- * Reused result. `grounded` / `groundNormal` are Phase 2 outputs;
99
- * in Phase 1 they're left at their defaults (the mover doesn't
100
- * categorize ground yet).
101
- * @type {{hit:boolean, grounded:boolean, groundNormal:Vector3}}
102
- */
103
- this._result = { hit: false, grounded: false, groundNormal: new Vector3(0, 1, 0) };
104
- }
105
-
106
- /**
107
- * Resolve one move. Mutates `position` to the resolved location and
108
- * `velocity` to the corrected value.
109
- *
110
- * @param {Vector3} position in/out — current pose, written to the resolved pose
111
- * @param {{x:number,y:number,z:number,w:number}} rotation capsule orientation (read)
112
- * @param {import("../../../../core/geom/3d/shape/AbstractShape3D.js").AbstractShape3D} shape convex capsule
113
- * @param {Vector3} velocity in/out — desired velocity, written to the corrected velocity
114
- * @param {number} dt
115
- * @param {(entity:number, collider:Collider)=>boolean} filter excludes
116
- * the player's own body in integration; accept-all in isolation
117
- * @returns {{hit:boolean, grounded:boolean, groundNormal:Vector3}} reused result
118
- */
119
- move(position, rotation, shape, velocity, dt, filter) {
120
- const result = this._result;
121
- result.hit = false;
122
- result.grounded = false;
123
- result.groundNormal.set(0, 1, 0);
124
-
125
- // Capture intent-to-leave BEFORE the slide. A positive input
126
- // vertical velocity is a jump / launch (the only thing that sets
127
- // one — gravity is negative); on those ticks we must NOT
128
- // stick-to-ground or the snap cancels the jump on its first
129
- // frame. A ramp's upward velocity is produced INSIDE the slide
130
- // (the ramp is a contact plane), so it doesn't show up here —
131
- // which is exactly why we gate on the pre-slide value.
132
- const ascending = velocity.y > MIN_MOVE;
133
-
134
- // 1. Recover — start the move penetration-free.
135
- this._recover(position, rotation, shape, filter);
136
-
137
- // Snapshot pre-slide state for a possible stair step-up retry.
138
- const sx = position.x, sy = position.y, sz = position.z;
139
- const svx = velocity.x, svy = velocity.y, svz = velocity.z;
140
-
141
- // 2. Sweep-and-slide the desired motion.
142
- this._slide(position, rotation, shape, velocity, dt, filter, result);
143
-
144
- // 2b. Stairs if a grounded move was blocked, try to step up and
145
- // over a low riser. The capsule's round bottom also rolls up
146
- // low steps via the slide, but only with enough forward
147
- // momentum; a controller that reads back the slide-clipped
148
- // velocity loses that momentum at the riser and gets stuck.
149
- // The explicit step-up climbs without depending on momentum
150
- // AND restores the horizontal velocity, so the player keeps
151
- // moving up the flight.
152
- if (this.stepHeight > 0 && !ascending && result.hit) {
153
- this._tryStepUp(position, rotation, shape, velocity,
154
- sx, sy, sz, svx, svy, svz, dt, filter);
155
- }
156
-
157
- // 3. Ground categorize + stick + slope velocity clip.
158
- this._categorizeGround(position, rotation, shape, velocity, filter, result, ascending);
159
-
160
- return result;
161
- }
162
-
163
- /**
164
- * Stair step-up. Called after a slide that hit something while the
165
- * player was trying to move horizontally on the ground. Re-runs the
166
- * horizontal move from the pre-slide position lifted by `stepHeight`,
167
- * guarded by a forward CLEARANCE cast: if the path is still blocked at
168
- * the lifted height the obstacle is taller than a step (a wall) and we
169
- * abandon the attempt; if clear it's a step, so advance over it and
170
- * drop. Commits only when it advances farther horizontally than the
171
- * plain slide and the rise is within `stepHeight`. On success the
172
- * horizontal velocity is restored (the riser wasn't a wall) so the
173
- * controller doesn't read back a stalled speed; the vertical is left
174
- * for `_categorizeGround` to settle on the step.
175
- *
176
- * @private
177
- */
178
- _tryStepUp(position, rotation, shape, velocity, sx, sy, sz, svx, svy, svz, dt, filter) {
179
- const hdx = svx * dt, hdz = svz * dt;
180
- const forwardLen = Math.sqrt(hdx * hdx + hdz * hdz);
181
- if (forwardLen < MIN_MOVE) return; // no horizontal intent
182
- // Only step from a grounded start never mid-air (else a player
183
- // could climb a wall by jumping into it).
184
- if (!this._groundedAt(sx, sy, sz, filter)) return;
185
-
186
- const slideProg = (position.x - sx) * (position.x - sx) + (position.z - sz) * (position.z - sz);
187
- const px = position.x, py = position.y, pz = position.z;
188
- const pvx = velocity.x, pvy = velocity.y, pvz = velocity.z;
189
- const fnx = hdx / forwardLen, fnz = hdz / forwardLen; // travel direction
190
-
191
- // Direction the slide was BLOCKED in — the inward normal of the
192
- // obstacle the player walked into = the horizontal velocity the
193
- // slide removed (pre-slide minus clipped). For a head-on approach
194
- // this equals the travel direction; for an OBLIQUE one it's the
195
- // wall's normal, which is what we must probe along: the capsule
196
- // meets the wall with its normal-direction extent, so a probe cast
197
- // along TRAVEL would stop short of the face (it only has to reach
198
- // radius/​cos θ along travel, but radius along the normal) and read a
199
- // wall as clear, climbing it. Deriving it from the velocity change
200
- // needs no slide-internal normal plumbed out.
201
- let bdx = svx - velocity.x, bdz = svz - velocity.z;
202
- const bdLen = Math.sqrt(bdx * bdx + bdz * bdz);
203
- if (bdLen < MIN_MOVE) return; // nothing opposed the horizontal move no wall/step ahead
204
- bdx /= bdLen; bdz /= bdLen;
205
-
206
- // ── Step-vs-wall decision: a THIN horizontal ray at the step-height
207
- // plane ──────────────────────────────────────────────────────
208
- // The obvious "lift the capsule by stepHeight and sweep it forward"
209
- // clearance test is fooled by the capsule's ROUNDED bottom: lifted
210
- // so its tip sits at stepHeight, the hemisphere narrows through the
211
- // band (stepHeight, stepHeight+radius), so a wall whose top lands in
212
- // that band is never reached by the swept capsule — it reads "clear"
213
- // and the player climbs a too-tall wall (and does so speed-
214
- // dependently, since a faster sweep reaches further into the round-
215
- // off). A thin ray has no such round-off. Cast it INTO the obstacle
216
- // (along the blocked normal) at exactly the highest climbable height:
217
- // anything taller than a step has solid material crossing that plane
218
- // and is struck on its front face; a genuine step (top below the
219
- // plane) is passed clean over. This reads the obstacle's true height,
220
- // independent of how the round bottom would perch on its edge so
221
- // "clear it or don't" holds at any speed and any approach angle.
222
- //
223
- // Origin at the PRE-slide centre, not post-slide: rounding a convex
224
- // corner, the slide can carry the centre just past the corner (out
225
- // of the obstacle's footprint) so a probe from there shoots past it
226
- // and reads clear, climbing the corner. The pre-slide centre was
227
- // still in front of what blocked it. Reach spans the swept move
228
- // (`forwardLen`) plus a forward-extent to the leading edge plus a
229
- // few skins enough to cross the front face wherever along the
230
- // sweep contact happened. The reach tracking the sweep is not the
231
- // banned speed coupling: the climb-or-block DECISION is the
232
- // step-height plane alone; the reach only governs how far ahead we
233
- // look for the thing the slide already hit.
234
- shape.support(this._support, 0, bdx, 0, bdz);
235
- const lead = this._support[0] * bdx + this._support[2] * bdz; // extent toward the wall (capsule radius)
236
- const ray = this._ray;
237
- ray.setOrigin(sx, sy + this.stepHeight + this.skin, sz);
238
- ray.setDirection(bdx, 0, bdz);
239
- ray.tMax = forwardLen + lead + 4 * this.skin;
240
- if (this.physicsSystem.raycast(ray, this._hit, filter)) return; // taller than a step — a wall; keep the plain slide
241
-
242
- // Confirmed climbable (nothing crosses the step-height plane ahead).
243
- // Up–forward–down places the capsule onto the step top.
244
- position.set(sx, sy, sz);
245
- const up = this._castDistance(position, rotation, shape, 0, 1, 0, this.stepHeight, filter);
246
- position._add(0, up, 0);
247
- position._add(fnx * forwardLen, 0, fnz * forwardLen);
248
- const down = this._castDistance(position, rotation, shape, 0, -1, 0, up + this.skin, filter);
249
- position._add(0, -down, 0);
250
-
251
- // Reject a climb that ends INSIDE geometry. The thin step-height
252
- // probe is a single ray, so at a convex CORNER it can thread past
253
- // the corner point (the contact normal there is near-parallel to a
254
- // face, so the ray runs alongside the box just outside its
255
- // footprint) and read clear — then up-forward-down perches the round
256
- // body on that corner, overlapping it. A genuine step top is rested
257
- // on `skin` ABOVE, never overlapping; so an overlap at the
258
- // destination is the tell that we climbed onto a wall, not a step.
259
- const stepProg = (position.x - sx) * (position.x - sx) + (position.z - sz) * (position.z - sz);
260
- const heightGain = position.y - sy;
261
- const overlaps = this.physicsSystem.overlap(shape, position, rotation, this._overlapBuf, 0, filter) > 0;
262
- if (stepProg > slideProg + 1e-8 && heightGain <= this.stepHeight + this.skin && !overlaps) {
263
- velocity.set(svx, svy, svz); // restore horizontal; vertical settled by categorize
264
- } else {
265
- position.set(px, py, pz);
266
- velocity.set(pvx, pvy, pvz);
267
- }
268
- }
269
-
270
- /**
271
- * Distance the shape can sweep along a unit axis before contact (less
272
- * `skin`), or the full `maxDist` if clear. For the step-up lift/drop.
273
- * @private
274
- */
275
- _castDistance(position, rotation, shape, dx, dy, dz, maxDist, filter) {
276
- const ray = this._ray;
277
- ray.setOrigin(position.x, position.y, position.z);
278
- ray.setDirection(dx, dy, dz);
279
- ray.tMax = maxDist;
280
- if (!this.physicsSystem.shapeCast(ray, shape, rotation, this._hit, filter)) return maxDist;
281
- const t = this._hit.t - this.skin;
282
- return t > 0 ? t : 0;
283
- }
284
-
285
- /**
286
- * True when a centre raycast at (x,y,z) finds a walkable surface
287
- * within stick range — gates step-up on "actually standing".
288
- * @private
289
- */
290
- _groundedAt(x, y, z, filter) {
291
- const ray = this._ray;
292
- const lift = this.stepHeight;
293
- ray.setOrigin(x, y + lift, z);
294
- ray.setDirection(0, -1, 0);
295
- ray.tMax = lift + this.stepHeight + this.skin;
296
- if (!this.physicsSystem.raycast(ray, this._hit, filter)) return false;
297
- if (this._hit.normal.y < this.minWalkNormal) return false;
298
- return (y + lift - this._hit.t) <= y + this.skin;
299
- }
300
-
301
- /**
302
- * Push the capsule out of any geometry it currently overlaps. Each
303
- * pass queries overlaps, finds the single deepest penetration via
304
- * {@link compute_penetration}, and pushes out along its separation
305
- * axis by `depth + skin`; re-queries until clear or the iteration
306
- * cap is hit. Deepest-first converges multi-body resting contact
307
- * (each pass removes the worst offender) without solving a system.
308
- *
309
- * @private
310
- */
311
- _recover(position, rotation, shape, filter) {
312
- const physics = this.physicsSystem;
313
- const ecd = this.ecd;
314
- const buf = this._overlapBuf;
315
- const dir = this._penDir;
316
-
317
- for (let iter = 0; iter < this.maxRecoverIterations; iter++) {
318
- const n = physics.overlap(shape, position, rotation, buf, 0, filter);
319
- if (n === 0) return;
320
-
321
- let bestDepth = 0;
322
- let bestX = 0, bestY = 0, bestZ = 0;
323
-
324
- for (let i = 0; i < n; i++) {
325
- const bodyId = buf[i];
326
- const entity = physics.entityOf(bodyId);
327
- if (entity < 0) continue;
328
- const otherT = ecd.getComponent(entity, Transform);
329
- const otherC = ecd.getComponent(entity, Collider);
330
- if (otherT === undefined || otherC === undefined) continue;
331
-
332
- // depth + unit B→A separation (A = our capsule, B = other).
333
- const depth = compute_penetration(
334
- dir,
335
- shape, position, rotation,
336
- otherC.shape, otherT.position, otherT.rotation,
337
- );
338
- if (depth > bestDepth) {
339
- bestDepth = depth;
340
- bestX = dir[0]; bestY = dir[1]; bestZ = dir[2];
341
- }
342
- }
343
-
344
- // Overlap reported but no actionable depth (touching boundary,
345
- // or MPR/overlap disagreement at the kiss) — treat as clear.
346
- if (bestDepth <= 0) return;
347
-
348
- const push = bestDepth + this.skin;
349
- position._add(bestX * push, bestY * push, bestZ * push);
350
- }
351
- }
352
-
353
- /**
354
- * Unified 3D collide-and-slide. Faithful to Quake's `SV_FlyMove`:
355
- * sweep the velocity for the remaining time, stop at contact, clip
356
- * the (original) velocity against every plane hit so far, slide
357
- * along the seam of a two-plane crease, dead-stop on a third plane
358
- * or a velocity reversal.
359
- *
360
- * @private
361
- */
362
- _slide(position, rotation, shape, velocity, dt, filter, result) {
363
- const physics = this.physicsSystem;
364
- const ray = this._ray;
365
- const hit = this._hit;
366
- const planes = this._planes;
367
- const cand = this._cand;
368
- const skin = this.skin;
369
-
370
- // Original desired velocity — clipping is always done against
371
- // this, not the running value (Quake's invariant; prevents
372
- // accumulated rounding from spiralling the direction).
373
- const ovx = velocity.x, ovy = velocity.y, ovz = velocity.z;
374
- let vx = ovx, vy = ovy, vz = ovz;
375
- let timeLeft = dt;
376
- let numPlanes = 0;
377
-
378
- for (let bump = 0; bump < this.maxSlideIterations; bump++) {
379
- const speed = Math.sqrt(vx * vx + vy * vy + vz * vz);
380
- if (speed < MIN_MOVE) break;
381
- const len = speed * timeLeft;
382
- if (len < MIN_MOVE) break;
383
-
384
- const inv = 1 / speed;
385
- const ndx = vx * inv, ndy = vy * inv, ndz = vz * inv;
386
-
387
- ray.setOrigin(position.x, position.y, position.z);
388
- ray.setDirection(ndx, ndy, ndz);
389
- ray.tMax = len;
390
-
391
- const didHit = physics.shapeCast(ray, shape, rotation, hit, filter);
392
-
393
- if (!didHit) {
394
- // Clear path — consume the whole remaining move.
395
- position._add(ndx * len, ndy * len, ndz * len);
396
- break;
397
- }
398
-
399
- // Separating contact: `shapeCast` reports a start-in-contact
400
- // hit (t≈0) even when we're moving AWAY from the touched
401
- // surface e.g. jumping off a floor we're resting on, where
402
- // the down-normal floor is behind the upward motion. If the
403
- // sweep direction points along the outward contact normal
404
- // (dir·n 0) the surface isn't in our way; take the full
405
- // remaining move rather than clipping the velocity into it
406
- // (which would cancel the jump). A genuine blocker is only
407
- // reachable by moving INTO it (dir·n < 0).
408
- const dirDotN = ndx * hit.normal.x + ndy * hit.normal.y + ndz * hit.normal.z;
409
- if (dirDotN >= 0) {
410
- position._add(ndx * len, ndy * len, ndz * len);
411
- break;
412
- }
413
-
414
- result.hit = true;
415
-
416
- // Advance up to the contact (less skin), and consume the
417
- // corresponding fraction of the remaining time.
418
- const advance = hit.t - skin > 0 ? hit.t - skin : 0;
419
- if (advance > 0) position._add(ndx * advance, ndy * advance, ndz * advance);
420
- const fraction = hit.t / len; // time uses the true TOI, not the skinned advance
421
- timeLeft -= timeLeft * (fraction > 1 ? 1 : fraction);
422
-
423
- if (numPlanes >= MAX_CLIP_PLANES) {
424
- // Too many planes wedged. Stop.
425
- vx = vy = vz = 0;
426
- break;
427
- }
428
- // Store the true contact plane for velocity clipping. The
429
- // capsule's round bottom does NOT roll up a too-tall obstacle:
430
- // standing on the floor it meets a wall with its full-radius
431
- // cylinder side (a horizontal normal — a clean stop), and
432
- // CLIMBING a step ≤ stepHeight is the explicit step-up's job,
433
- // gated by a thin step-height probe that reads true obstacle
434
- // height regardless of approach speed. So the slide keeps every
435
- // surface's real normal flattening steep contacts to vertical
436
- // here would also rob a too-steep SLOPE of its downhill slide.
437
- const po = numPlanes * 3;
438
- planes[po] = hit.normal.x;
439
- planes[po + 1] = hit.normal.y;
440
- planes[po + 2] = hit.normal.z;
441
- numPlanes++;
442
-
443
- // Re-derive velocity: find a plane the ORIGINAL velocity can
444
- // slide along without violating any other plane.
445
- let resolved = false;
446
- for (let i = 0; i < numPlanes; i++) {
447
- const ix = planes[i * 3], iy = planes[i * 3 + 1], iz = planes[i * 3 + 2];
448
- this._clip(ovx, ovy, ovz, ix, iy, iz, cand);
449
- let ok = true;
450
- for (let j = 0; j < numPlanes; j++) {
451
- if (j === i) continue;
452
- const jx = planes[j * 3], jy = planes[j * 3 + 1], jz = planes[j * 3 + 2];
453
- if (cand[0] * jx + cand[1] * jy + cand[2] * jz < 0) { ok = false; break; }
454
- }
455
- if (ok) {
456
- vx = cand[0]; vy = cand[1]; vz = cand[2];
457
- resolved = true;
458
- break;
459
- }
460
- }
461
-
462
- if (!resolved) {
463
- // No single plane works slide along the crease of two.
464
- // (More than two simultaneously-violated planes corner;
465
- // dead-stop.)
466
- if (numPlanes !== 2) {
467
- vx = vy = vz = 0;
468
- break;
469
- }
470
- const ax = planes[0], ay = planes[1], az = planes[2];
471
- const bx = planes[3], by = planes[4], bz = planes[5];
472
- // seam = a × b
473
- let cx = ay * bz - az * by;
474
- let cy = az * bx - ax * bz;
475
- let cz = ax * by - ay * bx;
476
- const clen = Math.sqrt(cx * cx + cy * cy + cz * cz);
477
- if (clen < MIN_MOVE) { vx = vy = vz = 0; break; }
478
- const cinv = 1 / clen;
479
- cx *= cinv; cy *= cinv; cz *= cinv;
480
- const d = cx * ovx + cy * ovy + cz * ovz;
481
- vx = cx * d; vy = cy * d; vz = cz * d;
482
- }
483
-
484
- // Anti-oscillation: if the new velocity opposes the original
485
- // intent, we'd bounce back into the geometry — stop instead.
486
- if (vx * ovx + vy * ovy + vz * ovz <= 0) {
487
- vx = vy = vz = 0;
488
- break;
489
- }
490
- }
491
-
492
- velocity.set(vx, vy, vz);
493
- }
494
-
495
- /**
496
- * Ground categorization + stick-to-ground + slope velocity clip.
497
- * Probes below the feet to decide grounded-ness, the surface height
498
- * to rest on, and the surface normal for the velocity clip.
499
- *
500
- * - `ascending` (jumped this tick) → never grounded; skip entirely
501
- * so the snap can't cancel the jump.
502
- * - no surface within `stepHeight` below the feet airborne.
503
- *
504
- * Two probes, with a deliberate division of labour that's what makes
505
- * stairs AND slopes both work (they're the same steep-normal contact
506
- * to a single probe, so one probe can't tell them apart):
507
- *
508
- * (A) WALKABILITY + base height — a centre-point RAYCAST. It sees
509
- * the actual planar surface under the feet and ignores both a
510
- * step's convex top EDGE (whose normal is misleadingly steep)
511
- * and a wall's vertical SIDE face. A steep normal here means a
512
- * genuine steep SLOPE → not grounded (slide).
513
- * (B) STEP height a footprint capsule SHAPECAST. It raises the
514
- * rest height onto a step the leading edge overhangs (which the
515
- * centre ray, aimed behind the riser, misses). Used only when
516
- * it genuinely swept (`hit.t > skin`, not start-solid in a wall)
517
- * and the step is within `stepHeight`.
518
- *
519
- * Reference point is the capsule bottom (`position.y`), matching the
520
- * feet-at-origin player capsule.
521
- *
522
- * @private
523
- */
524
- _categorizeGround(position, rotation, shape, velocity, filter, result, ascending) {
525
- if (ascending) return; // jump / launch stay airborne this tick
526
-
527
- const ray = this._ray;
528
- const hit = this._hit;
529
- const lift = this.stepHeight; // probe starts this high above the feet
530
- const reach = lift + this.stepHeight + this.skin; // …down to stepHeight below the feet
531
-
532
- // (A) Centre raycast walkability + base surface height.
533
- ray.setOrigin(position.x, position.y + lift, position.z);
534
- ray.setDirection(0, -1, 0);
535
- ray.tMax = reach;
536
- if (!this.physicsSystem.raycast(ray, hit, filter)) return; // airborne
537
- const nx = hit.normal.x, ny = hit.normal.y, nz = hit.normal.z;
538
- result.groundNormal.set(nx, ny, nz);
539
- if (ny < this.minWalkNormal) return; // steep slope under the feet — slide, not grounded
540
- const centreSurfaceY = position.y + lift - hit.t; // the planar ground under the feet
541
- let surfaceY = centreSurfaceY;
542
-
543
- // (B) Footprint shapecast — mount a step the leading edge overhangs.
544
- ray.setOrigin(position.x, position.y + lift, position.z);
545
- ray.setDirection(0, -1, 0);
546
- ray.tMax = reach;
547
- if (this.physicsSystem.shapeCast(ray, shape, rotation, hit, filter) && hit.t > this.skin) {
548
- const stepY = position.y + lift - hit.t;
549
- // Mount it only if it's higher than the centre surface and no
550
- // more than `stepHeight` ABOVE THAT CENTRE SURFACE not above
551
- // the feet. Gating on the feet would let the player climb a
552
- // wall incrementally: ride up its edge a hair, then mount the
553
- // now-within-reach next sliver, and so on. Gating on the
554
- // ground under the centre means a wall top is always >
555
- // stepHeight above the floor the centre sees never mounted,
556
- // and a capsule that rode up gets snapped back to the floor.
557
- if (stepY > surfaceY && stepY - centreSurfaceY <= this.stepHeight + this.skin) {
558
- surfaceY = stepY;
559
- }
560
- }
561
-
562
- result.grounded = true;
563
-
564
- // Stick: rest the feet `skin` above the surface (snaps down for a
565
- // descent, up onto a step / out of a grazing contact).
566
- position.y = surfaceY + this.skin;
567
-
568
- // Clip the into-ground velocity component (gravity on flat ground;
569
- // the into-slope part on a ramp), leaving tangential motion.
570
- const vdot = velocity.x * nx + velocity.y * ny + velocity.z * nz;
571
- if (vdot < 0) {
572
- velocity.set(
573
- velocity.x - vdot * nx,
574
- velocity.y - vdot * ny,
575
- velocity.z - vdot * nz,
576
- );
577
- }
578
- }
579
-
580
- /**
581
- * `out = v - n·(v·n)` project `v` onto the plane with unit normal
582
- * `n` (overbounce 1.0; clearance handled by `skin`, so no extra
583
- * nudge — equivalent in practice to Quake3's `OVERCLIP 1.001`).
584
- * @private
585
- */
586
- _clip(vx, vy, vz, nx, ny, nz, out) {
587
- const backoff = vx * nx + vy * ny + vz * nz;
588
- out[0] = vx - nx * backoff;
589
- out[1] = vy - ny * backoff;
590
- out[2] = vz - nz * backoff;
591
- }
592
- }
1
+ import Vector3 from "../../../../core/geom/Vector3.js";
2
+ import { Ray3 } from "../../../../core/geom/3d/ray/Ray3.js";
3
+ import { Transform } from "../../../ecs/transform/Transform.js";
4
+ import { Collider } from "../../../physics/ecs/Collider.js";
5
+ import { compute_penetration } from "../../../physics/narrowphase/compute_penetration.js";
6
+ import { PhysicsSurfacePoint } from "../../../physics/queries/PhysicsSurfacePoint.js";
7
+
8
+ /**
9
+ * Maximum simultaneous clip planes tracked within a single slide,
10
+ * matching Quake's `MAX_CLIP_PLANES`. A move that contacts more than
11
+ * this many distinct planes in one tick is in pathological geometry
12
+ * (a cone of inward-pointing walls); we dead-stop rather than thrash.
13
+ * @type {number}
14
+ */
15
+ const MAX_CLIP_PLANES = 5;
16
+
17
+ /** Below this speed/length we treat the remaining motion as spent. */
18
+ const MIN_MOVE = 1e-6;
19
+
20
+ /**
21
+ * Kinematic character collision solver — Phase 1 (recover +
22
+ * unified sweep-and-slide). See DESIGN_COLLISION.md.
23
+ *
24
+ * The mover is controller-agnostic: it knows about a capsule pose, a
25
+ * desired velocity, and the physics world. Its job is to take the
26
+ * velocity the control layer wants and return the position it actually
27
+ * reaches plus the velocity corrected for whatever it hit. It invents
28
+ * no motion — gravity / jump impulses live in the control layer and
29
+ * arrive folded into `velocity`.
30
+ *
31
+ * One move is a sequence (Phase 1 implements steps 1-2; ground
32
+ * categorize / stairs / settle land in later phases):
33
+ *
34
+ * 1. RECOVER — depenetration via `overlap()` + `compute_penetration`,
35
+ * run unconditionally so the move starts clear even from a
36
+ * start-solid state a pure sweep can't escape.
37
+ * 2. SLIDE — unified 3D collide-and-slide via `shapeCast`, clipping
38
+ * velocity onto true narrowphase contact normals. Crease-aware
39
+ * (Quake `SV_FlyMove`): a second plane re-violating the first is
40
+ * handled by sliding along their seam; a third (or a velocity
41
+ * reversal) dead-stops. Floors / ceilings / walls are all just
42
+ * contact planes, so vertical anti-tunnelling is automatic.
43
+ *
44
+ * @author Alex Goldring
45
+ * @copyright Company Named Limited (c) 2026
46
+ */
47
+ export class KinematicMover {
48
+ /**
49
+ * @param {import("../../../physics/ecs/PhysicsSystem.js").PhysicsSystem} physicsSystem
50
+ * @param {import("../../../ecs/EntityComponentDataset.js").EntityComponentDataset} ecd
51
+ * used to resolve an overlapping `body_id` (via
52
+ * `physicsSystem.entityOf`) back to its {@link Transform} +
53
+ * {@link Collider} for `compute_penetration`.
54
+ * @param {object} [options]
55
+ * @param {number} [options.skin=0.005] clearance left after each
56
+ * sweep stop / push-out so the next query doesn't start in contact.
57
+ * @param {number} [options.maxSlideIterations=4] slide "bumps" per
58
+ * move (Quake `numbumps`).
59
+ * @param {number} [options.maxRecoverIterations=4] depenetration
60
+ * passes before giving up (deepest body pushed out per pass).
61
+ * @param {number} [options.minWalkNormal=0.7] minimum ground-normal Y
62
+ * to count as standable (~45.6°). Below it the surface is "too
63
+ * steep" — the player slides instead of grounding. Matches Quake3
64
+ * `MIN_WALK_NORMAL` / Source `normal.z ≥ 0.7`.
65
+ * @param {number} [options.stepHeight=0.3] maximum step the player
66
+ * traverses, both up and down:
67
+ * - UP: a riser no taller than this is climbed — by the explicit
68
+ * step-up ({@link _tryStepUp}, up-forward-down, which works
69
+ * regardless of forward momentum) and by `_categorizeGround`
70
+ * mounting the player onto a surface within `stepHeight` of the
71
+ * feet. A taller riser blocks (the slide stops the player; the
72
+ * step-up's forward clearance cast detects the wall and aborts).
73
+ * - DOWN: the ground-stick reach — walking off a drop no larger
74
+ * than this snaps the player onto the lower surface (stays
75
+ * grounded); a larger drop goes airborne (a real ledge).
76
+ * Source `sv_stepsize` is ~0.34 m; the default is just under the
77
+ * capsule radius so low ledges don't feel "magnetic".
78
+ */
79
+ constructor(physicsSystem, ecd, options = {}) {
80
+ this.physicsSystem = physicsSystem;
81
+ this.ecd = ecd;
82
+ this.skin = options.skin !== undefined ? options.skin : 0.005;
83
+ this.maxSlideIterations = options.maxSlideIterations !== undefined ? options.maxSlideIterations : 4;
84
+ this.maxRecoverIterations = options.maxRecoverIterations !== undefined ? options.maxRecoverIterations : 4;
85
+ this.minWalkNormal = options.minWalkNormal !== undefined ? options.minWalkNormal : 0.7;
86
+ this.stepHeight = options.stepHeight !== undefined ? options.stepHeight : 0.3;
87
+
88
+ // ── Scratch — reused per move, no per-call allocation ──────────
89
+ this._ray = new Ray3();
90
+ this._hit = new PhysicsSurfacePoint();
91
+ this._overlapBuf = new Uint32Array(16);
92
+ this._penDir = new Float64Array(3); // compute_penetration out
93
+ this._planes = new Float64Array(MAX_CLIP_PLANES * 3);
94
+ this._cand = new Float64Array(3); // clipped-velocity candidate
95
+ this._support = new Float64Array(3); // shape support point (forward extent)
96
+
97
+ /**
98
+ * Reused result. `grounded` / `groundNormal` are Phase 2 outputs;
99
+ * in Phase 1 they're left at their defaults (the mover doesn't
100
+ * categorize ground yet).
101
+ * @type {{hit:boolean, grounded:boolean, groundNormal:Vector3}}
102
+ */
103
+ this._result = { hit: false, grounded: false, groundNormal: new Vector3(0, 1, 0) };
104
+ }
105
+
106
+ /**
107
+ * Resolve one move. Mutates `position` to the resolved location and
108
+ * `velocity` to the corrected value.
109
+ *
110
+ * @param {Vector3} position in/out — current pose, written to the resolved pose
111
+ * @param {{x:number,y:number,z:number,w:number}} rotation capsule orientation (read)
112
+ * @param {import("../../../../core/geom/3d/shape/AbstractShape3D.js").AbstractShape3D} shape convex capsule
113
+ * @param {Vector3} velocity in/out — desired velocity, written to the corrected velocity
114
+ * @param {number} dt
115
+ * @param {(entity:number, collider:Collider)=>boolean} filter excludes
116
+ * the player's own body in integration; accept-all in isolation
117
+ * @returns {{hit:boolean, grounded:boolean, groundNormal:Vector3}} reused result
118
+ */
119
+ move(position, rotation, shape, velocity, dt, filter) {
120
+ const result = this._result;
121
+ result.hit = false;
122
+ result.grounded = false;
123
+ result.groundNormal.set(0, 1, 0);
124
+
125
+ // Capture intent-to-leave BEFORE the slide. A positive input
126
+ // vertical velocity is a jump / launch (the only thing that sets
127
+ // one — gravity is negative); on those ticks we must NOT
128
+ // stick-to-ground or the snap cancels the jump on its first
129
+ // frame. A ramp's upward velocity is produced INSIDE the slide
130
+ // (the ramp is a contact plane), so it doesn't show up here —
131
+ // which is exactly why we gate on the pre-slide value.
132
+ const ascending = velocity.y > MIN_MOVE;
133
+
134
+ // 1. Recover — start the move penetration-free.
135
+ this._recover(position, rotation, shape, filter);
136
+
137
+ // On WALKABLE ground the surface bears the body's weight, so gravity
138
+ // must not leak into a downhill SLIDE. A biped grips any slope it
139
+ // could walk up; the instant its feet slip it could no longer have
140
+ // climbed either — so "doesn't slip" and "is walkable" are the SAME
141
+ // minWalkNormal gate, not two tunables. Drop the downward (gravity)
142
+ // velocity before the slide whenever the feet rest on walkable
143
+ // ground (`_groundedAt` is precisely that: a walkable contact normal
144
+ // within the stick band). The slide then carries only the player's
145
+ // own intent and the stick in `_categorizeGround` keeps the body
146
+ // glued to the slope. On a TOO-STEEP face `_groundedAt` is false (the
147
+ // normal fails the gate), gravity survives, and the body slides down
148
+ // as feet with no purchase must. A jump (`velocity.y > 0`) is left
149
+ // alone so the launch is never cancelled.
150
+ if (velocity.y < 0 && this._groundedAt(position.x, position.y, position.z, filter)) {
151
+ velocity.y = 0;
152
+ }
153
+
154
+ // Snapshot pre-slide state for a possible stair step-up retry.
155
+ const sx = position.x, sy = position.y, sz = position.z;
156
+ const svx = velocity.x, svy = velocity.y, svz = velocity.z;
157
+
158
+ // 2. Sweep-and-slide the desired motion.
159
+ this._slide(position, rotation, shape, velocity, dt, filter, result);
160
+
161
+ // 2b. Stairs — if a grounded move was blocked, try to step up and
162
+ // over a low riser. The capsule's round bottom also rolls up
163
+ // low steps via the slide, but only with enough forward
164
+ // momentum; a controller that reads back the slide-clipped
165
+ // velocity loses that momentum at the riser and gets stuck.
166
+ // The explicit step-up climbs without depending on momentum
167
+ // AND restores the horizontal velocity, so the player keeps
168
+ // moving up the flight.
169
+ if (this.stepHeight > 0 && !ascending && result.hit) {
170
+ this._tryStepUp(position, rotation, shape, velocity,
171
+ sx, sy, sz, svx, svy, svz, dt, filter);
172
+ }
173
+
174
+ // 3. Ground categorize + stick + slope velocity clip.
175
+ this._categorizeGround(position, rotation, shape, velocity, filter, result, ascending);
176
+
177
+ return result;
178
+ }
179
+
180
+ /**
181
+ * Stair step-up. Called after a slide that hit something while the
182
+ * player was trying to move horizontally on the ground. Re-runs the
183
+ * horizontal move from the pre-slide position lifted by `stepHeight`,
184
+ * guarded by a forward CLEARANCE cast: if the path is still blocked at
185
+ * the lifted height the obstacle is taller than a step (a wall) and we
186
+ * abandon the attempt; if clear it's a step, so advance over it and
187
+ * drop. Commits only when it advances farther horizontally than the
188
+ * plain slide and the rise is within `stepHeight`. On success the
189
+ * horizontal velocity is restored (the riser wasn't a wall) so the
190
+ * controller doesn't read back a stalled speed; the vertical is left
191
+ * for `_categorizeGround` to settle on the step.
192
+ *
193
+ * @private
194
+ */
195
+ _tryStepUp(position, rotation, shape, velocity, sx, sy, sz, svx, svy, svz, dt, filter) {
196
+ const hdx = svx * dt, hdz = svz * dt;
197
+ const forwardLen = Math.sqrt(hdx * hdx + hdz * hdz);
198
+ if (forwardLen < MIN_MOVE) return; // no horizontal intent
199
+ // Only step from a grounded start never mid-air (else a player
200
+ // could climb a wall by jumping into it).
201
+ if (!this._groundedAt(sx, sy, sz, filter)) return;
202
+
203
+ const slideProg = (position.x - sx) * (position.x - sx) + (position.z - sz) * (position.z - sz);
204
+ const px = position.x, py = position.y, pz = position.z;
205
+ const pvx = velocity.x, pvy = velocity.y, pvz = velocity.z;
206
+ const fnx = hdx / forwardLen, fnz = hdz / forwardLen; // travel direction
207
+
208
+ // Direction the slide was BLOCKED in the inward normal of the
209
+ // obstacle the player walked into = the horizontal velocity the
210
+ // slide removed (pre-slide minus clipped). For a head-on approach
211
+ // this equals the travel direction; for an OBLIQUE one it's the
212
+ // wall's normal, which is what we must probe along: the capsule
213
+ // meets the wall with its normal-direction extent, so a probe cast
214
+ // along TRAVEL would stop short of the face (it only has to reach
215
+ // radius/​cos θ along travel, but radius along the normal) and read a
216
+ // wall as clear, climbing it. Deriving it from the velocity change
217
+ // needs no slide-internal normal plumbed out.
218
+ let bdx = svx - velocity.x, bdz = svz - velocity.z;
219
+ const bdLen = Math.sqrt(bdx * bdx + bdz * bdz);
220
+ if (bdLen < MIN_MOVE) return; // nothing opposed the horizontal moveno wall/step ahead
221
+ bdx /= bdLen; bdz /= bdLen;
222
+
223
+ // ── Step-vs-wall decision: a THIN horizontal ray at the step-height
224
+ // plane ──────────────────────────────────────────────────────
225
+ // The obvious "lift the capsule by stepHeight and sweep it forward"
226
+ // clearance test is fooled by the capsule's ROUNDED bottom: lifted
227
+ // so its tip sits at stepHeight, the hemisphere narrows through the
228
+ // band (stepHeight, stepHeight+radius), so a wall whose top lands in
229
+ // that band is never reached by the swept capsule it reads "clear"
230
+ // and the player climbs a too-tall wall (and does so speed-
231
+ // dependently, since a faster sweep reaches further into the round-
232
+ // off). A thin ray has no such round-off. Cast it INTO the obstacle
233
+ // (along the blocked normal) at exactly the highest climbable height:
234
+ // anything taller than a step has solid material crossing that plane
235
+ // and is struck on its front face; a genuine step (top below the
236
+ // plane) is passed clean over. This reads the obstacle's true height,
237
+ // independent of how the round bottom would perch on its edge — so
238
+ // "clear it or don't" holds at any speed and any approach angle.
239
+ //
240
+ // Origin at the PRE-slide centre, not post-slide: rounding a convex
241
+ // corner, the slide can carry the centre just past the corner (out
242
+ // of the obstacle's footprint) so a probe from there shoots past it
243
+ // and reads clear, climbing the corner. The pre-slide centre was
244
+ // still in front of what blocked it. Reach spans the swept move
245
+ // (`forwardLen`) plus a forward-extent to the leading edge plus a
246
+ // few skins — enough to cross the front face wherever along the
247
+ // sweep contact happened. The reach tracking the sweep is not the
248
+ // banned speed coupling: the climb-or-block DECISION is the
249
+ // step-height plane alone; the reach only governs how far ahead we
250
+ // look for the thing the slide already hit.
251
+ shape.support(this._support, 0, bdx, 0, bdz);
252
+ const lead = this._support[0] * bdx + this._support[2] * bdz; // extent toward the wall (capsule radius)
253
+ const ray = this._ray;
254
+ ray.setOrigin(sx, sy + this.stepHeight + this.skin, sz);
255
+ ray.setDirection(bdx, 0, bdz);
256
+ ray.tMax = forwardLen + lead + 4 * this.skin;
257
+ if (this.physicsSystem.raycast(ray, this._hit, filter)) return; // taller than a step — a wall; keep the plain slide
258
+
259
+ // Confirmed climbable (nothing crosses the step-height plane ahead).
260
+ // Up–forward–down places the capsule onto the step top.
261
+ position.set(sx, sy, sz);
262
+ const up = this._castDistance(position, rotation, shape, 0, 1, 0, this.stepHeight, filter);
263
+ position._add(0, up, 0);
264
+ position._add(fnx * forwardLen, 0, fnz * forwardLen);
265
+ const down = this._castDistance(position, rotation, shape, 0, -1, 0, up + this.skin, filter);
266
+ position._add(0, -down, 0);
267
+
268
+ // Reject a climb that ends INSIDE geometry. The thin step-height
269
+ // probe is a single ray, so at a convex CORNER it can thread past
270
+ // the corner point (the contact normal there is near-parallel to a
271
+ // face, so the ray runs alongside the box just outside its
272
+ // footprint) and read clear then up-forward-down perches the round
273
+ // body on that corner, overlapping it. A genuine step top is rested
274
+ // on `skin` ABOVE, never overlapping; so an overlap at the
275
+ // destination is the tell that we climbed onto a wall, not a step.
276
+ const stepProg = (position.x - sx) * (position.x - sx) + (position.z - sz) * (position.z - sz);
277
+ const heightGain = position.y - sy;
278
+ const overlaps = this.physicsSystem.overlap(shape, position, rotation, this._overlapBuf, 0, filter) > 0;
279
+ if (stepProg > slideProg + 1e-8 && heightGain <= this.stepHeight + this.skin && !overlaps) {
280
+ velocity.set(svx, svy, svz); // restore horizontal; vertical settled by categorize
281
+ } else {
282
+ position.set(px, py, pz);
283
+ velocity.set(pvx, pvy, pvz);
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Distance the shape can sweep along a unit axis before contact (less
289
+ * `skin`), or the full `maxDist` if clear. For the step-up lift/drop.
290
+ * @private
291
+ */
292
+ _castDistance(position, rotation, shape, dx, dy, dz, maxDist, filter) {
293
+ const ray = this._ray;
294
+ ray.setOrigin(position.x, position.y, position.z);
295
+ ray.setDirection(dx, dy, dz);
296
+ ray.tMax = maxDist;
297
+ if (!this.physicsSystem.shapeCast(ray, shape, rotation, this._hit, filter)) return maxDist;
298
+ const t = this._hit.t - this.skin;
299
+ return t > 0 ? t : 0;
300
+ }
301
+
302
+ /**
303
+ * True when a centre raycast at (x,y,z) finds a walkable surface
304
+ * within stick range gates step-up on "actually standing".
305
+ * @private
306
+ */
307
+ _groundedAt(x, y, z, filter) {
308
+ const ray = this._ray;
309
+ const lift = this.stepHeight;
310
+ ray.setOrigin(x, y + lift, z);
311
+ ray.setDirection(0, -1, 0);
312
+ ray.tMax = lift + this.stepHeight + this.skin;
313
+ if (!this.physicsSystem.raycast(ray, this._hit, filter)) return false;
314
+ if (this._hit.normal.y < this.minWalkNormal) return false;
315
+ return (y + lift - this._hit.t) <= y + this.skin;
316
+ }
317
+
318
+ /**
319
+ * Push the capsule out of any geometry it currently overlaps. Each
320
+ * pass queries overlaps, finds the single deepest penetration via
321
+ * {@link compute_penetration}, and pushes out along its separation
322
+ * axis by `depth + skin`; re-queries until clear or the iteration
323
+ * cap is hit. Deepest-first converges multi-body resting contact
324
+ * (each pass removes the worst offender) without solving a system.
325
+ *
326
+ * @private
327
+ */
328
+ _recover(position, rotation, shape, filter) {
329
+ const physics = this.physicsSystem;
330
+ const ecd = this.ecd;
331
+ const buf = this._overlapBuf;
332
+ const dir = this._penDir;
333
+
334
+ for (let iter = 0; iter < this.maxRecoverIterations; iter++) {
335
+ const n = physics.overlap(shape, position, rotation, buf, 0, filter);
336
+ if (n === 0) return;
337
+
338
+ let bestDepth = 0;
339
+ let bestX = 0, bestY = 0, bestZ = 0;
340
+
341
+ for (let i = 0; i < n; i++) {
342
+ const bodyId = buf[i];
343
+ const entity = physics.entityOf(bodyId);
344
+ if (entity < 0) continue;
345
+ const otherT = ecd.getComponent(entity, Transform);
346
+ const otherC = ecd.getComponent(entity, Collider);
347
+ if (otherT === undefined || otherC === undefined) continue;
348
+
349
+ // depth + unit B→A separation (A = our capsule, B = other).
350
+ const depth = compute_penetration(
351
+ dir,
352
+ shape, position, rotation,
353
+ otherC.shape, otherT.position, otherT.rotation,
354
+ );
355
+ if (depth > bestDepth) {
356
+ bestDepth = depth;
357
+ bestX = dir[0]; bestY = dir[1]; bestZ = dir[2];
358
+ }
359
+ }
360
+
361
+ // Overlap reported but no actionable depth (touching boundary,
362
+ // or MPR/overlap disagreement at the kiss) — treat as clear.
363
+ if (bestDepth <= 0) return;
364
+
365
+ const push = bestDepth + this.skin;
366
+ position._add(bestX * push, bestY * push, bestZ * push);
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Unified 3D collide-and-slide. Faithful to Quake's `SV_FlyMove`:
372
+ * sweep the velocity for the remaining time, stop at contact, clip
373
+ * the (original) velocity against every plane hit so far, slide
374
+ * along the seam of a two-plane crease, dead-stop on a third plane
375
+ * or a velocity reversal.
376
+ *
377
+ * @private
378
+ */
379
+ _slide(position, rotation, shape, velocity, dt, filter, result) {
380
+ const physics = this.physicsSystem;
381
+ const ray = this._ray;
382
+ const hit = this._hit;
383
+ const planes = this._planes;
384
+ const cand = this._cand;
385
+ const skin = this.skin;
386
+
387
+ // Original desired velocity — clipping is always done against
388
+ // this, not the running value (Quake's invariant; prevents
389
+ // accumulated rounding from spiralling the direction).
390
+ const ovx = velocity.x, ovy = velocity.y, ovz = velocity.z;
391
+ let vx = ovx, vy = ovy, vz = ovz;
392
+ let timeLeft = dt;
393
+ let numPlanes = 0;
394
+
395
+ for (let bump = 0; bump < this.maxSlideIterations; bump++) {
396
+ const speed = Math.sqrt(vx * vx + vy * vy + vz * vz);
397
+ if (speed < MIN_MOVE) break;
398
+ const len = speed * timeLeft;
399
+ if (len < MIN_MOVE) break;
400
+
401
+ const inv = 1 / speed;
402
+ const ndx = vx * inv, ndy = vy * inv, ndz = vz * inv;
403
+
404
+ ray.setOrigin(position.x, position.y, position.z);
405
+ ray.setDirection(ndx, ndy, ndz);
406
+ ray.tMax = len;
407
+
408
+ const didHit = physics.shapeCast(ray, shape, rotation, hit, filter);
409
+
410
+ if (!didHit) {
411
+ // Clear path — consume the whole remaining move.
412
+ position._add(ndx * len, ndy * len, ndz * len);
413
+ break;
414
+ }
415
+
416
+ // Separating contact: `shapeCast` reports a start-in-contact
417
+ // hit (t≈0) even when we're moving AWAY from the touched
418
+ // surface e.g. jumping off a floor we're resting on, where
419
+ // the down-normal floor is behind the upward motion. If the
420
+ // sweep direction points along the outward contact normal
421
+ // (dir·n 0) the surface isn't in our way; take the full
422
+ // remaining move rather than clipping the velocity into it
423
+ // (which would cancel the jump). A genuine blocker is only
424
+ // reachable by moving INTO it (dir·n < 0).
425
+ const dirDotN = ndx * hit.normal.x + ndy * hit.normal.y + ndz * hit.normal.z;
426
+ if (dirDotN >= 0) {
427
+ position._add(ndx * len, ndy * len, ndz * len);
428
+ break;
429
+ }
430
+
431
+ result.hit = true;
432
+
433
+ // Advance up to the contact (less skin), and consume the
434
+ // corresponding fraction of the remaining time.
435
+ const advance = hit.t - skin > 0 ? hit.t - skin : 0;
436
+ if (advance > 0) position._add(ndx * advance, ndy * advance, ndz * advance);
437
+ const fraction = hit.t / len; // time uses the true TOI, not the skinned advance
438
+ timeLeft -= timeLeft * (fraction > 1 ? 1 : fraction);
439
+
440
+ if (numPlanes >= MAX_CLIP_PLANES) {
441
+ // Too many planes — wedged. Stop.
442
+ vx = vy = vz = 0;
443
+ break;
444
+ }
445
+ // Store the true contact plane for velocity clipping. The
446
+ // capsule's round bottom does NOT roll up a too-tall obstacle:
447
+ // standing on the floor it meets a wall with its full-radius
448
+ // cylinder side (a horizontal normal a clean stop), and
449
+ // CLIMBING a step ≤ stepHeight is the explicit step-up's job,
450
+ // gated by a thin step-height probe that reads true obstacle
451
+ // height regardless of approach speed. So the slide keeps every
452
+ // surface's real normal flattening steep contacts to vertical
453
+ // here would also rob a too-steep SLOPE of its downhill slide.
454
+ const po = numPlanes * 3;
455
+ planes[po] = hit.normal.x;
456
+ planes[po + 1] = hit.normal.y;
457
+ planes[po + 2] = hit.normal.z;
458
+ numPlanes++;
459
+
460
+ // Re-derive velocity: find a plane the ORIGINAL velocity can
461
+ // slide along without violating any other plane.
462
+ let resolved = false;
463
+ for (let i = 0; i < numPlanes; i++) {
464
+ const ix = planes[i * 3], iy = planes[i * 3 + 1], iz = planes[i * 3 + 2];
465
+ this._clip(ovx, ovy, ovz, ix, iy, iz, cand);
466
+ let ok = true;
467
+ for (let j = 0; j < numPlanes; j++) {
468
+ if (j === i) continue;
469
+ const jx = planes[j * 3], jy = planes[j * 3 + 1], jz = planes[j * 3 + 2];
470
+ if (cand[0] * jx + cand[1] * jy + cand[2] * jz < 0) { ok = false; break; }
471
+ }
472
+ if (ok) {
473
+ vx = cand[0]; vy = cand[1]; vz = cand[2];
474
+ resolved = true;
475
+ break;
476
+ }
477
+ }
478
+
479
+ if (!resolved) {
480
+ // No single plane works slide along the crease of two.
481
+ // (More than two simultaneously-violated planes corner;
482
+ // dead-stop.)
483
+ if (numPlanes !== 2) {
484
+ vx = vy = vz = 0;
485
+ break;
486
+ }
487
+ const ax = planes[0], ay = planes[1], az = planes[2];
488
+ const bx = planes[3], by = planes[4], bz = planes[5];
489
+ // seam = a × b
490
+ let cx = ay * bz - az * by;
491
+ let cy = az * bx - ax * bz;
492
+ let cz = ax * by - ay * bx;
493
+ const clen = Math.sqrt(cx * cx + cy * cy + cz * cz);
494
+ if (clen < MIN_MOVE) { vx = vy = vz = 0; break; }
495
+ const cinv = 1 / clen;
496
+ cx *= cinv; cy *= cinv; cz *= cinv;
497
+ const d = cx * ovx + cy * ovy + cz * ovz;
498
+ vx = cx * d; vy = cy * d; vz = cz * d;
499
+ }
500
+
501
+ // Anti-oscillation: if the new velocity opposes the original
502
+ // intent, we'd bounce back into the geometry stop instead.
503
+ if (vx * ovx + vy * ovy + vz * ovz <= 0) {
504
+ vx = vy = vz = 0;
505
+ break;
506
+ }
507
+ }
508
+
509
+ velocity.set(vx, vy, vz);
510
+ }
511
+
512
+ /**
513
+ * Ground categorization + stick-to-ground + slope velocity clip.
514
+ * Probes below the feet to decide grounded-ness, the surface height
515
+ * to rest on, and the surface normal for the velocity clip.
516
+ *
517
+ * - `ascending` (jumped this tick) → never grounded; skip entirely
518
+ * so the snap can't cancel the jump.
519
+ * - no surface within `stepHeight` below the feet airborne.
520
+ *
521
+ * Two probes, with a deliberate division of labour that's what makes
522
+ * stairs AND slopes both work (they're the same steep-normal contact
523
+ * to a single probe, so one probe can't tell them apart):
524
+ *
525
+ * (A) WALKABILITY + base heighta centre-point RAYCAST. It sees
526
+ * the actual planar surface under the feet and ignores both a
527
+ * step's convex top EDGE (whose normal is misleadingly steep)
528
+ * and a wall's vertical SIDE face. A steep normal here means a
529
+ * genuine steep SLOPE not grounded (slide).
530
+ * (B) STEP height a footprint capsule SHAPECAST. It raises the
531
+ * rest height onto a step the leading edge overhangs (which the
532
+ * centre ray, aimed behind the riser, misses). Used only when
533
+ * it genuinely swept (`hit.t > skin`, not start-solid in a wall)
534
+ * and the step is within `stepHeight`.
535
+ *
536
+ * Reference point is the capsule bottom (`position.y`), matching the
537
+ * feet-at-origin player capsule.
538
+ *
539
+ * @private
540
+ */
541
+ _categorizeGround(position, rotation, shape, velocity, filter, result, ascending) {
542
+ if (ascending) return; // jump / launch — stay airborne this tick
543
+
544
+ const ray = this._ray;
545
+ const hit = this._hit;
546
+ const lift = this.stepHeight; // probe starts this high above the feet
547
+ const reach = lift + this.stepHeight + this.skin; // …down to stepHeight below the feet
548
+
549
+ // (A) Centre raycast walkability + base surface height. A steep
550
+ // normal here is a genuine SLOPE (slide). A MISS, though, is NOT
551
+ // "airborne" by itself: the centre ray is one thin ray, and when
552
+ // the body straddles a narrow GAP (or overhangs a ledge edge) it
553
+ // shoots clean through the slot and finds nothing within reach,
554
+ // while the footprint below is still resting on the box tops. So
555
+ // a miss just means "no centre surface" the footprint decides.
556
+ let nx = 0, ny = 1, nz = 0;
557
+ let haveCentre = false;
558
+ let centreSurfaceY = 0;
559
+ ray.setOrigin(position.x, position.y + lift, position.z);
560
+ ray.setDirection(0, -1, 0);
561
+ ray.tMax = reach;
562
+ if (this.physicsSystem.raycast(ray, hit, filter)) {
563
+ nx = hit.normal.x; ny = hit.normal.y; nz = hit.normal.z;
564
+ if (ny < this.minWalkNormal) {
565
+ result.groundNormal.set(nx, ny, nz);
566
+ return; // steep slope under the feet — slide, not grounded
567
+ }
568
+ centreSurfaceY = position.y + lift - hit.t; // the planar ground under the feet
569
+ haveCentre = true;
570
+ }
571
+
572
+ // (B) Footprint shapecast — the rest height. WITH a centre surface it
573
+ // MOUNTS a step the leading edge overhangs; with NO centre surface
574
+ // it IS the ground (the straddle support the centre ray missed).
575
+ // Gate the mount on `stepHeight` above the BASE — the centre
576
+ // surface when we have one (so a wall top, > stepHeight above the
577
+ // floor the centre sees, never mounts and a capsule that rode up
578
+ // snaps back), else `feet − stepHeight` (so any footprint within
579
+ // the stick band, but no higher than the feet, supports us).
580
+ const base = haveCentre ? centreSurfaceY : (position.y - this.stepHeight);
581
+ let surfaceY = haveCentre ? centreSurfaceY : Number.NEGATIVE_INFINITY;
582
+ ray.setOrigin(position.x, position.y + lift, position.z);
583
+ ray.setDirection(0, -1, 0);
584
+ ray.tMax = reach;
585
+ if (this.physicsSystem.shapeCast(ray, shape, rotation, hit, filter) && hit.t > this.skin) {
586
+ const stepY = position.y + lift - hit.t;
587
+ // With a centre surface the centre ray already vouched for
588
+ // walkability, so mount any step. With NONE, the footprint is the
589
+ // sole ground source accept it only if it's a WALKABLE rest (a
590
+ // flat gap-straddle / ledge edge, footprint normal ≈ up), not a
591
+ // steep SLOPE the thin centre ray merely shot past (which must
592
+ // still slide, not stick).
593
+ const ok = haveCentre || hit.normal.y >= this.minWalkNormal;
594
+ if (ok && stepY > surfaceY && stepY - base <= this.stepHeight + this.skin) {
595
+ surfaceY = stepY;
596
+ }
597
+ }
598
+
599
+ if (surfaceY === Number.NEGATIVE_INFINITY) return; // no centre AND no footprint — truly airborne
600
+
601
+ // A gap-straddle / ledge-edge rest is supported vertically; with no
602
+ // centre normal, treat the ground as up.
603
+ result.groundNormal.set(nx, ny, nz);
604
+ result.grounded = true;
605
+
606
+ // Stick: rest the feet `skin` above the surface (snaps down for a
607
+ // descent, up onto a step / out of a grazing contact).
608
+ position.y = surfaceY + this.skin;
609
+
610
+ // Clip the into-ground velocity component (gravity on flat ground;
611
+ // the into-slope part on a ramp), leaving tangential motion.
612
+ const vdot = velocity.x * nx + velocity.y * ny + velocity.z * nz;
613
+ if (vdot < 0) {
614
+ velocity.set(
615
+ velocity.x - vdot * nx,
616
+ velocity.y - vdot * ny,
617
+ velocity.z - vdot * nz,
618
+ );
619
+ }
620
+ }
621
+
622
+ /**
623
+ * `out = v - n·(v·n)` — project `v` onto the plane with unit normal
624
+ * `n` (overbounce 1.0; clearance handled by `skin`, so no extra
625
+ * nudge — equivalent in practice to Quake3's `OVERCLIP 1.001`).
626
+ * @private
627
+ */
628
+ _clip(vx, vy, vz, nx, ny, nz, out) {
629
+ const backoff = vx * nx + vy * ny + vz * nz;
630
+ out[0] = vx - nx * backoff;
631
+ out[1] = vy - ny * backoff;
632
+ out[2] = vz - nz * backoff;
633
+ }
634
+ }