@gg-web-engine/matter 0.0.72 → 0.0.73

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.
@@ -0,0 +1,498 @@
1
+ import { BitMask, DebugBody2DSettings, Pnt2, warnOnce, } from '@gg-web-engine/core';
2
+ import { Bodies, Body, Composite, Detector, Query, Vector } from 'matter-js';
3
+ import { MatterRigidBodyComponent } from './matter-rigid-body.component';
4
+ import { MatterTriggerComponent } from './matter-trigger.component';
5
+ const DEFAULT_OPTIONS = {
6
+ offset: 0.01,
7
+ maxStepHeight: 0.3,
8
+ minStepWidth: 0.2,
9
+ maxSlopeClimbAngleRad: (50 * Math.PI) / 180,
10
+ snapToGroundDistance: 0.3,
11
+ up: Pnt2.nY,
12
+ interactWithCollisionGroups: 'all',
13
+ pushMass: 80,
14
+ };
15
+ /**
16
+ * A capsule-shaped kinematic character controller implemented as a direct discrete-query
17
+ * sweep-and-slide mover, driven entirely by this class's own `move()` - the 2D counterpart of a
18
+ * from-scratch native-engine character mover (see `gg-engine-physics-adapter`'s own section on this
19
+ * general pattern).
20
+ *
21
+ * **Why this isn't built on `createRigidBody`/a `kinematic_pos` body**: matter-js has no native
22
+ * kinematic body concept at all (see `MatterFactory.transformOptions`'s doc) - a `kinematic_pos`/
23
+ * `kinematic_vel` request there falls back to a plain `isStatic: true` body, which never moves and
24
+ * never pushes/wakes anything. This component instead constructs its own `Matter.Body` directly (a
25
+ * capsule via `Bodies.rectangle` with a `chamfer`, matching `MatterFactory.createRigidBody`'s own
26
+ * `CAPSULE` case), marks it `isStatic: true` so matter's own `Engine.update` never touches it, and
27
+ * **never adds it to `Composite`/`engine.world` at all** - there is no need to, since every query this
28
+ * class issues (`Matter.Query.collides`) is run directly against this body and an explicit list of
29
+ * other bodies, not through matter's own broadphase/`Engine.update` pipeline. This also means
30
+ * matter-js's own per-engine `collisionFilter`-aware broadphase (`Detector.canCollide`) never runs for
31
+ * this body either - `collectObstacles()` below replicates that exact category/mask check by hand
32
+ * before ever calling `Query.collides`, since that function tests raw geometry with no collision-group
33
+ * awareness of its own.
34
+ *
35
+ * **Why movement is substep-marched rather than a single discrete overlap test at the final
36
+ * position**: matter-js has no continuous collision detection at all (see `MatterFactory
37
+ * .transformOptions`'s own `ccd` note) and `Matter.Query.collides`/`Collision.collides` are purely
38
+ * discrete overlap tests at whatever transform a body currently has - matter-js exposes no
39
+ * swept/time-of-impact query to call instead. A
40
+ * single test-then-clamp at the fully-displaced candidate position would tunnel clean through any
41
+ * obstacle thinner than the requested displacement (a large single-tick `move()` call, or a thin wall,
42
+ * would simply never register contact at all). `marchMove` compensates by subdividing the requested
43
+ * delta into substeps no longer than `min(radius, 0.1)` and re-querying after each one, stopping at the
44
+ * first substep that would overlap something - a standard workaround for discrete-only collision
45
+ * detection, and the direct 2D analog of what a sweep primitive gives other backends for free.
46
+ *
47
+ * **Collision normal convention**: `Matter.Collision`'s own `collision.bodyA`/`collision.bodyB` (and
48
+ * `parentA`/`parentB`) are reassigned by ascending `Body.id`, not by the order two bodies were passed
49
+ * into `Collision.collides`/`Query.collides` - and the final `collision.normal` is oriented so that
50
+ * `dot(normal, bodyB.position - bodyA.position) <= 0` always holds (verified empirically against
51
+ * `Collision.js`'s own flip check; its inline comment claims the opposite, "facing away from bodyA",
52
+ * which does not match the code - see `gg-engine-physics-adapter-matter`'s own note on this same
53
+ * gotcha for the engine-wide `collisionStart`/`collisionEnd` event wiring). Concretely this means the
54
+ * final normal always points *towards* `collision.bodyA`/`parentA`, away from `bodyB`/`parentB`,
55
+ * regardless of which side of the original `Query.collides(body, bodies)` call each one came from -
56
+ * `normalTowardCharacter` below re-derives a consistent "points away from the obstacle, towards this
57
+ * character" direction from that by comparing `parentA`/`parentB` against this character's own native
58
+ * body, not by assuming a fixed argument order.
59
+ *
60
+ * **Ground overlap for `Trigger2dEntity`**: since this character's phantom body is deliberately never
61
+ * added to matter's own `Composite`, matter's native `collisionStart`/`collisionEnd` engine events
62
+ * (what `MatterTriggerComponent`'s own enter/exit detection is normally driven by) can never fire for
63
+ * it - there is no pair for the engine to ever notice. `MatterTriggerComponent.checkOverlaps()`
64
+ * (already called once per tick by `Trigger2dEntity`, previously a no-op for matter-js since ordinary
65
+ * rigid-body overlaps are handled by those native events instead) now *additionally* polls every
66
+ * `MatterCharacterControllerComponent` currently in the world via `Query.collides` each time it's
67
+ * called, entirely independently of the native event path - this was the natural fit given
68
+ * `checkOverlaps()` already existed as a per-tick hook with nothing else needing it for matter-js,
69
+ * rather than inventing a second, differently-shaped mechanism.
70
+ *
71
+ * **Colliding with other character controllers**: `collectObstacles()` below includes every other
72
+ * `MatterCharacterControllerComponent` currently in the world (found via `this.world.children`, not
73
+ * `Composite.allBodies`, for the same reason as the previous paragraph) alongside ordinary bodies -
74
+ * two characters block each other's movement the same way any other obstacle does.
75
+ *
76
+ * **Divergence from the interface's own options**: `minStepWidth` is accepted but not honored - see
77
+ * `applyStepAssist`'s own doc for why.
78
+ */
79
+ export class MatterCharacterControllerComponent {
80
+ get up() {
81
+ return this._up;
82
+ }
83
+ set up(value) {
84
+ this._up = Pnt2.norm(value);
85
+ }
86
+ get isGrounded() {
87
+ return this._isGrounded;
88
+ }
89
+ get groundNormal() {
90
+ return this._groundNormal;
91
+ }
92
+ get position() {
93
+ return Pnt2.clone(this.nativeBody.position);
94
+ }
95
+ set position(value) {
96
+ Body.setPosition(this.nativeBody, Vector.create(value.x, value.y));
97
+ }
98
+ get rotation() {
99
+ return this.nativeBody.angle;
100
+ }
101
+ set rotation(value) {
102
+ Body.setAngle(this.nativeBody, value);
103
+ }
104
+ get ownCollisionGroups() {
105
+ return BitMask.unpack(this._ownCGsMask, 16);
106
+ }
107
+ set ownCollisionGroups(value) {
108
+ this._ownCGsMask = value === 'all' ? BitMask.full(16) : BitMask.pack(value, 16);
109
+ this.updateCollisionFilter();
110
+ }
111
+ get interactWithCollisionGroups() {
112
+ return BitMask.unpack(this._interactWithCGsMask, 16);
113
+ }
114
+ set interactWithCollisionGroups(value) {
115
+ this._interactWithCGsMask = value === 'all' ? BitMask.full(16) : BitMask.pack(value, 16);
116
+ this.updateCollisionFilter();
117
+ }
118
+ updateCollisionFilter() {
119
+ this.nativeBody.collisionFilter = Object.assign(Object.assign({}, this.nativeBody.collisionFilter), { category: this._ownCGsMask, mask: this._interactWithCGsMask });
120
+ }
121
+ constructor(world, options, transform) {
122
+ var _a, _b;
123
+ this.world = world;
124
+ this.entity = null;
125
+ this.name = '';
126
+ this._isGrounded = false;
127
+ this._groundNormal = null;
128
+ /** See `ICharacterController2dComponent.ignoredBodies`'s doc. Consulted fresh by `collectObstacles`
129
+ * every `move()` call. */
130
+ this.ignoredBodies = new Set();
131
+ this._added = false;
132
+ this._ownCGsMask = BitMask.full(16);
133
+ this._interactWithCGsMask = BitMask.full(16);
134
+ this.options = Object.assign(Object.assign(Object.assign({}, DEFAULT_OPTIONS), { ownCollisionGroups: [world.mainCollisionGroup] }), options);
135
+ this.radius = options.radius;
136
+ this.centersDistance = options.centersDistance;
137
+ this._up = Pnt2.norm(this.options.up);
138
+ this.nativeBody = Bodies.rectangle(0, 0, options.radius * 2, options.centersDistance + options.radius * 2, {
139
+ isStatic: true,
140
+ chamfer: { radius: options.radius },
141
+ });
142
+ Body.setPosition(this.nativeBody, Vector.create(((_a = transform === null || transform === void 0 ? void 0 : transform.position) === null || _a === void 0 ? void 0 : _a.x) || 0, ((_b = transform === null || transform === void 0 ? void 0 : transform.position) === null || _b === void 0 ? void 0 : _b.y) || 0));
143
+ Body.setAngle(this.nativeBody, (transform === null || transform === void 0 ? void 0 : transform.rotation) || 0);
144
+ this.debugBodySettings = new DebugBody2DSettings(
145
+ // A kinematic character never truly "sleeps" the way a dynamic rigid body does - it's either
146
+ // being actively driven every tick or idle at rest; report it as always-awake.
147
+ { type: 'RIGID_DYNAMIC', sleeping: () => false }, { shape: 'CAPSULE', radius: this.radius, centersDistance: this.centersDistance });
148
+ this.ownCollisionGroups = this.options.ownCollisionGroups;
149
+ this.interactWithCollisionGroups = this.options.interactWithCollisionGroups;
150
+ }
151
+ /**
152
+ * Every other body currently in the world this character's own queries must consider - excludes
153
+ * sensors (triggers never physically block anything, see `ITrigger2dComponent`), everything in
154
+ * `ignoredBodies` (consulted fresh here, every call), and anything this character's own collision
155
+ * groups wouldn't interact with anyway (`Query.collides`/`Collision.collides` test raw geometry
156
+ * only and know nothing about `collisionFilter`, unlike matter's own `Detector` - so `canCollideWith`
157
+ * calls `Detector.canCollide` directly against this character's own `nativeBody.collisionFilter`,
158
+ * which the `ownCollisionGroups`/`interactWithCollisionGroups` setters keep in sync).
159
+ *
160
+ * Also includes every *other* `MatterCharacterControllerComponent` currently in the world, via its
161
+ * own phantom `nativeBody` - without this, two character controllers could freely overlap and pass
162
+ * straight through each other, since neither one's phantom body is ever added to
163
+ * `Composite`/`engine.world` (see this class's own doc) and so neither is ever a candidate for the
164
+ * other's queries through `Composite.allBodies` alone.
165
+ */
166
+ collectObstacles() {
167
+ const matterWorld = this.world.matterWorld;
168
+ if (!matterWorld) {
169
+ return [];
170
+ }
171
+ const ignoredNative = new Set();
172
+ for (const body of this.ignoredBodies) {
173
+ ignoredNative.add(body.nativeBody);
174
+ }
175
+ const worldBodies = Composite.allBodies(matterWorld).filter(b => !b.isSensor && !ignoredNative.has(b) && this.canCollideWith(b));
176
+ const otherCharacters = this.world.children
177
+ .filter((c) => c instanceof MatterCharacterControllerComponent && c !== this)
178
+ .map(c => c.nativeBody)
179
+ .filter(b => !ignoredNative.has(b) && this.canCollideWith(b));
180
+ return worldBodies.concat(otherCharacters);
181
+ }
182
+ canCollideWith(other) {
183
+ return Detector.canCollide(this.nativeBody.collisionFilter, other.collisionFilter);
184
+ }
185
+ /** See this class's own doc for the sign convention this re-derives (`parentA`/`parentB`, not
186
+ * calling-argument order). Returns a normal pointing away from the obstacle, towards this
187
+ * character. */
188
+ normalTowardCharacter(collision) {
189
+ const normal = Pnt2.clone(collision.normal);
190
+ return collision.parentA === this.nativeBody ? normal : Pnt2.neg(normal);
191
+ }
192
+ otherParent(collision) {
193
+ return collision.parentA === this.nativeBody ? collision.parentB : collision.parentA;
194
+ }
195
+ isWalkable(normal, up) {
196
+ return Pnt2.angle(normal, up) <= this.options.maxSlopeClimbAngleRad;
197
+ }
198
+ /**
199
+ * Pushes this character's phantom body out of any obstacle it currently overlaps at `pos`, via
200
+ * `Query.collides`, iterating a few times since resolving one contact can reveal/deepen another -
201
+ * a discrete query like this one can start a tick already embedded (e.g. from a previous tick's
202
+ * rounding/clamping). Must run before any marching this tick.
203
+ */
204
+ recoverFromPenetration(pos, obstacles) {
205
+ let corrected = pos;
206
+ for (let i = 0; i < 4; i++) {
207
+ Body.setPosition(this.nativeBody, corrected);
208
+ const collisions = Query.collides(this.nativeBody, obstacles);
209
+ let worstDepth = 0;
210
+ let worstNormal = null;
211
+ for (const collision of collisions) {
212
+ if (collision.depth > worstDepth) {
213
+ worstDepth = collision.depth;
214
+ worstNormal = this.normalTowardCharacter(collision);
215
+ }
216
+ }
217
+ if (!worstNormal) {
218
+ break;
219
+ }
220
+ corrected = Pnt2.add(corrected, Pnt2.scalarMult(worstNormal, worstDepth));
221
+ }
222
+ return corrected;
223
+ }
224
+ /**
225
+ * Marches this character's phantom body from `start` towards `start + delta` in small substeps
226
+ * (see this class's own doc for why substepping is needed at all, in place of a true sweep), moving
227
+ * the native body via `Body.setPosition` after every accepted substep, and stopping at the first
228
+ * substep whose query finds an obstacle with a meaningful component opposing the direction of
229
+ * travel. `overlappingBodies` in the result always reflects the query at wherever this call
230
+ * finished (the fully-displaced position if never blocked, or the last-accepted position if it
231
+ * was) - used by `move()` to find dynamic bodies to push.
232
+ */
233
+ marchMove(start, delta, obstacles) {
234
+ const totalLen = Pnt2.len(delta);
235
+ if (totalLen <= 1e-9) {
236
+ Body.setPosition(this.nativeBody, start);
237
+ return { pos: start, blocked: false, normal: null, overlappingBodies: [] };
238
+ }
239
+ const dir = Pnt2.scalarMult(delta, 1 / totalLen);
240
+ const maxSubstep = Math.max(Math.min(this.radius, 0.1), 1e-3);
241
+ const steps = Math.min(1024, Math.max(1, Math.ceil(totalLen / maxSubstep)));
242
+ const stepLen = totalLen / steps;
243
+ const stepVec = Pnt2.scalarMult(dir, stepLen);
244
+ let pos = start;
245
+ for (let i = 0; i < steps; i++) {
246
+ const candidate = Pnt2.add(pos, stepVec);
247
+ Body.setPosition(this.nativeBody, candidate);
248
+ const collisions = Query.collides(this.nativeBody, obstacles);
249
+ let blockingNormal = null;
250
+ let blockingDepth = 0;
251
+ let blockingOpposition = 1e-4;
252
+ const overlappingBodies = [];
253
+ for (const collision of collisions) {
254
+ overlappingBodies.push(this.otherParent(collision));
255
+ const normal = this.normalTowardCharacter(collision);
256
+ const opposition = -Pnt2.dot(normal, dir);
257
+ if (opposition > blockingOpposition) {
258
+ blockingOpposition = opposition;
259
+ blockingNormal = normal;
260
+ blockingDepth = collision.depth;
261
+ }
262
+ }
263
+ if (blockingNormal) {
264
+ // Rather than simply reverting the whole (up to `maxSubstep`-long) substep - which would
265
+ // only ever be as precise as the substep length itself, up to ~0.1 units short of the real
266
+ // surface - push the phantom body back out of `candidate` along the blocking contact's own
267
+ // normal by its exact overlap depth (the same technique `recoverFromPenetration` uses),
268
+ // landing it right at the true contact surface regardless of substep granularity.
269
+ // `this.options.offset` on top of that is the small skin gap `CharacterController2dOptions
270
+ // .offset`'s own doc describes ("keep the underlying sweep test numerically stable") - without
271
+ // it, this would land at *exactly* zero-gap contact, which is one bad floating-point rounding
272
+ // away from a spurious re-penetration on the very next tick's query.
273
+ const corrected = Pnt2.add(candidate, Pnt2.scalarMult(blockingNormal, blockingDepth + this.options.offset));
274
+ Body.setPosition(this.nativeBody, corrected);
275
+ return { pos: corrected, blocked: true, normal: blockingNormal, overlappingBodies };
276
+ }
277
+ pos = candidate;
278
+ }
279
+ return { pos, blocked: false, normal: null, overlappingBodies: [] };
280
+ }
281
+ /** Like `marchMove`, but always leaves the native body at `start` before returning - for a
282
+ * speculative query (step-up assist, ground snap/landing checks) that must not commit any movement
283
+ * unless the caller explicitly applies the returned position itself. */
284
+ probe(start, delta, obstacles) {
285
+ const result = this.marchMove(start, delta, obstacles);
286
+ Body.setPosition(this.nativeBody, start);
287
+ return result;
288
+ }
289
+ /**
290
+ * If the horizontal leg was blocked, tries lifting the phantom body up by up to `maxStepHeight`,
291
+ * retrying the same horizontal move at that height, and probing back down - only accepting the step
292
+ * if it both clears more horizontal distance than the unraised attempt *and* actually lands on
293
+ * walkable ground, not just a curved/vertical surface that happens to allow more clearance a hair
294
+ * higher up (see the general `gg-engine-physics-adapter` skill's own caution on this exact
295
+ * failure mode).
296
+ *
297
+ * **`minStepWidth` is not honored** - a step is accepted purely on `maxStepHeight`/walkability,
298
+ * with no separate check for how much free space sits on top of the ledge. An attempt at that
299
+ * check (probing forward from the landing spot by `minStepWidth` and requiring the ledge to still
300
+ * be walkable there) was tried and reverted: this mover's own step-up sequence routinely *accepts*
301
+ * a landing spot that is itself only a marginal, partial advance still snug against the same
302
+ * obstacle corner that blocked the original horizontal move (`marchMove`'s substep-and-slide
303
+ * approach, see this class's own doc, naturally creeps forward across several ticks rather than
304
+ * clearing a corner in one) - a width probe from a landing spot like that immediately re-hits the
305
+ * same corner and rejects the step outright, which stalls the character completely instead of
306
+ * letting it creep across a perfectly normal ledge over the next few ticks. `minStepWidth` is
307
+ * still accepted into this class's own options (see `CharacterController2dOptions.minStepWidth`'s
308
+ * own doc: "not every backend can honor this exactly").
309
+ */
310
+ applyStepAssist(start, initial, horizontal, up, obstacles) {
311
+ if (!initial.blocked || this.options.maxStepHeight <= 0) {
312
+ Body.setPosition(this.nativeBody, initial.pos);
313
+ return initial.pos;
314
+ }
315
+ const maxStep = this.options.maxStepHeight;
316
+ const upProbe = this.probe(start, Pnt2.scalarMult(up, maxStep), obstacles);
317
+ const raisedAmount = Pnt2.dot(Pnt2.sub(upProbe.pos, start), up);
318
+ if (raisedAmount <= 1e-6) {
319
+ Body.setPosition(this.nativeBody, initial.pos);
320
+ return initial.pos;
321
+ }
322
+ const retryProbe = this.probe(upProbe.pos, horizontal, obstacles);
323
+ const originalTravel = Pnt2.len(Pnt2.sub(initial.pos, start));
324
+ const retryTravel = Pnt2.len(Pnt2.sub(retryProbe.pos, upProbe.pos));
325
+ if (retryTravel <= originalTravel + 1e-6) {
326
+ Body.setPosition(this.nativeBody, initial.pos);
327
+ return initial.pos;
328
+ }
329
+ const landingProbe = this.probe(retryProbe.pos, Pnt2.scalarMult(up, -raisedAmount), obstacles);
330
+ if (landingProbe.blocked && landingProbe.normal && this.isWalkable(landingProbe.normal, up)) {
331
+ Body.setPosition(this.nativeBody, landingProbe.pos);
332
+ return landingProbe.pos;
333
+ }
334
+ Body.setPosition(this.nativeBody, initial.pos);
335
+ return initial.pos;
336
+ }
337
+ /**
338
+ * Shoves a dynamic body the horizontal leg bumped into this tick - see
339
+ * `CharacterController2dOptions.pushMass`'s doc for the formula (an inelastic collision against a
340
+ * virtual mass moving at the character's own speed), applied here through
341
+ * `MatterRigidBodyComponent.linearVelocity`'s existing getter/setter (which already carries the
342
+ * conversion between matter-js's internal per-step velocity units and this engine's public m/s
343
+ * units - see `MATTER_VELOCITY_SCALE`'s own doc) rather than touching `Body.velocity` directly.
344
+ */
345
+ pushDynamicBodies(bodies, direction, horizLen, dt) {
346
+ const pushMass = this.options.pushMass;
347
+ if (pushMass <= 0 || bodies.length === 0) {
348
+ return;
349
+ }
350
+ if (!dt || dt <= 1e-9) {
351
+ warnOnce('[MatterCharacterControllerComponent] move() was called without `dt` while `pushMass` > 0 - ' +
352
+ 'skipping this dynamic-body push rather than approximating character speed from raw ' +
353
+ 'per-tick displacement (which would understate push force by roughly 1/dt). Pass the real ' +
354
+ 'tick delta (seconds) as the third argument to move() to enable pushing dynamic bodies.');
355
+ return;
356
+ }
357
+ const characterSpeed = horizLen / dt;
358
+ const seen = new Set();
359
+ for (const nativeBody of bodies) {
360
+ if (seen.has(nativeBody.id)) {
361
+ continue;
362
+ }
363
+ seen.add(nativeBody.id);
364
+ const comp = this.world.handleIdEntityMap.get(nativeBody.id);
365
+ if (!(comp instanceof MatterRigidBodyComponent) || comp instanceof MatterTriggerComponent) {
366
+ continue;
367
+ }
368
+ const bodyMass = comp.nativeBody.mass;
369
+ if (!isFinite(bodyMass) || bodyMass <= 0) {
370
+ continue;
371
+ }
372
+ const pushSpeed = characterSpeed * (pushMass / (pushMass + bodyMass));
373
+ const v = comp.linearVelocity;
374
+ const currentAlong = Pnt2.dot(v, direction);
375
+ if (pushSpeed <= currentAlong) {
376
+ continue;
377
+ }
378
+ const deltaSpeed = pushSpeed - currentAlong;
379
+ comp.linearVelocity = Pnt2.add(v, Pnt2.scalarMult(direction, deltaSpeed));
380
+ }
381
+ }
382
+ /**
383
+ * Resolves `desiredTranslation` fully synchronously (see the interface's own doc) via
384
+ * `recoverFromPenetration` + two axis-separated `marchMove` legs (horizontal, then vertical) +
385
+ * a ground-snap probe - see this class's own doc for the overall approach and why each piece is
386
+ * needed.
387
+ */
388
+ move(desiredTranslation, dt) {
389
+ if (!this._added) {
390
+ return;
391
+ }
392
+ const up = this._up;
393
+ const obstacles = this.collectObstacles();
394
+ let pos = this.recoverFromPenetration(this.position, obstacles);
395
+ const vertical = Pnt2.scalarMult(up, Pnt2.dot(desiredTranslation, up));
396
+ const horizontal = Pnt2.sub(desiredTranslation, vertical);
397
+ const horizLen = Pnt2.len(horizontal);
398
+ let pushBodies = [];
399
+ if (horizLen > 1e-9) {
400
+ const initial = this.marchMove(pos, horizontal, obstacles);
401
+ pushBodies = initial.overlappingBodies;
402
+ pos = this.applyStepAssist(pos, initial, horizontal, up, obstacles);
403
+ }
404
+ else {
405
+ Body.setPosition(this.nativeBody, pos);
406
+ }
407
+ // Moving strictly upward (a jump/rising through the air) must never be pulled back down by the
408
+ // ground-snap fallback below - it exists to hug the ground while falling/standing still, not to
409
+ // cancel out a deliberate upward move (this tick's own small rise is well within
410
+ // `snapToGroundDistance` of the floor the character just left, otherwise every jump's takeoff
411
+ // would be undone the very next tick - see the general `gg-engine-physics-adapter` skill's own
412
+ // section on this pitfall).
413
+ const movingUp = Pnt2.dot(vertical, up) > 1e-9;
414
+ let grounded = false;
415
+ let groundNormal = null;
416
+ const vertLen = Pnt2.len(vertical);
417
+ if (vertLen > 1e-9) {
418
+ const vStart = pos;
419
+ const vResult = this.marchMove(pos, vertical, obstacles);
420
+ pos = vResult.pos;
421
+ if (vResult.blocked && vResult.normal) {
422
+ if (!movingUp && this.isWalkable(vResult.normal, up)) {
423
+ grounded = true;
424
+ groundNormal = vResult.normal;
425
+ }
426
+ else {
427
+ // Blocked by something that isn't walkable ground underfoot (a ceiling while ascending, or
428
+ // a too-steep slope pressed straight into) - slide the remaining vertical distance along
429
+ // the hit surface's tangent instead of leaving the character stuck jittering at the same
430
+ // blocked point every tick.
431
+ const travelledAlongUp = Pnt2.dot(Pnt2.sub(pos, vStart), up);
432
+ const remainingAlongUp = Pnt2.dot(vertical, up) - travelledAlongUp;
433
+ if (Math.abs(remainingAlongUp) > 1e-9) {
434
+ const remaining = Pnt2.scalarMult(up, remainingAlongUp);
435
+ const n = vResult.normal;
436
+ const tangentRemaining = Pnt2.sub(remaining, Pnt2.scalarMult(n, Pnt2.dot(remaining, n)));
437
+ if (Pnt2.len(tangentRemaining) > 1e-9) {
438
+ const slideResult = this.marchMove(pos, tangentRemaining, obstacles);
439
+ pos = slideResult.pos;
440
+ }
441
+ }
442
+ }
443
+ }
444
+ }
445
+ else {
446
+ Body.setPosition(this.nativeBody, pos);
447
+ }
448
+ if (!grounded && !movingUp && this.options.snapToGroundDistance > 0) {
449
+ const probeResult = this.probe(pos, Pnt2.scalarMult(up, -this.options.snapToGroundDistance), obstacles);
450
+ if (probeResult.blocked && probeResult.normal && this.isWalkable(probeResult.normal, up)) {
451
+ pos = probeResult.pos;
452
+ grounded = true;
453
+ groundNormal = probeResult.normal;
454
+ }
455
+ }
456
+ Body.setPosition(this.nativeBody, pos);
457
+ this._isGrounded = grounded;
458
+ this._groundNormal = groundNormal;
459
+ if (horizLen > 1e-9) {
460
+ this.pushDynamicBodies(pushBodies, Pnt2.scalarMult(horizontal, 1 / horizLen), horizLen, dt);
461
+ }
462
+ }
463
+ clone() {
464
+ // Reads the CURRENT position/rotation, and the current `up`/`ownCollisionGroups`/
465
+ // `interactWithCollisionGroups` - not whatever `this.options` was frozen to at construction
466
+ // time. All five go stale the instant this component is added to a world and starts being
467
+ // driven directly by `move()`/their own live setters (`this.options` itself is never touched
468
+ // again after the constructor runs).
469
+ return new MatterCharacterControllerComponent(this.world, Object.assign(Object.assign({}, this.options), { up: this.up, ownCollisionGroups: this.ownCollisionGroups, interactWithCollisionGroups: this.interactWithCollisionGroups }), {
470
+ position: this.position,
471
+ rotation: this.rotation,
472
+ });
473
+ }
474
+ addToWorld(world) {
475
+ if (world.physicsWorld != this.world) {
476
+ throw new Error('Matter bodies cannot be shared between different worlds');
477
+ }
478
+ this._added = true;
479
+ this.world.added$.next(this);
480
+ }
481
+ removeFromWorld(world, dispose = false) {
482
+ if (world.physicsWorld != this.world) {
483
+ throw new Error('Matter bodies cannot be shared between different worlds');
484
+ }
485
+ this._added = false;
486
+ this.world.removed$.next(this);
487
+ if (dispose) {
488
+ this.dispose();
489
+ }
490
+ }
491
+ // Like `MatterRigidBodyComponent.dispose()`, this phantom body is a plain, GC-managed JS object
492
+ // that was never added to `Composite`/`engine.world` at all - there is no native handle to free and
493
+ // no RxJS subject of its own to complete (unlike a rigid body/trigger, this interface exposes no
494
+ // collision-event stream).
495
+ dispose() {
496
+ // nothing to free
497
+ }
498
+ }
@@ -1,10 +1,12 @@
1
- import { CollisionEvent, CollisionGroup, DebugBody2DSettings, Entity2d, IRigidBody2dComponent, Point2, Shape2DDescriptor } from '@gg-web-engine/core';
1
+ import { BodyOptions, BodyType, CollisionEvent, CollisionGroup, DebugBody2DSettings, Entity2d, IRigidBody2dComponent, Point2, Shape2DDescriptor } from '@gg-web-engine/core';
2
2
  import { Body } from 'matter-js';
