@gg-web-engine/matter 0.0.71 → 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.
@@ -1,19 +1,47 @@
1
1
  import { Observable, Subject } from 'rxjs';
2
2
  import { Body } from 'matter-js';
3
3
  import { MatterRigidBodyComponent } from './matter-rigid-body.component';
4
+ import { MatterCharacterControllerComponent } from './matter-character-controller.component';
4
5
  import { DebugBody2DSettings, ITrigger2dComponent, Shape2DDescriptor } from '@gg-web-engine/core';
5
6
  import { MatterWorldComponent } from './matter-world.component';
6
7
  import { MatterGgWorld, MatterPhysicsTypeDocRepo } from '../types';
7
8
  export declare class MatterTriggerComponent extends MatterRigidBodyComponent implements ITrigger2dComponent<MatterPhysicsTypeDocRepo> {
8
9
  readonly shape: Shape2DDescriptor;
9
10
  protected readonly world: MatterWorldComponent;
10
- get onEntityEntered(): Observable<MatterRigidBodyComponent>;
11
- get onEntityLeft(): Observable<MatterRigidBodyComponent>;
12
- protected readonly onEnter$: Subject<MatterRigidBodyComponent>;
13
- protected readonly onLeft$: Subject<MatterRigidBodyComponent>;
11
+ get onEntityEntered(): Observable<MatterRigidBodyComponent | MatterCharacterControllerComponent>;
12
+ get onEntityLeft(): Observable<MatterRigidBodyComponent | MatterCharacterControllerComponent>;
13
+ protected readonly onEnter$: Subject<MatterRigidBodyComponent | MatterCharacterControllerComponent>;
14
+ protected readonly onLeft$: Subject<MatterRigidBodyComponent | MatterCharacterControllerComponent>;
15
+ /** Character controllers currently overlapping this trigger, as of the last `checkOverlaps()`
16
+ * poll - see that method's own doc for why this needs its own separate polling mechanism instead
17
+ * of the native `collisionStart`/`collisionEnd` events `handleCollisionStart`/`handleCollisionEnd`
18
+ * below rely on for ordinary rigid bodies. */
19
+ protected currentCharacterOverlaps: Set<MatterCharacterControllerComponent>;
14
20
  readonly debugBodySettings: DebugBody2DSettings;
15
21
  protected intersectionsAmount: number;
16
22
  protected currentOverlaps: Set<MatterRigidBodyComponent>;
23
+ /** `Composite.remove` (what `removeFromWorld` calls) never fires a native `collisionEnd` for the
24
+ * body it removes - matter-js simply stops considering that body's pairs on the next step, it
25
+ * doesn't retroactively report the pairs that were active at removal time. Without this, a body
26
+ * removed from the world while still overlapping this trigger would leave `currentOverlaps`
27
+ * (and, for a character, `currentCharacterOverlaps`) permanently stale and `onEntityLeft` would
28
+ * never fire for it. `world.physicsWorld.removed$` fires for every component removal regardless
29
+ * of overlap state, so this only actually acts when the removed component is one this trigger was
30
+ * still tracking as an overlap.
31
+ *
32
+ * The stale entry is dropped synchronously, but `onLeft$` itself is only ever emitted on a
33
+ * microtask from this reaction, never inline. `removed$` fires from inside whatever call stack
34
+ * performed the removal - which can be another component's own in-progress lifecycle operation:
35
+ * `CharacterController2dEntity.recreateCapsule()` (crouch/stand) removes the *old*
36
+ * `characterController` with `dispose: true` before it has assigned the new one, so an `onLeft$`
37
+ * subscriber running inline there (app code driven by `Trigger2dEntity`, e.g. a kill volume
38
+ * resetting `.position` on the character that just left it) would write through the old,
39
+ * already-removed `MatterCharacterControllerComponent` - `Body.setPosition` on a body no longer
40
+ * queried by anything - and the write is silently lost, with the replacement capsule then spawning
41
+ * at the stale position. Deferring the emission keeps it within the same JS turn (well before the
42
+ * next `Engine.update`) but strictly after every synchronous call stack in flight, including the
43
+ * removal's own, has unwound. */
44
+ private removedSub?;
17
45
  private handleCollisionStart;
18
46
  private handleCollisionEnd;
19
47
  constructor(nativeBody: Body, shape: Shape2DDescriptor, world: MatterWorldComponent);
@@ -23,6 +51,20 @@ export declare class MatterTriggerComponent extends MatterRigidBodyComponent imp
23
51
  * `onCollisionStart$`/`onCollisionEnd$` completion (via `super.dispose()`) - this trigger's own
24
52
  * enter/exit subjects are a separate pair this subclass owns and must complete itself. */
25
53
  dispose(): void;
54
+ /**
55
+ * Regular rigid-body overlaps are handled entirely by `handleCollisionStart`/`handleCollisionEnd`
56
+ * above, off matter's own native `collisionStart`/`collisionEnd` engine events - so this used to be
57
+ * a pure no-op for matter-js. A `MatterCharacterControllerComponent`'s own phantom body is
58
+ * deliberately never added to `Composite`/`engine.world` at all (see that class's own doc), so no
59
+ * native collision pair - and thus no native event - can ever involve it. Since `checkOverlaps()` is
60
+ * already called once per tick by `Trigger2dEntity` regardless of backend, this is the natural place
61
+ * to add the poll this needs instead of inventing a second, differently-shaped mechanism: every
62
+ * `MatterCharacterControllerComponent` currently in the world (`world.children`, which - unlike
63
+ * matter's own `Composite` - already tracks it) is tested against this trigger's own body via
64
+ * `Query.collides`, diffed against `currentCharacterOverlaps` to fire `onEntityEntered`/
65
+ * `onEntityLeft` exactly on the enter/exit transitions, the same as the native-event path does for
66
+ * ordinary bodies.
67
+ */
26
68
  checkOverlaps(): void;
27
69
  clone(): MatterTriggerComponent;
28
70
  }
