@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.
- package/package.json +1 -1
- package/src/core/geom/3d/shape/HeightMapShape3D.d.ts +33 -3
- package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -1
- package/src/core/geom/3d/shape/HeightMapShape3D.js +486 -451
- package/src/engine/control/first-person/DESIGN_COLLISION.md +365 -352
- package/src/engine/control/first-person/FirstPersonPlayerController.d.ts +1 -3
- package/src/engine/control/first-person/FirstPersonPlayerController.d.ts.map +1 -1
- package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts +12 -2
- package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts.map +1 -1
- package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.js +7 -2
- package/src/engine/control/first-person/TODO.md +13 -11
- package/src/engine/control/first-person/abilities/WallJump.d.ts.map +1 -1
- package/src/engine/control/first-person/abilities/WallJump.js +11 -3
- package/src/engine/control/first-person/abilities/WallRun.d.ts.map +1 -1
- package/src/engine/control/first-person/abilities/WallRun.js +12 -0
- package/src/engine/control/first-person/collision/KinematicMover.d.ts.map +1 -1
- package/src/engine/control/first-person/collision/KinematicMover.js +634 -592
- package/src/engine/control/first-person/prototype_first_person_controller.js +1003 -901
- package/src/engine/physics/PLAN.md +943 -809
- package/src/engine/physics/body/BodyStorage.d.ts +9 -0
- package/src/engine/physics/body/BodyStorage.d.ts.map +1 -1
- package/src/engine/physics/body/BodyStorage.js +23 -0
- package/src/engine/physics/broadphase/generate_pairs.d.ts.map +1 -1
- package/src/engine/physics/broadphase/generate_pairs.js +7 -0
- package/src/engine/physics/ccd/linear_sweep.d.ts +97 -0
- package/src/engine/physics/ccd/linear_sweep.d.ts.map +1 -0
- package/src/engine/physics/ccd/linear_sweep.js +238 -0
- package/src/engine/physics/ecs/PhysicsSystem.d.ts +18 -3
- package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
- package/src/engine/physics/ecs/PhysicsSystem.js +59 -8
- package/src/engine/physics/ecs/RigidBodyFlags.d.ts +6 -0
- package/src/engine/physics/ecs/RigidBodyFlags.d.ts.map +1 -1
- package/src/engine/physics/ecs/RigidBodyFlags.js +6 -0
- package/src/engine/physics/narrowphase/box_triangle_contact.js +811 -811
- package/src/engine/physics/narrowphase/compute_penetration.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/compute_penetration.js +325 -323
- package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts +27 -8
- package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.js +235 -204
- package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/narrowphase_step.js +70 -13
- package/src/engine/physics/queries/overlap_shape.d.ts.map +1 -1
- package/src/engine/physics/queries/overlap_shape.js +185 -183
- package/src/engine/simulation/Ticker.d.ts +14 -0
- package/src/engine/simulation/Ticker.d.ts.map +1 -1
- 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
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
// (
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
*
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
ray
|
|
294
|
-
ray.
|
|
295
|
-
ray.
|
|
296
|
-
|
|
297
|
-
if (this.
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
ray
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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 move — no 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 height — a 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
|
+
}
|