3
3
  import { Observable, Subject } from 'rxjs';
4
4
  import { MatterGgWorld, MatterPhysicsTypeDocRepo } from '../types';
5
5
  export declare class MatterRigidBodyComponent implements IRigidBody2dComponent<MatterPhysicsTypeDocRepo> {
6
6
  nativeBody: Body;
7
7
  readonly shape: Shape2DDescriptor;
8
+ readonly bodyType: BodyType;
9
+ readonly ccd: boolean;
8
10
  get position(): Point2;
9
11
  set position(value: Point2);
10
12
  get rotation(): number;
@@ -16,6 +18,19 @@ export declare class MatterRigidBodyComponent implements IRigidBody2dComponent<M
16
18
  name: string;
17
19
  entity: Entity2d | null;
18
20
  readonly debugBodySettings: DebugBody2DSettings;
21
+ /**
22
+ * See `IRigidBodyComponent.bodyOptions`'s own doc. `mass`/`friction`/`restitution` are read live
23
+ * off the native matter-js body (a plain JS object - `.mass`/`.friction`/`.restitution` are
24
+ * ordinary fields, resolved to matter-js's own defaults by `Body.create` for whichever of them
25
+ * weren't explicitly given, so this reflects the body's *actual* resolved values, not just
26
+ * whatever was requested). `bodyType`/`ccd` are stored as originally requested rather than
27
+ * derived from the native body: matter-js has no kinematic body concept at all (a requested
28
+ * `kinematic_pos`/`kinematic_vel` degrades to a plain `isStatic` body - see `MatterFactory
29
+ * .transformOptions`'s own doc) and no CCD, so neither is recoverable from - or even meaningfully
30
+ * "live" on - the native body itself; echoing the request instead keeps a level JSON reloaded
31
+ * under a different, kinematic/CCD-capable adapter faithful to what was actually asked for.
32
+ */
33
+ get bodyOptions(): Readonly<BodyOptions>;
19
34
  protected _interactWithCGsMask: number;
20
35
  protected _ownCGsMask: number;
21
36
  get onCollisionStart(): Observable<CollisionEvent<Point2, MatterRigidBodyComponent>>;
@@ -28,7 +43,7 @@ export declare class MatterRigidBodyComponent implements IRigidBody2dComponent<M
28
43
  * `notifyCollisionStart`/`notifyCollisionEnd`, called by `MatterWorldComponent`'s single
29
44
  * world-wide `collisionStart`/`collisionEnd` listener - not touched directly by anything else. */
30
45
  protected readonly currentContacts: Set<MatterRigidBodyComponent>;
31
- constructor(nativeBody: Body, shape: Shape2DDescriptor);
46
+ constructor(nativeBody: Body, shape: Shape2DDescriptor, bodyType?: BodyType, ccd?: boolean);
32
47
  /** @internal called by `MatterWorldComponent`'s global `collisionStart` listener - not part of
33
48
  * this component's public API surface. */
34
49
  notifyCollisionStart(collisionEvent: CollisionEvent<Point2, MatterRigidBodyComponent>): void;
@@ -28,15 +28,40 @@ export class MatterRigidBodyComponent {
28
28
  set angularVelocity(value) {
29
29
  Body.setAngularVelocity(this.nativeBody, value);
30
30
  }
31
+ /**
32
+ * See `IRigidBodyComponent.bodyOptions`'s own doc. `mass`/`friction`/`restitution` are read live
33
+ * off the native matter-js body (a plain JS object - `.mass`/`.friction`/`.restitution` are
34
+ * ordinary fields, resolved to matter-js's own defaults by `Body.create` for whichever of them
35
+ * weren't explicitly given, so this reflects the body's *actual* resolved values, not just
36
+ * whatever was requested). `bodyType`/`ccd` are stored as originally requested rather than
37
+ * derived from the native body: matter-js has no kinematic body concept at all (a requested
38
+ * `kinematic_pos`/`kinematic_vel` degrades to a plain `isStatic` body - see `MatterFactory
39
+ * .transformOptions`'s own doc) and no CCD, so neither is recoverable from - or even meaningfully
40
+ * "live" on - the native body itself; echoing the request instead keeps a level JSON reloaded
41
+ * under a different, kinematic/CCD-capable adapter faithful to what was actually asked for.
42
+ */
43
+ get bodyOptions() {
44
+ return {
45
+ bodyType: this.bodyType,
46
+ mass: this.nativeBody.mass,
47
+ friction: this.nativeBody.friction,
48
+ restitution: this.nativeBody.restitution,
49
+ ccd: this.ccd,
50
+ ownCollisionGroups: this.ownCollisionGroups,
51
+ interactWithCollisionGroups: this.interactWithCollisionGroups,
52
+ };
53
+ }
31
54
  get onCollisionStart() {
32
55
  return this.onCollisionStart$.asObservable();
33
56
  }
34
57
  get onCollisionEnd() {
35
58
  return this.onCollisionEnd$.asObservable();
36
59
  }
37
- constructor(nativeBody, shape) {
60
+ constructor(nativeBody, shape, bodyType = 'dynamic', ccd = false) {
38
61
  this.nativeBody = nativeBody;
39
62
  this.shape = shape;
63
+ this.bodyType = bodyType;
64
+ this.ccd = ccd;
40
65
  this.name = '';
41
66
  this.entity = null;
42
67
  // matter-js has no native kinematic body - a body requested as `kinematic_pos`/`kinematic_vel`
@@ -110,7 +135,7 @@ export class MatterRigidBodyComponent {
110
135
  }
111
136
  clone() {
112
137
  const clonedBody = Body.create(Object.assign(Object.assign({}, this.nativeBody), { collisionFilter: Object.assign({}, this.nativeBody.collisionFilter) }));
113
- const component = new MatterRigidBodyComponent(clonedBody, this.shape);
138
+ const component = new MatterRigidBodyComponent(clonedBody, this.shape, this.bodyType, this.ccd);
114
139
  component.ownCollisionGroups = this.ownCollisionGroups;
115
140
  component.interactWithCollisionGroups = this.interactWithCollisionGroups;
116
141
  return component;