@@ -1,6 +1,7 @@
1
1
  import { map, merge, Subject } from 'rxjs';
2
- import { Body, Events } from 'matter-js';
2
+ import { Body, Detector, Events, Query } from 'matter-js';
3
3
  import { MatterRigidBodyComponent } from './matter-rigid-body.component';
4
+ import { MatterCharacterControllerComponent } from './matter-character-controller.component';
4
5
  import { DebugBody2DSettings } from '@gg-web-engine/core';
5
6
  export class MatterTriggerComponent extends MatterRigidBodyComponent {
6
7
  get onEntityEntered() {
@@ -20,7 +21,8 @@ export class MatterTriggerComponent extends MatterRigidBodyComponent {
20
21
  }
21
22
  if (body) {
22
23
  let comp = this.world.children.find(c => c.nativeBody === body);
23
- if (comp) {
24
+ if (comp instanceof MatterRigidBodyComponent) {
25
+ this.currentOverlaps.add(comp);
24
26
  this.onEnter$.next(comp);
25
27
  }
26
28
  }
@@ -37,7 +39,8 @@ export class MatterTriggerComponent extends MatterRigidBodyComponent {
37
39
  }
38
40
  if (body) {
39
41
  let comp = this.world.children.find(c => c.nativeBody === body);
40
- if (comp) {
42
+ if (comp instanceof MatterRigidBodyComponent) {
43
+ this.currentOverlaps.delete(comp);
41
44
  this.onLeft$.next(comp);
42
45
  }
43
46
  }
@@ -49,6 +52,11 @@ export class MatterTriggerComponent extends MatterRigidBodyComponent {
49
52
  this.world = world;
50
53
  this.onEnter$ = new Subject();
51
54
  this.onLeft$ = new Subject();
55
+ /** Character controllers currently overlapping this trigger, as of the last `checkOverlaps()`
56
+ * poll - see that method's own doc for why this needs its own separate polling mechanism instead
57
+ * of the native `collisionStart`/`collisionEnd` events `handleCollisionStart`/`handleCollisionEnd`
58
+ * below rely on for ordinary rigid bodies. */
59
+ this.currentCharacterOverlaps = new Set();
52
60
  this.debugBodySettings = new DebugBody2DSettings({ type: 'TRIGGER', activated: () => this.intersectionsAmount > 0 }, this.shape);
53
61
  this.intersectionsAmount = 0;
54
62
  this.currentOverlaps = new Set();
@@ -73,14 +81,33 @@ export class MatterTriggerComponent extends MatterRigidBodyComponent {
73
81
  super.addToWorld(world);
74
82
  Events.on(world.physicsWorld.matterEngine, 'collisionStart', this.handleCollisionStart);
75
83
  Events.on(world.physicsWorld.matterEngine, 'collisionEnd', this.handleCollisionEnd);
84
+ this.removedSub = world.physicsWorld.removed$.subscribe(c => {
85
+ if (c === this) {
86
+ return;
87
+ }
88
+ if (c instanceof MatterCharacterControllerComponent) {
89
+ if (this.currentCharacterOverlaps.delete(c)) {
90
+ queueMicrotask(() => this.onLeft$.next(c));
91
+ }
92
+ }
93
+ else if (c instanceof MatterRigidBodyComponent && this.currentOverlaps.delete(c)) {
94
+ queueMicrotask(() => this.onLeft$.next(c));
95
+ }
96
+ });
76
97
  }
77
98
  removeFromWorld(world, dispose) {
99
+ var _a;
78
100
  Events.off(world.physicsWorld.matterEngine, 'collisionStart', this.handleCollisionStart);
79
101
  Events.off(world.physicsWorld.matterEngine, 'collisionEnd', this.handleCollisionEnd);
102
+ (_a = this.removedSub) === null || _a === void 0 ? void 0 : _a.unsubscribe();
80
103
  for (const body of this.currentOverlaps) {
81
104
  this.onLeft$.next(body);
82
105
  }
83
106
  this.currentOverlaps.clear();
107
+ for (const character of this.currentCharacterOverlaps) {
108
+ this.onLeft$.next(character);
109
+ }
110
+ this.currentCharacterOverlaps.clear();
84
111
  super.removeFromWorld(world, dispose);
85
112
  }
86
113
  /** Completes `onEnter$`/`onLeft$` on top of `MatterRigidBodyComponent.dispose()`'s own
@@ -91,8 +118,51 @@ export class MatterTriggerComponent extends MatterRigidBodyComponent {
91
118
  this.onLeft$.complete();
92
119
  super.dispose();
93
120
  }
121
+ /**
122
+ * Regular rigid-body overlaps are handled entirely by `handleCollisionStart`/`handleCollisionEnd`
123
+ * above, off matter's own native `collisionStart`/`collisionEnd` engine events - so this used to be
124
+ * a pure no-op for matter-js. A `MatterCharacterControllerComponent`'s own phantom body is
125
+ * deliberately never added to `Composite`/`engine.world` at all (see that class's own doc), so no
126
+ * native collision pair - and thus no native event - can ever involve it. Since `checkOverlaps()` is
127
+ * already called once per tick by `Trigger2dEntity` regardless of backend, this is the natural place
128
+ * to add the poll this needs instead of inventing a second, differently-shaped mechanism: every
129
+ * `MatterCharacterControllerComponent` currently in the world (`world.children`, which - unlike
130
+ * matter's own `Composite` - already tracks it) is tested against this trigger's own body via
131
+ * `Query.collides`, diffed against `currentCharacterOverlaps` to fire `onEntityEntered`/
132
+ * `onEntityLeft` exactly on the enter/exit transitions, the same as the native-event path does for
133
+ * ordinary bodies.
134
+ */
94
135
  checkOverlaps() {
95
- // do nothing, for matter.js we handle this differently
136
+ // `Query.collides` tests raw geometry only and knows nothing about `collisionFilter` (see
137
+ // `MatterCharacterControllerComponent.collectObstacles`'s own doc on this same gap) - call
138
+ // matter's own `Detector.canCollide` directly so a character controller whose collision groups
139
+ // wouldn't ordinarily interact with this trigger isn't falsely reported entering it just because
140
+ // this poll bypasses the broadphase that would otherwise exclude it.
141
+ const characters = this.world.children.filter((c) => c instanceof MatterCharacterControllerComponent &&
142
+ Detector.canCollide(this.nativeBody.collisionFilter, c.nativeBody.collisionFilter));
143
+ const stillOverlapping = new Set();
144
+ if (characters.length > 0) {
145
+ const collisions = Query.collides(this.nativeBody, characters.map(c => c.nativeBody));
146
+ for (const collision of collisions) {
147
+ const otherNative = collision.parentA === this.nativeBody ? collision.parentB : collision.parentA;
148
+ const comp = characters.find(c => c.nativeBody === otherNative);
149
+ if (comp) {
150
+ stillOverlapping.add(comp);
151
+ }
152
+ }
153
+ }
154
+ for (const comp of stillOverlapping) {
155
+ if (!this.currentCharacterOverlaps.has(comp)) {
156
+ this.currentCharacterOverlaps.add(comp);
157
+ this.onEnter$.next(comp);
158
+ }
159
+ }
160
+ for (const comp of this.currentCharacterOverlaps) {
161
+ if (!stillOverlapping.has(comp)) {
162
+ this.currentCharacterOverlaps.delete(comp);
163
+ this.onLeft$.next(comp);
164
+ }
165
+ }
96
166
  }
97
167
  clone() {
98
168
  const clonedBody = Body.create(Object.assign(Object.assign({}, this.nativeBody), { isSensor: true, collisionFilter: Object.assign({}, this.nativeBody.collisionFilter) }));
@@ -5,19 +5,23 @@ import { MatterPhysicsTypeDocRepo } from '../types';
5
5
  import { Subject } from 'rxjs';
6
6
  import { MatterRigidBodyComponent } from './matter-rigid-body.component';
7
7
  import { MatterTriggerComponent } from './matter-trigger.component';
8
+ import { MatterCharacterControllerComponent } from './matter-character-controller.component';
9
+ type MatterWorldChild = MatterRigidBodyComponent | MatterTriggerComponent | MatterCharacterControllerComponent;
8
10
  export declare class MatterWorldComponent implements IPhysicsWorld2dComponent<MatterPhysicsTypeDocRepo> {
9
11
  protected matterEngine_: Engine | null;
10
12
  get matterEngine(): Engine | null;
11
13
  get matterWorld(): World | null;
12
14
  readonly factory: MatterFactory;
13
- readonly added$: Subject<MatterRigidBodyComponent | MatterTriggerComponent>;
14
- readonly removed$: Subject<MatterRigidBodyComponent | MatterTriggerComponent>;
15
- readonly children: (MatterRigidBodyComponent | MatterTriggerComponent)[];
15
+ readonly added$: Subject<MatterWorldChild>;
16
+ readonly removed$: Subject<MatterWorldChild>;
17
+ readonly children: MatterWorldChild[];
16
18
  /** Mirrors the rapier packages' `handleIdEntityMap` pattern: `Body.id` (matter-js's own
17
19
  * globally-unique numeric id, assigned once per body via `Body.nextId` and stable for its whole
18
20
  * lifetime) to component, kept in sync alongside `children` so `findRigidBody` - called once per
19
- * collision pair, per step - is an O(1) lookup instead of an O(n) `Array.find` scan. */
20
- readonly handleIdEntityMap: Map<number, MatterRigidBodyComponent>;
21
+ * collision pair, per step - is an O(1) lookup instead of an O(n) `Array.find` scan. Also used by
22
+ * `MatterCharacterControllerComponent.pushDynamicBodies` to resolve a native body it just bumped
23
+ * into back to its owning component. */
24
+ readonly handleIdEntityMap: Map<number, MatterWorldChild>;
21
25
  private _gravity;
22
26
  get gravity(): Point2;
23
27
  set gravity(value: Point2);
@@ -54,6 +58,29 @@ export declare class MatterWorldComponent implements IPhysicsWorld2dComponent<Ma
54
58
  deregisterCollisionGroup(group: CollisionGroup): void;
55
59
  private lastDelta;
56
60
  simulate(delta: number): void;
61
+ /**
62
+ * matter-js has no native raycast query. Built on a true parametric ray-vs-polygon intersection
63
+ * (`segmentIntersection`/`bodyPolygons`), not `Matter.Query.ray` - that helper is only a thin
64
+ * wrapper over `Query.collides` (a full-geometry SAT test of a synthetic, very thin rectangle body
65
+ * against candidates), which reports an approximate overlap contact point, not a true "where does
66
+ * the ray segment first cross this body's boundary" point; verified via this adapter's own
67
+ * regression suite (`MatterWorldComponent.spec.ts`'s `Raycast` block expects a precise hit point at
68
+ * a known box edge) that the SAT-approximated version produces a visibly wrong point.
69
+ *
70
+ * `Query.collides`/`Collision.collides` test raw geometry only and know nothing about
71
+ * `collisionFilter` (the same limitation `MatterCharacterControllerComponent.collectObstacles`'s
72
+ * own doc describes) - candidates are pre-filtered here via `Detector.canCollide` itself, called
73
+ * directly against a synthetic filter built from `options.collisionFilterGroups`/
74
+ * `collisionFilterMask`. Sensor bodies (triggers) are excluded from candidates entirely, mirroring
75
+ * `collectObstacles`'s own exclusion - a trigger never physically blocks anything, so it shouldn't
76
+ * register as a raycast hit either.
77
+ *
78
+ * `hitNormal` is derived from whichever polygon edge the closest intersection landed on (rotated
79
+ * 90°, sign chosen to point back towards `options.from`) - not from a matter-js collision object at
80
+ * all, sidestepping the sign-convention pitfall `handleCollisionStart`'s own doc describes for
81
+ * `pair.collision.normal`.
82
+ */
57
83
  raycast(options: RaycastOptions<Point2>): RaycastResult<Point2, MatterRigidBodyComponent>;
58
84
  dispose(): void;
59
85
  }
86
+ export {};
@@ -7,10 +7,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
- import { Pnt2, } from '@gg-web-engine/core';
11
- import { Engine, Events } from 'matter-js';
10
+ import { BitMask, Pnt2, } from '@gg-web-engine/core';
11
+ import { Composite, Detector, Engine, Events } from 'matter-js';
12
12
  import { MatterFactory } from '../matter-factory';
13
13
  import { Subject } from 'rxjs';
14
+ import { MatterRigidBodyComponent } from './matter-rigid-body.component';
14
15
  /**
15
16
  * Averages matter-js's collision support points into a single representative contact point for
16
17
  * `CollisionEvent.position`. `collision.supports` is *always* a fixed-length-2 array
@@ -34,6 +35,42 @@ function averageSupports(collision) {
34
35
  }
35
36
  return Pnt2.scalarMult(sum, 1 / supports.length);
36
37
  }
38
+ /**
39
+ * Standard 2D segment-vs-segment intersection (parametric form): `p1->p2` is the ray segment,
40
+ * `p3->p4` one polygon edge. Returns the intersection's fractional position along `p1->p2`
41
+ * (`t`, always in `[0,1]` when a result is returned) and the world-space point itself, or `null`
42
+ * for parallel/non-intersecting/out-of-range segments. Used by `MatterWorldComponent.raycast`
43
+ * (see its own doc for why) instead of matter-js's own `Query.ray`/`Collision.collides`, which
44
+ * only ever reports an approximate SAT contact point, not a true point where the ray segment
45
+ * crosses a candidate's actual boundary.
46
+ */
47
+ function segmentIntersection(p1, p2, p3, p4) {
48
+ const d1 = Pnt2.sub(p2, p1);
49
+ const d2 = Pnt2.sub(p4, p3);
50
+ const denom = d1.x * d2.y - d1.y * d2.x;
51
+ if (Math.abs(denom) < 1e-10) {
52
+ return null; // parallel (or degenerate) - no single intersection point
53
+ }
54
+ const diff = Pnt2.sub(p3, p1);
55
+ const t = (diff.x * d2.y - diff.y * d2.x) / denom;
56
+ const u = (diff.x * d1.y - diff.y * d1.x) / denom;
57
+ if (t < 0 || t > 1 || u < 0 || u > 1) {
58
+ return null;
59
+ }
60
+ return { t, point: Pnt2.add(p1, Pnt2.scalarMult(d1, t)) };
61
+ }
62
+ /**
63
+ * Every polygon (in matter-js's sense) making up `body`'s actual collision boundary, each as a
64
+ * closed loop of world-space vertices - `body` itself for a simple (non-compound) body, or every
65
+ * part but the synthetic index-0 "container" part for a compound one (mirrors `Query.collides`'s
66
+ * own `partsAStart = partsALength === 1 ? 0 : 1` convention). A circle is included via matter-js's
67
+ * own many-sided-polygon approximation (`Bodies.circle` is built on `Bodies.polygon`) - close
68
+ * enough for a raycast headroom check, not a true circle intersection.
69
+ */
70
+ function bodyPolygons(body) {
71
+ const parts = body.parts.length === 1 ? [body] : body.parts.slice(1);
72
+ return parts.map(part => part.vertices);
73
+ }
37
74
  // TODO probably should be configurable in world
38
75
  const MATTER_WORLD_SCALE = 0.0001;
39
76
  export class MatterWorldComponent {
@@ -61,7 +98,9 @@ export class MatterWorldComponent {
61
98
  /** Mirrors the rapier packages' `handleIdEntityMap` pattern: `Body.id` (matter-js's own
62
99
  * globally-unique numeric id, assigned once per body via `Body.nextId` and stable for its whole
63
100
  * lifetime) to component, kept in sync alongside `children` so `findRigidBody` - called once per
64
- * collision pair, per step - is an O(1) lookup instead of an O(n) `Array.find` scan. */
101
+ * collision pair, per step - is an O(1) lookup instead of an O(n) `Array.find` scan. Also used by
102
+ * `MatterCharacterControllerComponent.pushDynamicBodies` to resolve a native body it just bumped
103
+ * into back to its owning component. */
65
104
  this.handleIdEntityMap = new Map();
66
105
  this._gravity = { x: 0, y: 9.82 };
67
106
  this.mainCollisionGroup = 0;
@@ -89,7 +128,11 @@ export class MatterWorldComponent {
89
128
  });
90
129
  }
91
130
  findRigidBody(nativeBody) {
92
- return this.handleIdEntityMap.get(nativeBody.id);
131
+ // a character controller's phantom body is never added to `Composite`/`engine.world` (see its
132
+ // own doc), so it can never actually be `pair.bodyA`/`pair.bodyB` here - this narrows the lookup's
133
+ // type back down since `handleIdEntityMap` itself now also tracks that component class.
134
+ const comp = this.handleIdEntityMap.get(nativeBody.id);
135
+ return comp instanceof MatterRigidBodyComponent ? comp : undefined;
93
136
  }
94
137
  /**
95
138
  * Wires `IRigidBody2dComponent.onCollisionStart` for every non-sensor pair in the world, off
@@ -173,8 +216,79 @@ export class MatterWorldComponent {
173
216
  Engine.update(this.matterEngine, delta, this.lastDelta > 0 ? delta / this.lastDelta : 1);
174
217
  this.lastDelta = delta;
175
218
  }
219
+ /**
220
+ * matter-js has no native raycast query. Built on a true parametric ray-vs-polygon intersection
221
+ * (`segmentIntersection`/`bodyPolygons`), not `Matter.Query.ray` - that helper is only a thin
222
+ * wrapper over `Query.collides` (a full-geometry SAT test of a synthetic, very thin rectangle body
223
+ * against candidates), which reports an approximate overlap contact point, not a true "where does
224
+ * the ray segment first cross this body's boundary" point; verified via this adapter's own
225
+ * regression suite (`MatterWorldComponent.spec.ts`'s `Raycast` block expects a precise hit point at
226
+ * a known box edge) that the SAT-approximated version produces a visibly wrong point.
227
+ *
228
+ * `Query.collides`/`Collision.collides` test raw geometry only and know nothing about
229
+ * `collisionFilter` (the same limitation `MatterCharacterControllerComponent.collectObstacles`'s
230
+ * own doc describes) - candidates are pre-filtered here via `Detector.canCollide` itself, called
231
+ * directly against a synthetic filter built from `options.collisionFilterGroups`/
232
+ * `collisionFilterMask`. Sensor bodies (triggers) are excluded from candidates entirely, mirroring
233
+ * `collectObstacles`'s own exclusion - a trigger never physically blocks anything, so it shouldn't
234
+ * register as a raycast hit either.
235
+ *
236
+ * `hitNormal` is derived from whichever polygon edge the closest intersection landed on (rotated
237
+ * 90°, sign chosen to point back towards `options.from`) - not from a matter-js collision object at
238
+ * all, sidestepping the sign-convention pitfall `handleCollisionStart`'s own doc describes for
239
+ * `pair.collision.normal`.
240
+ */
176
241
  raycast(options) {
177
- throw new Error('Ray casting is not implemented for Matter.js');
242
+ var _a;
243
+ const matterWorld = this.matterWorld;
244
+ const direction = Pnt2.sub(options.to, options.from);
245
+ const rayLength = Pnt2.len(direction);
246
+ if (!matterWorld || rayLength === 0) {
247
+ return { hasHit: false };
248
+ }
249
+ const groupsMask = options.collisionFilterGroups
250
+ ? BitMask.pack(options.collisionFilterGroups, 16)
251
+ : BitMask.full(16);
252
+ const maskMask = options.collisionFilterMask ? BitMask.pack(options.collisionFilterMask, 16) : BitMask.full(16);
253
+ const rayFilter = { category: groupsMask, mask: maskMask, group: 0 };
254
+ const candidates = Composite.allBodies(matterWorld).filter(b => !b.isSensor && Detector.canCollide(rayFilter, b.collisionFilter));
255
+ let closestT = Infinity;
256
+ let closestPoint = null;
257
+ let closestNormal = null;
258
+ let closestBody = null;
259
+ for (const body of candidates) {
260
+ for (const polygon of bodyPolygons(body)) {
261
+ for (let i = 0; i < polygon.length; i++) {
262
+ const a = polygon[i];
263
+ const b = polygon[(i + 1) % polygon.length];
264
+ const hit = segmentIntersection(options.from, options.to, a, b);
265
+ if (!hit || hit.t >= closestT) {
266
+ continue;
267
+ }
268
+ closestT = hit.t;
269
+ closestPoint = hit.point;
270
+ closestBody = body;
271
+ const edge = Pnt2.sub(b, a);
272
+ const normal = Pnt2.norm({ x: -edge.y, y: edge.x });
273
+ closestNormal = Pnt2.dot(normal, Pnt2.sub(options.from, hit.point)) >= 0 ? normal : Pnt2.neg(normal);
274
+ }
275
+ }
276
+ }
277
+ if (!closestBody) {
278
+ return { hasHit: false };
279
+ }
280
+ // A compound body's hit part is not the parent body `handleIdEntityMap` is keyed by (see
281
+ // `MatterFactory`'s compound-shape flattening) - `.parent` (self-referential for a non-compound
282
+ // body) resolves back to it.
283
+ const parentBody = (_a = closestBody.parent) !== null && _a !== void 0 ? _a : closestBody;
284
+ const resolved = this.handleIdEntityMap.get(parentBody.id);
285
+ return {
286
+ hasHit: true,
287
+ hitBody: resolved instanceof MatterRigidBodyComponent ? resolved : undefined,
288
+ hitDistance: closestT * rayLength,
289
+ hitPoint: closestPoint,
290
+ hitNormal: closestNormal,
291
+ };
178
292
  }
179
293
  dispose() {
180
294
  Events.off(this.matterEngine, 'collisionStart', this.handleCollisionStart);
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './components/matter-character-controller.component';
1
2
  export * from './components/matter-rigid-body.component';
2
3
  export * from './components/matter-trigger.component';
3
4
  export * from './components/matter-world.component';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './components/matter-character-controller.component';
1
2
  export * from './components/matter-rigid-body.component';
2
3
  export * from './components/matter-trigger.component';
3
4
  export * from './components/matter-world.component';
@@ -1,6 +1,7 @@
1
- import { BodyShape2DDescriptor, IPhysicsBody2dComponentFactory, Point2, Shape2DDescriptor } from '@gg-web-engine/core';
1
+ import { BodyShape2DDescriptor, CharacterController2dOptions, IPhysicsBody2dComponentFactory, Point2, Shape2DDescriptor } from '@gg-web-engine/core';
2
2
  import { MatterRigidBodyComponent } from './components/matter-rigid-body.component';
3
3
  import { MatterTriggerComponent } from './components/matter-trigger.component';
4
+ import { MatterCharacterControllerComponent } from './components/matter-character-controller.component';
4
5
  import { MatterWorldComponent } from './components/matter-world.component';
5
6
  import { MatterPhysicsTypeDocRepo } from './types';
6
7
  export declare class MatterFactory implements IPhysicsBody2dComponentFactory<MatterPhysicsTypeDocRepo> {
@@ -14,6 +15,12 @@ export declare class MatterFactory implements IPhysicsBody2dComponentFactory<Mat
14
15
  position?: Point2;
15
16
  rotation?: number;
16
17
  }): MatterTriggerComponent;
18
+ createCharacterController(options: CharacterController2dOptions, transform?: {
19
+ position?: Point2;
20
+ rotation?: number;
21
+ }): MatterCharacterControllerComponent;
22
+ private createShapeParts;
23
+ private createShapeBody;
17
24
  /**
18
25
  * matter-js's own body model only has `isStatic` - no distinct kinematic body type (position-
19
26
  * driven, but still pushes/wakes dynamic bodies it moves into) and no continuous collision
@@ -1,7 +1,17 @@
1
- import { warnOnce, } from '@gg-web-engine/core';
1
+ import { Pnt2, warnOnce, } from '@gg-web-engine/core';
2
2
  import { MatterRigidBodyComponent } from './components/matter-rigid-body.component';
3
3
  import { MatterTriggerComponent } from './components/matter-trigger.component';
4
- import { Bodies, Vector } from 'matter-js';
4
+ import { MatterCharacterControllerComponent } from './components/matter-character-controller.component';
5
+ import { Bodies, Body, Common, Vector } from 'matter-js';
6
+ import * as decomp from 'poly-decomp';
7
+ /**
8
+ * `Bodies.fromVertices` (used for the `POLYGON` shape below) only actually decomposes a concave
9
+ * vertex set into convex parts when a decomposition library is registered via `Common.setDecomp` -
10
+ * without it, matter-js silently falls back to the convex hull of the given vertices, so a concave
11
+ * `POLYGON` would render/collide as if it were convex. Registering `poly-decomp` here, once, at
12
+ * module load makes `Bodies.fromVertices` actually decompose concave outlines everywhere it's used.
13
+ */
14
+ Common.setDecomp(decomp);
5
15
  /**
6
16
  * `kinematic_pos`/`kinematic_vel`/`ccd` have no native matter-js equivalent at all - unlike
7
17
  * `packages/ammo`/`packages/rapier2d`/`packages/rapier3d`, this package can only warn and fall
@@ -19,45 +29,140 @@ export class MatterFactory {
19
29
  this.world = world;
20
30
  }
21
31
  createRigidBody(descriptor, transform) {
22
- var _a, _b;
32
+ var _a, _b, _c;
23
33
  let nativeBody = null;
24
34
  switch (descriptor.shape.shape) {
25
- case 'SQUARE':
35
+ case 'BOX':
26
36
  nativeBody = Bodies.rectangle(0, 0, descriptor.shape.dimensions.x, descriptor.shape.dimensions.y, this.transformOptions(descriptor.body));
27
37
  break;
28
38
  case 'CIRCLE':
29
39
  nativeBody = Bodies.circle(0, 0, descriptor.shape.radius, this.transformOptions(descriptor.body));
30
40
  break;
41
+ case 'CAPSULE':
42
+ nativeBody = Bodies.rectangle(0, 0, descriptor.shape.radius * 2, descriptor.shape.centersDistance + descriptor.shape.radius * 2, {
43
+ chamfer: {
44
+ radius: descriptor.shape.radius,
45
+ },
46
+ });
47
+ break;
48
+ case 'CONVEX_HULL':
49
+ nativeBody = Bodies.fromVertices(0, 0, [Pnt2.hull(descriptor.shape.vertices).map(v => Vector.create(v.x, v.y))], this.transformOptions(descriptor.body));
50
+ break;
51
+ case 'POLYGON':
52
+ nativeBody = Bodies.fromVertices(0, 0, [descriptor.shape.vertices.map(v => Vector.create(v.x, v.y))], this.transformOptions(descriptor.body));
53
+ break;
54
+ case 'COMPOUND':
55
+ nativeBody = this.createShapeBody(descriptor.shape, this.transformOptions(descriptor.body));
56
+ break;
31
57
  }
32
58
  if (!nativeBody) {
33
59
  throw new Error(`Shape "${descriptor.shape}" not implemented for Matter.js`);
34
60
  }
35
- nativeBody.position = 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);
36
- nativeBody.angle = (transform === null || transform === void 0 ? void 0 : transform.rotation) || 0;
37
- return new MatterRigidBodyComponent(nativeBody, descriptor.shape);
61
+ // Must go through `Body.setPosition`/`Body.setAngle` (not a raw `nativeBody.position = ...`
62
+ // field assignment) - see `gg-engine-physics-adapter-matter`'s own note on this: a body's
63
+ // `vertices`/`bounds` (what every actual collision query reads) are only ever translated to
64
+ // match `.position` at creation time, using whatever position was passed into
65
+ // `Bodies.rectangle`/`Bodies.circle`/`Bodies.fromVertices` itself (always `(0, 0)` here) - a
66
+ // later raw field write changes what `.position` *reports* without moving the real collision
67
+ // geometry at all, permanently desyncing the two until something else (ordinary simulation,
68
+ // which this character-controller-adjacent code path can't rely on) happens to correct it.
69
+ Body.setPosition(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));
70
+ Body.setAngle(nativeBody, (transform === null || transform === void 0 ? void 0 : transform.rotation) || 0);
71
+ const bodyType = (_c = descriptor.body.bodyType) !== null && _c !== void 0 ? _c : (descriptor.body.mass ? 'dynamic' : 'static');
72
+ const component = new MatterRigidBodyComponent(nativeBody, descriptor.shape, bodyType, !!descriptor.body.ccd);
73
+ // `transformOptions` (used to build `nativeBody` above) only ever reads
74
+ // `bodyType`/`mass`/`restitution`/`friction` - `ownCollisionGroups`/`interactWithCollisionGroups`
75
+ // must be applied through the component's own setters afterward (same as
76
+ // `MatterCharacterControllerComponent`'s constructor already does for a character), or a
77
+ // configured collision group is silently dropped in favor of the "all groups" default every
78
+ // `MatterRigidBodyComponent` otherwise starts with.
79
+ if (descriptor.body.ownCollisionGroups !== undefined) {
80
+ component.ownCollisionGroups = descriptor.body.ownCollisionGroups;
81
+ }
82
+ if (descriptor.body.interactWithCollisionGroups !== undefined) {
83
+ component.interactWithCollisionGroups = descriptor.body.interactWithCollisionGroups;
84
+ }
85
+ return component;
38
86
  }
39
87
  createTrigger(descriptor, transform) {
40
88
  var _a, _b;
41
89
  let nativeBody = null;
42
90
  switch (descriptor.shape) {
43
- case 'SQUARE':
91
+ case 'BOX':
44
92
  nativeBody = Bodies.rectangle(0, 0, descriptor.dimensions.x, descriptor.dimensions.y, { isSensor: true });
45
93
  break;
46
94
  case 'CIRCLE':
47
95
  nativeBody = Bodies.circle(0, 0, descriptor.radius, { isSensor: true });
48
96
  break;
97
+ case 'CAPSULE':
98
+ nativeBody = Bodies.rectangle(0, 0, descriptor.radius * 2, descriptor.centersDistance + descriptor.radius * 2, {
99
+ isSensor: true,
100
+ chamfer: {
101
+ radius: descriptor.radius,
102
+ },
103
+ });
104
+ break;
105
+ case 'CONVEX_HULL':
106
+ nativeBody = Bodies.fromVertices(0, 0, [Pnt2.hull(descriptor.vertices).map(v => Vector.create(v.x, v.y))], {
107
+ isSensor: true,
108
+ });
109
+ break;
110
+ case 'POLYGON':
111
+ nativeBody = Bodies.fromVertices(0, 0, [descriptor.vertices.map(v => Vector.create(v.x, v.y))], {
112
+ isSensor: true,
113
+ });
114
+ break;
115
+ case 'COMPOUND':
116
+ nativeBody = this.createShapeBody(descriptor, { isSensor: true });
117
+ break;
49
118
  }
50
119
  if (!nativeBody) {
51
120
  throw new Error(`Shape "${descriptor.shape}" not implemented for Matter.js`);
52
121
  }
53
- nativeBody.position.x = ((_a = transform === null || transform === void 0 ? void 0 : transform.position) === null || _a === void 0 ? void 0 : _a.x) || 0;
54
- nativeBody.position.y = ((_b = transform === null || transform === void 0 ? void 0 : transform.position) === null || _b === void 0 ? void 0 : _b.y) || 0;
55
- nativeBody.angle = (transform === null || transform === void 0 ? void 0 : transform.rotation) || 0;
122
+ // See the identical fix (and its doc) in `createRigidBody` above - a trigger's collision
123
+ // geometry needs the same real `Body.setPosition`/`Body.setAngle` treatment, not a raw
124
+ // `.position.x`/`.position.y`/`.angle` field write.
125
+ Body.setPosition(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));
126
+ Body.setAngle(nativeBody, (transform === null || transform === void 0 ? void 0 : transform.rotation) || 0);
56
127
  if (!this.world) {
57
128
  throw new Error('MatterFactory: World not set. Make sure the factory is created by MatterWorldComponent.');
58
129
  }
59
130
  return new MatterTriggerComponent(nativeBody, descriptor, this.world);
60
131
  }
132
+ createCharacterController(options, transform) {
133
+ return new MatterCharacterControllerComponent(this.world, options, transform);
134
+ }
135
+ createShapeParts(shape, options) {
136
+ switch (shape.shape) {
137
+ case 'BOX':
138
+ return [Bodies.rectangle(0, 0, shape.dimensions.x, shape.dimensions.y, options)];
139
+ case 'CIRCLE':
140
+ return [Bodies.circle(0, 0, shape.radius, options)];
141
+ case 'CAPSULE':
142
+ return [
143
+ Bodies.rectangle(0, 0, shape.radius * 2, shape.centersDistance + shape.radius * 2, Object.assign(Object.assign({}, options), { chamfer: { radius: shape.radius } })),
144
+ ];
145
+ case 'CONVEX_HULL':
146
+ return [Bodies.fromVertices(0, 0, [Pnt2.hull(shape.vertices).map(v => Vector.create(v.x, v.y))], options)];
147
+ case 'POLYGON':
148
+ return [Bodies.fromVertices(0, 0, [shape.vertices.map(v => Vector.create(v.x, v.y))], options)];
149
+ case 'COMPOUND': {
150
+ const parts = [];
151
+ for (const { position, rotation, shape: childShape } of shape.children) {
152
+ const childParts = this.createShapeParts(childShape, options);
153
+ for (const part of childParts) {
154
+ Body.setPosition(part, Vector.add(Vector.rotate(part.position, rotation || 0), Vector.create((position === null || position === void 0 ? void 0 : position.x) || 0, (position === null || position === void 0 ? void 0 : position.y) || 0)));
155
+ Body.setAngle(part, part.angle + (rotation || 0));
156
+ }
157
+ parts.push(...childParts);
158
+ }
159
+ return parts;
160
+ }
161
+ }
162
+ }
163
+ createShapeBody(shape, options) {
164
+ return Body.create(Object.assign({ parts: this.createShapeParts(shape, options) }, options));
165
+ }
61
166
  /**
62
167
  * matter-js's own body model only has `isStatic` - no distinct kinematic body type (position-
63
168
  * driven, but still pushes/wakes dynamic bodies it moves into) and no continuous collision
package/dist/types.d.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { MatterFactory } from './matter-factory';
2
2
  import { MatterRigidBodyComponent } from './components/matter-rigid-body.component';
3
3
  import { MatterTriggerComponent } from './components/matter-trigger.component';
4
+ import { MatterCharacterControllerComponent } from './components/matter-character-controller.component';
4
5
  import { Gg2dWorld, Gg2dWorldSceneTypeDocPPatch, Gg2dWorldTypeDocPPatch } from '@gg-web-engine/core';
5
6
  import { MatterWorldComponent } from './components/matter-world.component';
6
7
  export type MatterPhysicsTypeDocRepo = {
7
8
  factory: MatterFactory;
8
9
  rigidBody: MatterRigidBodyComponent;
9
10
  trigger: MatterTriggerComponent;
11
+ characterController: MatterCharacterControllerComponent;
10
12
  };
11
13
  export type MatterTypeDoc = Gg2dWorldTypeDocPPatch<MatterPhysicsTypeDocRepo>;
12
14
  export type MatterSceneTypeDoc = Gg2dWorldSceneTypeDocPPatch<MatterPhysicsTypeDocRepo, MatterWorldComponent>;