@gg-web-engine/matter 0.0.66 → 0.0.68

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,5 +1,6 @@
1
- import { CollisionGroup, DebugBody2DSettings, Entity2d, IRigidBody2dComponent, Point2, Shape2DDescriptor } from '@gg-web-engine/core';
1
+ import { CollisionEvent, CollisionGroup, DebugBody2DSettings, Entity2d, IRigidBody2dComponent, Point2, Shape2DDescriptor } from '@gg-web-engine/core';
2
2
  import { Body } from 'matter-js';
3
+ import { Observable, Subject } from 'rxjs';
3
4
  import { MatterGgWorld, MatterPhysicsTypeDocRepo } from '../types';
4
5
  export declare class MatterRigidBodyComponent implements IRigidBody2dComponent<MatterPhysicsTypeDocRepo> {
5
6
  nativeBody: Body;
@@ -17,7 +18,23 @@ export declare class MatterRigidBodyComponent implements IRigidBody2dComponent<M
17
18
  readonly debugBodySettings: DebugBody2DSettings;
18
19
  protected _interactWithCGsMask: number;
19
20
  protected _ownCGsMask: number;
21
+ get onCollisionStart(): Observable<CollisionEvent<Point2, MatterRigidBodyComponent>>;
22
+ get onCollisionEnd(): Observable<MatterRigidBodyComponent | null>;
23
+ protected readonly onCollisionStart$: Subject<CollisionEvent<Point2, MatterRigidBodyComponent>>;
24
+ protected readonly onCollisionEnd$: Subject<MatterRigidBodyComponent | null>;
25
+ /** Other rigid bodies this body is currently touching (non-sensor contact only), tracked so
26
+ * `removeFromWorld` can tell them apart to emit `onCollisionEnd(null)` per that member's
27
+ * documented "other body removed while still in contact" case. Maintained exclusively via
28
+ * `notifyCollisionStart`/`notifyCollisionEnd`, called by `MatterWorldComponent`'s single
29
+ * world-wide `collisionStart`/`collisionEnd` listener - not touched directly by anything else. */
30
+ protected readonly currentContacts: Set<MatterRigidBodyComponent>;
20
31
  constructor(nativeBody: Body, shape: Shape2DDescriptor);
32
+ /** @internal called by `MatterWorldComponent`'s global `collisionStart` listener - not part of
33
+ * this component's public API surface. */
34
+ notifyCollisionStart(collisionEvent: CollisionEvent<Point2, MatterRigidBodyComponent>): void;
35
+ /** @internal called by `MatterWorldComponent`'s global `collisionEnd` listener - not part of
36
+ * this component's public API surface. */
37
+ notifyCollisionEnd(otherBody: MatterRigidBodyComponent | null): void;
21
38
  get interactWithCollisionGroups(): ReadonlyArray<CollisionGroup>;
22
39
  set interactWithCollisionGroups(value: ReadonlyArray<CollisionGroup> | 'all');
23
40
  get ownCollisionGroups(): ReadonlyArray<CollisionGroup>;
@@ -1,5 +1,6 @@
1
1
  import { BitMask, DebugBody2DSettings, Pnt2, } from '@gg-web-engine/core';
2
2
  import { Body, Composite, Vector } from 'matter-js';
3
+ import { Subject } from 'rxjs';
3
4
  // FIXME why this needs to be introduced? investigate units in matter.js
4
5
  const MATTER_VELOCITY_SCALE = 0.0166667;
5
6
  export class MatterRigidBodyComponent {
@@ -27,6 +28,12 @@ export class MatterRigidBodyComponent {
27
28
  set angularVelocity(value) {
28
29
  Body.setAngularVelocity(this.nativeBody, value);
29
30
  }
31
+ get onCollisionStart() {
32
+ return this.onCollisionStart$.asObservable();
33
+ }
34
+ get onCollisionEnd() {
35
+ return this.onCollisionEnd$.asObservable();
36
+ }
30
37
  constructor(nativeBody, shape) {
31
38
  this.nativeBody = nativeBody;
32
39
  this.shape = shape;
@@ -37,8 +44,30 @@ export class MatterRigidBodyComponent {
37
44
  : { type: 'RIGID_STATIC' }, this.shape);
38
45
  this._interactWithCGsMask = BitMask.full(16);
39
46
  this._ownCGsMask = BitMask.full(16);
47
+ this.onCollisionStart$ = new Subject();
48
+ this.onCollisionEnd$ = new Subject();
49
+ /** Other rigid bodies this body is currently touching (non-sensor contact only), tracked so
50
+ * `removeFromWorld` can tell them apart to emit `onCollisionEnd(null)` per that member's
51
+ * documented "other body removed while still in contact" case. Maintained exclusively via
52
+ * `notifyCollisionStart`/`notifyCollisionEnd`, called by `MatterWorldComponent`'s single
53
+ * world-wide `collisionStart`/`collisionEnd` listener - not touched directly by anything else. */
54
+ this.currentContacts = new Set();
40
55
  this.updateCollisionFilter();
41
56
  }
57
+ /** @internal called by `MatterWorldComponent`'s global `collisionStart` listener - not part of
58
+ * this component's public API surface. */
59
+ notifyCollisionStart(collisionEvent) {
60
+ this.currentContacts.add(collisionEvent.otherBody);
61
+ this.onCollisionStart$.next(collisionEvent);
62
+ }
63
+ /** @internal called by `MatterWorldComponent`'s global `collisionEnd` listener - not part of
64
+ * this component's public API surface. */
65
+ notifyCollisionEnd(otherBody) {
66
+ if (otherBody) {
67
+ this.currentContacts.delete(otherBody);
68
+ }
69
+ this.onCollisionEnd$.next(otherBody);
70
+ }
42
71
  get interactWithCollisionGroups() {
43
72
  return BitMask.unpack(this._interactWithCGsMask, 16);
44
73
  }
@@ -88,15 +117,27 @@ export class MatterRigidBodyComponent {
88
117
  removeFromWorld(world, dispose = false) {
89
118
  Composite.remove(world.physicsWorld.matterWorld, this.nativeBody);
90
119
  world.physicsWorld.removed$.next(this);
120
+ // this body is leaving the world while still touching others - per `onCollisionEnd`'s own
121
+ // contract, each of those bodies sees `null` (no further contact geometry is available), not a
122
+ // dangling reference to a body no longer in the world.
123
+ for (const other of this.currentContacts) {
124
+ other.notifyCollisionEnd(null);
125
+ other.currentContacts.delete(this);
126
+ }
127
+ this.currentContacts.clear();
91
128
  if (dispose) {
92
129
  this.dispose();
93
130
  }
94
131
  }
95
132
  // matter.js bodies are plain JS objects with no native/WASM handle - ordinary GC reclaims them
96
- // once `Composite.remove` above drops the engine's own reference, so there's nothing to free here
97
- // at this level. `MatterTriggerComponent` overrides this to complete its own RxJS subjects, which
98
- // *do* need an explicit dispose.
99
- dispose() { }
133
+ // once `Composite.remove` above drops the engine's own reference, so there's nothing to free
134
+ // beyond completing this component's own RxJS subjects. `MatterTriggerComponent` overrides this
135
+ // to also complete its own `onEnter$`/`onLeft$` subjects, calling back into this via `super.
136
+ // dispose()`.
137
+ dispose() {
138
+ this.onCollisionStart$.complete();
139
+ this.onCollisionEnd$.complete();
140
+ }
100
141
  resetMotion() {
101
142
  Body.setVelocity(this.nativeBody, Pnt2.O);
102
143
  Body.setAngularVelocity(this.nativeBody, 0);
@@ -14,13 +14,14 @@ export declare class MatterTriggerComponent extends MatterRigidBodyComponent imp
14
14
  readonly debugBodySettings: DebugBody2DSettings;
15
15
  protected intersectionsAmount: number;
16
16
  protected currentOverlaps: Set<MatterRigidBodyComponent>;
17
- private onCollisionStart;
18
- private onCollisionEnd;
17
+ private handleCollisionStart;
18
+ private handleCollisionEnd;
19
19
  constructor(nativeBody: Body, shape: Shape2DDescriptor, world: MatterWorldComponent);
20
20
  addToWorld(world: MatterGgWorld): void;
21
21
  removeFromWorld(world: MatterGgWorld, dispose?: boolean): void;
22
- /** Completes `onEnter$`/`onLeft$` - `MatterRigidBodyComponent.dispose()` is a no-op (see its own
23
- * doc), so this is the only place these two subjects ever get completed. */
22
+ /** Completes `onEnter$`/`onLeft$` on top of `MatterRigidBodyComponent.dispose()`'s own
23
+ * `onCollisionStart$`/`onCollisionEnd$` completion (via `super.dispose()`) - this trigger's own
24
+ * enter/exit subjects are a separate pair this subclass owns and must complete itself. */
24
25
  dispose(): void;
25
26
  checkOverlaps(): void;
26
27
  clone(): MatterTriggerComponent;
@@ -9,7 +9,7 @@ export class MatterTriggerComponent extends MatterRigidBodyComponent {
9
9
  get onEntityLeft() {
10
10
  return this.onLeft$.asObservable();
11
11
  }
12
- onCollisionStart(event) {
12
+ handleCollisionStart(event) {
13
13
  for (const pair of event.pairs) {
14
14
  let body = null;
15
15
  if (pair.bodyA === this.nativeBody) {
@@ -26,7 +26,7 @@ export class MatterTriggerComponent extends MatterRigidBodyComponent {
26
26
  }
27
27
  }
28
28
  }
29
- onCollisionEnd(event) {
29
+ handleCollisionEnd(event) {
30
30
  for (const pair of event.pairs) {
31
31
  let body = null;
32
32
  if (pair.bodyA === this.nativeBody) {
@@ -61,8 +61,8 @@ export class MatterTriggerComponent extends MatterRigidBodyComponent {
61
61
  this.intersectionsAmount--;
62
62
  }
63
63
  });
64
- this.onCollisionStart = this.onCollisionStart.bind(this);
65
- this.onCollisionEnd = this.onCollisionEnd.bind(this);
64
+ this.handleCollisionStart = this.handleCollisionStart.bind(this);
65
+ this.handleCollisionEnd = this.handleCollisionEnd.bind(this);
66
66
  }
67
67
  addToWorld(world) {
68
68
  if (world.physicsWorld != this.world) {
@@ -71,20 +71,21 @@ export class MatterTriggerComponent extends MatterRigidBodyComponent {
71
71
  this.intersectionsAmount = 0;
72
72
  this.currentOverlaps.clear();
73
73
  super.addToWorld(world);
74
- Events.on(world.physicsWorld.matterEngine, 'collisionStart', this.onCollisionStart);
75
- Events.on(world.physicsWorld.matterEngine, 'collisionEnd', this.onCollisionEnd);
74
+ Events.on(world.physicsWorld.matterEngine, 'collisionStart', this.handleCollisionStart);
75
+ Events.on(world.physicsWorld.matterEngine, 'collisionEnd', this.handleCollisionEnd);
76
76
  }
77
77
  removeFromWorld(world, dispose) {
78
- Events.off(world.physicsWorld.matterEngine, 'collisionStart', this.onCollisionStart);
79
- Events.off(world.physicsWorld.matterEngine, 'collisionEnd', this.onCollisionEnd);
78
+ Events.off(world.physicsWorld.matterEngine, 'collisionStart', this.handleCollisionStart);
79
+ Events.off(world.physicsWorld.matterEngine, 'collisionEnd', this.handleCollisionEnd);
80
80
  for (const body of this.currentOverlaps) {
81
81
  this.onLeft$.next(body);
82
82
  }
83
83
  this.currentOverlaps.clear();
84
84
  super.removeFromWorld(world, dispose);
85
85
  }
86
- /** Completes `onEnter$`/`onLeft$` - `MatterRigidBodyComponent.dispose()` is a no-op (see its own
87
- * doc), so this is the only place these two subjects ever get completed. */
86
+ /** Completes `onEnter$`/`onLeft$` on top of `MatterRigidBodyComponent.dispose()`'s own
87
+ * `onCollisionStart$`/`onCollisionEnd$` completion (via `super.dispose()`) - this trigger's own
88
+ * enter/exit subjects are a separate pair this subclass owns and must complete itself. */
88
89
  dispose() {
89
90
  this.onEnter$.complete();
90
91
  this.onLeft$.complete();
@@ -13,12 +13,42 @@ export declare class MatterWorldComponent implements IPhysicsWorld2dComponent<Ma
13
13
  readonly added$: Subject<MatterRigidBodyComponent | MatterTriggerComponent>;
14
14
  readonly removed$: Subject<MatterRigidBodyComponent | MatterTriggerComponent>;
15
15
  readonly children: (MatterRigidBodyComponent | MatterTriggerComponent)[];
16
+ /** Mirrors the rapier packages' `handleIdEntityMap` pattern: `Body.id` (matter-js's own
17
+ * globally-unique numeric id, assigned once per body via `Body.nextId` and stable for its whole
18
+ * 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>;
16
21
  private _gravity;
17
22
  get gravity(): Point2;
18
23
  set gravity(value: Point2);
19
24
  readonly mainCollisionGroup: CollisionGroup;
20
25
  constructor();
21
26
  init(): Promise<void>;
27
+ private findRigidBody;
28
+ /**
29
+ * Wires `IRigidBody2dComponent.onCollisionStart` for every non-sensor pair in the world, off
30
+ * matter-js's own `Matter.Events` 'collisionStart' (fired once per engine, not per-body - see
31
+ * `gg-engine-physics-adapter-matter` for why it's registered here rather than per component).
32
+ * `pair.isSensor` (true whenever either side is a trigger's underlying body, which always has
33
+ * `isSensor: true` - see `MatterTriggerComponent`'s constructor) is skipped entirely, so a
34
+ * trigger overlapping a rigid body only ever fires the trigger's own `onEntityEntered`, never
35
+ * this.
36
+ *
37
+ * Impulse: at the point this event fires, matter-js hasn't run `Resolver.solveVelocity` yet for
38
+ * this step (that happens later in `Engine.update`, whose event order is
39
+ * collisionStart → position solve → velocity solve → collisionActive → collisionEnd) - so a
40
+ * freshly-created pair's `pair.contacts[*].normalImpulse`/`tangentImpulse` are still their
41
+ * just-initialized `0`, not yet a meaningful number, for every pair this event could ever
42
+ * report. Rather than reading always-zero data, `impulse` here is estimated as
43
+ * `|relativeVelocity| * min(massA, massB)` (a rough "how hard did they hit" proxy - a static
44
+ * body's `mass` is `Infinity`, so `min` naturally reduces to the dynamic side's mass when one
45
+ * side is static).
46
+ */
47
+ private handleCollisionStart;
48
+ /** Wires `IRigidBody2dComponent.onCollisionEnd` for every non-sensor pair that just stopped
49
+ * touching - see `handleCollisionStart`'s doc for why sensor pairs are skipped and why this is
50
+ * registered once per engine rather than per-body. */
51
+ private handleCollisionEnd;
22
52
  protected lockedCollisionGroups: number[];
23
53
  registerCollisionGroup(): CollisionGroup;
24
54
  deregisterCollisionGroup(group: CollisionGroup): void;
@@ -7,9 +7,33 @@ 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 { Engine } from 'matter-js';
10
+ import { Pnt2, } from '@gg-web-engine/core';
11
+ import { Engine, Events } from 'matter-js';
11
12
  import { MatterFactory } from '../matter-factory';
12
13
  import { Subject } from 'rxjs';
14
+ /**
15
+ * Averages matter-js's collision support points into a single representative contact point for
16
+ * `CollisionEvent.position`. `collision.supports` is *always* a fixed-length-2 array
17
+ * (`[null, null]` initially - see `Collision.js`'s `Collision.create`), but `@types/matter-js`
18
+ * 0.20.2 types it as plain `Vector[]` with no way to tell which slots are actually populated -
19
+ * only the first `collision.supportCount` entries are ever real, the rest can be `null` (or stale
20
+ * leftovers from a previous collision reusing the same record). Reading past `supportCount` (or
21
+ * trusting `.length`, always 2) crashes on the `null` slot - always slice to `supportCount` first.
22
+ */
23
+ function averageSupports(collision) {
24
+ // `supportCount` is real at runtime (see `Collision.js`) but missing from `@types/matter-js`
25
+ // 0.20.2's `Collision` typing entirely - cast narrowly just for this one field.
26
+ const supportCount = collision.supportCount;
27
+ const supports = collision.supports.slice(0, supportCount);
28
+ if (supports.length === 0) {
29
+ return Pnt2.O;
30
+ }
31
+ let sum = Pnt2.O;
32
+ for (const support of supports) {
33
+ sum = Pnt2.add(sum, support);
34
+ }
35
+ return Pnt2.scalarMult(sum, 1 / supports.length);
36
+ }
13
37
  // TODO probably should be configurable in world
14
38
  const MATTER_WORLD_SCALE = 0.0001;
15
39
  export class MatterWorldComponent {
@@ -34,21 +58,105 @@ export class MatterWorldComponent {
34
58
  this.added$ = new Subject();
35
59
  this.removed$ = new Subject();
36
60
  this.children = [];
61
+ /** Mirrors the rapier packages' `handleIdEntityMap` pattern: `Body.id` (matter-js's own
62
+ * globally-unique numeric id, assigned once per body via `Body.nextId` and stable for its whole
63
+ * 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. */
65
+ this.handleIdEntityMap = new Map();
37
66
  this._gravity = { x: 0, y: 9.82 };
38
67
  this.mainCollisionGroup = 0;
39
68
  this.lockedCollisionGroups = [];
40
69
  this.lastDelta = 0;
41
- this.added$.subscribe(c => this.children.push(c));
42
- this.removed$.subscribe(c => this.children.splice(this.children.indexOf(c), 1));
70
+ this.added$.subscribe(c => {
71
+ this.children.push(c);
72
+ this.handleIdEntityMap.set(c.nativeBody.id, c);
73
+ });
74
+ this.removed$.subscribe(c => {
75
+ this.children.splice(this.children.indexOf(c), 1);
76
+ this.handleIdEntityMap.delete(c.nativeBody.id);
77
+ });
43
78
  this.factory = new MatterFactory(this);
79
+ this.handleCollisionStart = this.handleCollisionStart.bind(this);
80
+ this.handleCollisionEnd = this.handleCollisionEnd.bind(this);
44
81
  }
45
82
  init() {
46
83
  return __awaiter(this, void 0, void 0, function* () {
47
84
  this.matterEngine_ = Engine.create({
48
85
  gravity: Object.assign(Object.assign({}, this._gravity), { scale: MATTER_WORLD_SCALE }),
49
86
  });
87
+ Events.on(this.matterEngine_, 'collisionStart', this.handleCollisionStart);
88
+ Events.on(this.matterEngine_, 'collisionEnd', this.handleCollisionEnd);
50
89
  });
51
90
  }
91
+ findRigidBody(nativeBody) {
92
+ return this.handleIdEntityMap.get(nativeBody.id);
93
+ }
94
+ /**
95
+ * Wires `IRigidBody2dComponent.onCollisionStart` for every non-sensor pair in the world, off
96
+ * matter-js's own `Matter.Events` 'collisionStart' (fired once per engine, not per-body - see
97
+ * `gg-engine-physics-adapter-matter` for why it's registered here rather than per component).
98
+ * `pair.isSensor` (true whenever either side is a trigger's underlying body, which always has
99
+ * `isSensor: true` - see `MatterTriggerComponent`'s constructor) is skipped entirely, so a
100
+ * trigger overlapping a rigid body only ever fires the trigger's own `onEntityEntered`, never
101
+ * this.
102
+ *
103
+ * Impulse: at the point this event fires, matter-js hasn't run `Resolver.solveVelocity` yet for
104
+ * this step (that happens later in `Engine.update`, whose event order is
105
+ * collisionStart → position solve → velocity solve → collisionActive → collisionEnd) - so a
106
+ * freshly-created pair's `pair.contacts[*].normalImpulse`/`tangentImpulse` are still their
107
+ * just-initialized `0`, not yet a meaningful number, for every pair this event could ever
108
+ * report. Rather than reading always-zero data, `impulse` here is estimated as
109
+ * `|relativeVelocity| * min(massA, massB)` (a rough "how hard did they hit" proxy - a static
110
+ * body's `mass` is `Infinity`, so `min` naturally reduces to the dynamic side's mass when one
111
+ * side is static).
112
+ */
113
+ handleCollisionStart(event) {
114
+ for (const pair of event.pairs) {
115
+ if (pair.isSensor) {
116
+ continue;
117
+ }
118
+ const compA = this.findRigidBody(pair.bodyA);
119
+ const compB = this.findRigidBody(pair.bodyB);
120
+ if (!compA || !compB) {
121
+ continue;
122
+ }
123
+ const position = averageSupports(pair.collision);
124
+ // `pair.collision.normal` is normalized, but - despite `Collision.js`'s own inline comment
125
+ // claiming it's "facing away from bodyA" - empirically (verified against a floor/falling-ball
126
+ // scenario with known relative positions) it always ends up facing the *opposite* way: away
127
+ // from `pair.bodyB`, towards `pair.bodyA`. `Collision.js`'s flip check
128
+ // (`if (normalX * deltaX + normalY * deltaY >= 0) { negate }`, where `delta = bodyB.position -
129
+ // bodyA.position`) guarantees the *final* normal always has a non-positive dot product with
130
+ // the A→B delta - i.e. it points away from B, not away from A as the comment says. So it's
131
+ // `compB`'s event that gets the raw value here, and `compA`'s that needs the negation - the
132
+ // opposite pairing a literal reading of the upstream comment would suggest.
133
+ const normalFromB = Pnt2.clone(pair.collision.normal);
134
+ const normalFromA = Pnt2.neg(normalFromB);
135
+ const relativeVelocityForA = Pnt2.sub(compB.linearVelocity, compA.linearVelocity);
136
+ const relativeVelocityForB = Pnt2.neg(relativeVelocityForA);
137
+ const impulse = Pnt2.len(relativeVelocityForA) * Math.min(compA.nativeBody.mass, compB.nativeBody.mass);
138
+ const baseEvent = { position, impulse };
139
+ compA.notifyCollisionStart(Object.assign(Object.assign({}, baseEvent), { otherBody: compB, normal: normalFromA, relativeVelocity: relativeVelocityForA }));
140
+ compB.notifyCollisionStart(Object.assign(Object.assign({}, baseEvent), { otherBody: compA, normal: normalFromB, relativeVelocity: relativeVelocityForB }));
141
+ }
142
+ }
143
+ /** Wires `IRigidBody2dComponent.onCollisionEnd` for every non-sensor pair that just stopped
144
+ * touching - see `handleCollisionStart`'s doc for why sensor pairs are skipped and why this is
145
+ * registered once per engine rather than per-body. */
146
+ handleCollisionEnd(event) {
147
+ for (const pair of event.pairs) {
148
+ if (pair.isSensor) {
149
+ continue;
150
+ }
151
+ const compA = this.findRigidBody(pair.bodyA);
152
+ const compB = this.findRigidBody(pair.bodyB);
153
+ if (!compA || !compB) {
154
+ continue;
155
+ }
156
+ compA.notifyCollisionEnd(compB);
157
+ compB.notifyCollisionEnd(compA);
158
+ }
159
+ }
52
160
  registerCollisionGroup() {
53
161
  for (let i = 1; i < 16; i++) {
54
162
  if (!this.lockedCollisionGroups.includes(i)) {
@@ -69,6 +177,8 @@ export class MatterWorldComponent {
69
177
  throw new Error('Ray casting is not implemented for Matter.js');
70
178
  }
71
179
  dispose() {
180
+ Events.off(this.matterEngine, 'collisionStart', this.handleCollisionStart);
181
+ Events.off(this.matterEngine, 'collisionEnd', this.handleCollisionEnd);
72
182
  Engine.clear(this.matterEngine);
73
183
  }
74
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gg-web-engine/matter",
3
- "version": "0.0.66",
3
+ "version": "0.0.68",
4
4
  "description": "An attempt to create open source game engine for browser",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/matter-js": "0.20.2",
33
- "@gg-web-engine/core": "0.0.66",
33
+ "@gg-web-engine/core": "0.0.68",
34
34
  "@types/jest": "^30.0.0",
35
35
  "@types/node": "^26.4.0",
36
36
  "jest": "^30.5.0",
@@ -42,7 +42,7 @@
42
42
  "typescript": "~6.0.3"
43
43
  },
44
44
  "peerDependencies": {
45
- "@gg-web-engine/core": "0.0.66",
45
+ "@gg-web-engine/core": "0.0.68",
46
46
  "matter-js": "0.20.0",
47
47
  "rxjs": "7.8.2"
48
48
  },
@@ -0,0 +1,187 @@
1
+ import { CollisionEvent, Pnt2, Point2 } from '@gg-web-engine/core';
2
+ import { MatterFactory, MatterRigidBodyComponent, MatterWorldComponent } from '../../src';
3
+
4
+ describe('MatterRigidBodyComponent collision events', () => {
5
+ let world: MatterWorldComponent;
6
+ let factory: MatterFactory;
7
+
8
+ beforeEach(async () => {
9
+ if (world) {
10
+ world.dispose();
11
+ }
12
+ world = new MatterWorldComponent();
13
+ factory = new MatterFactory(world);
14
+ await world.init();
15
+ world.gravity = Pnt2.O;
16
+ });
17
+
18
+ afterAll(() => {
19
+ world.dispose();
20
+ });
21
+
22
+ it('should fire onCollisionStart reciprocally, with a sane position/normal, when a falling body lands on a floor', () => {
23
+ // static floor, top edge sits at y = -5
24
+ const floor = factory.createRigidBody(
25
+ { shape: { shape: 'SQUARE', dimensions: { x: 50, y: 10 } }, body: { dynamic: false, mass: 0 } },
26
+ { position: { x: 0, y: 0 } },
27
+ );
28
+ floor.addToWorld({ physicsWorld: world } as any);
29
+
30
+ const ball = factory.createRigidBody(
31
+ { shape: { shape: 'CIRCLE', radius: 1 }, body: { dynamic: true, mass: 1 } },
32
+ { position: { x: 0, y: -20 } },
33
+ );
34
+ ball.addToWorld({ physicsWorld: world } as any);
35
+ ball.linearVelocity = { x: 0, y: 10 };
36
+ ball.nativeBody.frictionAir = 0;
37
+
38
+ const ballEvents: CollisionEvent<Point2, MatterRigidBodyComponent>[] = [];
39
+ const floorEvents: CollisionEvent<Point2, MatterRigidBodyComponent>[] = [];
40
+ ball.onCollisionStart.subscribe(e => ballEvents.push(e));
41
+ floor.onCollisionStart.subscribe(e => floorEvents.push(e));
42
+
43
+ for (let i = 0; i < 200 && ballEvents.length === 0; i++) {
44
+ world.simulate(16);
45
+ }
46
+
47
+ expect(ballEvents.length).toBeGreaterThan(0);
48
+ expect(floorEvents.length).toBeGreaterThan(0);
49
+
50
+ const ballHit = ballEvents[0];
51
+ expect(ballHit.otherBody).toBe(floor);
52
+ expect(ballHit.impulse).toBeGreaterThan(0);
53
+ // ball fell straight down onto a flat floor - normal (pointing away from the ball, towards the
54
+ // floor) should point roughly downward (+y, matter.js's default gravity/screen convention)
55
+ expect(ballHit.normal.y).toBeGreaterThan(0.5);
56
+
57
+ const floorHit = floorEvents[0];
58
+ expect(floorHit.otherBody).toBe(ball);
59
+ // reciprocal event: normal seen by the floor should point the opposite way (towards the ball)
60
+ expect(floorHit.normal.y).toBeLessThan(-0.5);
61
+ expect(floorHit.normal.x).toBeCloseTo(-ballHit.normal.x, 5);
62
+ expect(floorHit.normal.y).toBeCloseTo(-ballHit.normal.y, 5);
63
+ // relative velocity is opposite too (otherBody velocity relative to self, on each side)
64
+ expect(floorHit.relativeVelocity.x).toBeCloseTo(-ballHit.relativeVelocity.x, 5);
65
+ expect(floorHit.relativeVelocity.y).toBeCloseTo(-ballHit.relativeVelocity.y, 5);
66
+ });
67
+
68
+ it('should not keep re-firing onCollisionStart while a body rests stably on another', () => {
69
+ const floor = factory.createRigidBody(
70
+ { shape: { shape: 'SQUARE', dimensions: { x: 50, y: 10 } }, body: { dynamic: false, mass: 0 } },
71
+ { position: { x: 0, y: 0 } },
72
+ );
73
+ floor.addToWorld({ physicsWorld: world } as any);
74
+
75
+ const ball = factory.createRigidBody(
76
+ { shape: { shape: 'CIRCLE', radius: 1 }, body: { dynamic: true, mass: 1 } },
77
+ { position: { x: 0, y: -20 } },
78
+ );
79
+ ball.addToWorld({ physicsWorld: world } as any);
80
+ ball.linearVelocity = { x: 0, y: 10 };
81
+ ball.nativeBody.frictionAir = 0;
82
+
83
+ world.gravity = { x: 0, y: 1 };
84
+
85
+ let startCount = 0;
86
+ ball.onCollisionStart.subscribe(() => startCount++);
87
+
88
+ // simulate until it lands and settles
89
+ for (let i = 0; i < 200; i++) {
90
+ world.simulate(16);
91
+ }
92
+ expect(startCount).toBeGreaterThan(0);
93
+ const countAfterLanding = startCount;
94
+
95
+ // continue simulating - resting contact must not re-fire onCollisionStart
96
+ for (let i = 0; i < 100; i++) {
97
+ world.simulate(16);
98
+ }
99
+ expect(startCount).toBe(countAfterLanding);
100
+ });
101
+
102
+ it('should fire onCollisionEnd when a body is knocked away and separates', () => {
103
+ const floor = factory.createRigidBody(
104
+ { shape: { shape: 'SQUARE', dimensions: { x: 50, y: 10 } }, body: { dynamic: false, mass: 0 } },
105
+ { position: { x: 0, y: 0 } },
106
+ );
107
+ floor.addToWorld({ physicsWorld: world } as any);
108
+
109
+ const ball = factory.createRigidBody(
110
+ { shape: { shape: 'CIRCLE', radius: 1 }, body: { dynamic: true, mass: 1 } },
111
+ { position: { x: 0, y: -20 } },
112
+ );
113
+ ball.addToWorld({ physicsWorld: world } as any);
114
+ ball.linearVelocity = { x: 0, y: 10 };
115
+ ball.nativeBody.frictionAir = 0;
116
+
117
+ let endEvents: (MatterRigidBodyComponent | null)[] = [];
118
+ ball.onCollisionEnd.subscribe(e => endEvents.push(e));
119
+
120
+ for (let i = 0; i < 200 && endEvents.length === 0; i++) {
121
+ world.simulate(16);
122
+ if (i === 50) {
123
+ // knock it straight back up, away from the floor, once it's had a chance to touch
124
+ ball.linearVelocity = { x: 0, y: -10 };
125
+ }
126
+ }
127
+
128
+ expect(endEvents.length).toBeGreaterThan(0);
129
+ expect(endEvents[0]).toBe(floor);
130
+ });
131
+
132
+ it('should emit onCollisionEnd(null) on the remaining body when the other body is removed from the world while still touching', () => {
133
+ const floor = factory.createRigidBody(
134
+ { shape: { shape: 'SQUARE', dimensions: { x: 50, y: 10 } }, body: { dynamic: false, mass: 0 } },
135
+ { position: { x: 0, y: 0 } },
136
+ );
137
+ floor.addToWorld({ physicsWorld: world } as any);
138
+
139
+ const ball = factory.createRigidBody(
140
+ { shape: { shape: 'CIRCLE', radius: 1 }, body: { dynamic: true, mass: 1 } },
141
+ { position: { x: 0, y: -20 } },
142
+ );
143
+ ball.addToWorld({ physicsWorld: world } as any);
144
+ ball.linearVelocity = { x: 0, y: 10 };
145
+ ball.nativeBody.frictionAir = 0;
146
+
147
+ let floorEndEvents: (MatterRigidBodyComponent | null)[] = [];
148
+ floor.onCollisionEnd.subscribe(e => floorEndEvents.push(e));
149
+ let ballStarted = false;
150
+ ball.onCollisionStart.subscribe(() => (ballStarted = true));
151
+
152
+ for (let i = 0; i < 200 && !ballStarted; i++) {
153
+ world.simulate(16);
154
+ }
155
+ expect(ballStarted).toBe(true);
156
+ expect(floorEndEvents.length).toBe(0);
157
+
158
+ ball.removeFromWorld({ physicsWorld: world } as any);
159
+ expect(floorEndEvents.length).toBe(1);
160
+ expect(floorEndEvents[0]).toBeNull();
161
+ });
162
+
163
+ it('should not fire the rigid body onCollisionStart for a trigger overlapping a rigid body', () => {
164
+ const trigger = factory.createTrigger({ shape: 'SQUARE', dimensions: { x: 50, y: 10 } }, { position: { x: 0, y: 0 } });
165
+ trigger.addToWorld({ physicsWorld: world } as any);
166
+
167
+ const ball = factory.createRigidBody(
168
+ { shape: { shape: 'CIRCLE', radius: 1 }, body: { dynamic: true, mass: 1 } },
169
+ { position: { x: 0, y: -20 } },
170
+ );
171
+ ball.addToWorld({ physicsWorld: world } as any);
172
+ ball.linearVelocity = { x: 0, y: 10 };
173
+ ball.nativeBody.frictionAir = 0;
174
+
175
+ let ballStartEvents = 0;
176
+ let triggerEnterEvents = 0;
177
+ ball.onCollisionStart.subscribe(() => ballStartEvents++);
178
+ trigger.onEntityEntered.subscribe(() => triggerEnterEvents++);
179
+
180
+ for (let i = 0; i < 200; i++) {
181
+ world.simulate(16);
182
+ }
183
+
184
+ expect(triggerEnterEvents).toBeGreaterThan(0);
185
+ expect(ballStartEvents).toBe(0);
186
+ });
187
+ });
@@ -18,13 +18,14 @@ describe('MatterWorldComponent', () => {
18
18
  });
19
19
 
20
20
  describe('Rigid bodies', () => {
21
-
22
21
  it('should simulate inertial motion of rigid body', () => {
23
-
24
- const circle = world.factory.createRigidBody({
25
- shape: { shape: 'CIRCLE', radius: 1 },
26
- body: { dynamic: true, mass: 5 },
27
- }, { position: { x: -5, y: 0 } });
22
+ const circle = world.factory.createRigidBody(
23
+ {
24
+ shape: { shape: 'CIRCLE', radius: 1 },
25
+ body: { dynamic: true, mass: 5 },
26
+ },
27
+ { position: { x: -5, y: 0 } },
28
+ );
28
29
  circle.addToWorld({ physicsWorld: world } as any);
29
30
  circle.linearVelocity = { x: 1, y: 0 };
30
31
  circle.nativeBody.frictionAir = 0;
@@ -37,19 +38,24 @@ describe('MatterWorldComponent', () => {
37
38
  });
38
39
 
39
40
  it('should simulate collision of two rigid bodies', () => {
40
-
41
- const circle0 = world.factory.createRigidBody({
42
- shape: { shape: 'CIRCLE', radius: 1 },
43
- body: { dynamic: true, mass: 5 },
44
- }, { position: { x: -5, y: 0 } });
41
+ const circle0 = world.factory.createRigidBody(
42
+ {
43
+ shape: { shape: 'CIRCLE', radius: 1 },
44
+ body: { dynamic: true, mass: 5 },
45
+ },
46
+ { position: { x: -5, y: 0 } },
47
+ );
45
48
  circle0.addToWorld({ physicsWorld: world } as any);
46
49
  circle0.linearVelocity = { x: 1, y: 0 };
47
50
  circle0.nativeBody.frictionAir = 0;
48
51
 
49
- const circle1 = world.factory.createRigidBody({
50
- shape: { shape: 'CIRCLE', radius: 1 },
51
- body: { dynamic: true, mass: 5 },
52
- }, { position: { x: 5, y: 0 } });
52
+ const circle1 = world.factory.createRigidBody(
53
+ {
54
+ shape: { shape: 'CIRCLE', radius: 1 },
55
+ body: { dynamic: true, mass: 5 },
56
+ },
57
+ { position: { x: 5, y: 0 } },
58
+ );
53
59
  circle1.addToWorld({ physicsWorld: world } as any);
54
60
  circle1.linearVelocity = { x: -1, y: 0 };
55
61
  circle1.nativeBody.frictionAir = 0;
@@ -62,22 +68,42 @@ describe('MatterWorldComponent', () => {
62
68
  expect(circle1.position.x).toBeGreaterThan(0);
63
69
  });
64
70
 
71
+ it('keeps handleIdEntityMap (the O(1) findRigidBody lookup) in sync with children on add/remove', () => {
72
+ const circle = world.factory.createRigidBody(
73
+ { shape: { shape: 'CIRCLE', radius: 1 }, body: { dynamic: true, mass: 1 } },
74
+ { position: { x: 0, y: 0 } },
75
+ );
76
+ circle.addToWorld({ physicsWorld: world } as any);
77
+
78
+ expect(world.handleIdEntityMap.get(circle.nativeBody.id)).toBe(circle);
79
+
80
+ circle.removeFromWorld({ physicsWorld: world } as any);
81
+
82
+ expect(world.handleIdEntityMap.has(circle.nativeBody.id)).toBe(false);
83
+ });
84
+
65
85
  it('should not simulate collision of two rigid bodies with different collision groups', () => {
66
86
  const cg0 = world.registerCollisionGroup();
67
- const circle0 = world.factory.createRigidBody({
68
- shape: { shape: 'CIRCLE', radius: 1 },
69
- body: { dynamic: true, mass: 5 },
70
- }, { position: { x: -5, y: 0 } });
87
+ const circle0 = world.factory.createRigidBody(
88
+ {
89
+ shape: { shape: 'CIRCLE', radius: 1 },
90
+ body: { dynamic: true, mass: 5 },
91
+ },
92
+ { position: { x: -5, y: 0 } },
93
+ );
71
94
  circle0.addToWorld({ physicsWorld: world } as any);
72
95
  circle0.linearVelocity = { x: 1, y: 0 };
73
96
  circle0.nativeBody.frictionAir = 0;
74
97
  circle0.ownCollisionGroups = circle0.interactWithCollisionGroups = [cg0];
75
98
 
76
99
  const cg1 = world.registerCollisionGroup();
77
- const circle1 = world.factory.createRigidBody({
78
- shape: { shape: 'CIRCLE', radius: 1 },
79
- body: { dynamic: true, mass: 5 },
80
- }, { position: { x: 5, y: 0 } });
100
+ const circle1 = world.factory.createRigidBody(
101
+ {
102
+ shape: { shape: 'CIRCLE', radius: 1 },
103
+ body: { dynamic: true, mass: 5 },
104
+ },
105
+ { position: { x: 5, y: 0 } },
106
+ );
81
107
  circle1.addToWorld({ physicsWorld: world } as any);
82
108
  circle1.linearVelocity = { x: -1, y: 0 };
83
109
  circle1.nativeBody.frictionAir = 0;
@@ -93,13 +119,15 @@ describe('MatterWorldComponent', () => {
93
119
  });
94
120
 
95
121
  describe.skip('Raycast', () => {
96
-
97
122
  it('should return no hit when ray does not intersect any object', () => {
98
123
  // Create a square far away from the ray
99
- const square = world.factory.createRigidBody({
100
- shape: { shape: 'SQUARE', dimensions: { x: 1, y: 1 } },
101
- body: { dynamic: false, mass: 0 },
102
- }, { position: { x: 10, y: 10 } });
124
+ const square = world.factory.createRigidBody(
125
+ {
126
+ shape: { shape: 'SQUARE', dimensions: { x: 1, y: 1 } },
127
+ body: { dynamic: false, mass: 0 },
128
+ },
129
+ { position: { x: 10, y: 10 } },
130
+ );
103
131
  square.addToWorld({ physicsWorld: world } as any);
104
132
 
105
133
  world.simulate(1);
@@ -119,10 +147,13 @@ describe('MatterWorldComponent', () => {
119
147
 
120
148
  it('should detect hit when ray intersects an object', () => {
121
149
  // Create a square in the path of the ray
122
- const square = world.factory.createRigidBody({
123
- shape: { shape: 'SQUARE', dimensions: { x: 2, y: 2 } },
124
- body: { dynamic: false, mass: 0 },
125
- }, { position: { x: 0, y: -5 } });
150
+ const square = world.factory.createRigidBody(
151
+ {
152
+ shape: { shape: 'SQUARE', dimensions: { x: 2, y: 2 } },
153
+ body: { dynamic: false, mass: 0 },
154
+ },
155
+ { position: { x: 0, y: -5 } },
156
+ );
126
157
  square.addToWorld({ physicsWorld: world } as any);
127
158
 
128
159
  world.simulate(1);
@@ -153,15 +184,18 @@ describe('MatterWorldComponent', () => {
153
184
  const group2 = world.registerCollisionGroup();
154
185
 
155
186
  // Create a square that only belongs to group1
156
- const square1 = world.factory.createRigidBody({
157
- shape: { shape: 'SQUARE', dimensions: { x: 2, y: 2 } },
158
- body: {
159
- dynamic: false,
160
- mass: 0,
161
- ownCollisionGroups: [group1],
162
- interactWithCollisionGroups: [group1, group2],
187
+ const square1 = world.factory.createRigidBody(
188
+ {
189
+ shape: { shape: 'SQUARE', dimensions: { x: 2, y: 2 } },
190
+ body: {
191
+ dynamic: false,
192
+ mass: 0,
193
+ ownCollisionGroups: [group1],
194
+ interactWithCollisionGroups: [group1, group2],
195
+ },
163
196
  },
164
- }, { position: { x: 0, y: -5 } });
197
+ { position: { x: 0, y: -5 } },
198
+ );
165
199
  square1.addToWorld({ physicsWorld: world } as any);
166
200
 
167
201
  world.simulate(1);
@@ -192,10 +226,13 @@ describe('MatterWorldComponent', () => {
192
226
 
193
227
  it('should calculate hit distance correctly', () => {
194
228
  // Create a square at a known distance
195
- const square = world.factory.createRigidBody({
196
- shape: { shape: 'SQUARE', dimensions: { x: 2, y: 2 } },
197
- body: { dynamic: false, mass: 0 },
198
- }, { position: { x: 0, y: -5 } });
229
+ const square = world.factory.createRigidBody(
230
+ {
231
+ shape: { shape: 'SQUARE', dimensions: { x: 2, y: 2 } },
232
+ body: { dynamic: false, mass: 0 },
233
+ },
234
+ { position: { x: 0, y: -5 } },
235
+ );
199
236
  square.addToWorld({ physicsWorld: world } as any);
200
237
 
201
238
  world.simulate(1);
@@ -220,15 +257,18 @@ describe('MatterWorldComponent', () => {
220
257
  const group2 = world.registerCollisionGroup();
221
258
 
222
259
  // Create a square that belongs to group1
223
- const square = world.factory.createRigidBody({
224
- shape: { shape: 'SQUARE', dimensions: { x: 2, y: 2 } },
225
- body: {
226
- dynamic: false,
227
- mass: 0,
228
- ownCollisionGroups: [group1],
229
- interactWithCollisionGroups: [group1, group2],
260
+ const square = world.factory.createRigidBody(
261
+ {
262
+ shape: { shape: 'SQUARE', dimensions: { x: 2, y: 2 } },
263
+ body: {
264
+ dynamic: false,
265
+ mass: 0,
266
+ ownCollisionGroups: [group1],
267
+ interactWithCollisionGroups: [group1, group2],
268
+ },
230
269
  },
231
- }, { position: { x: 0, y: -5 } });
270
+ { position: { x: 0, y: -5 } },
271
+ );
232
272
  square.addToWorld({ physicsWorld: world } as any);
233
273
 
234
274
  world.simulate(1);
@@ -247,16 +287,22 @@ describe('MatterWorldComponent', () => {
247
287
 
248
288
  it('should return correct hit body', () => {
249
289
  // Create two squares at different positions
250
- const square1 = world.factory.createRigidBody({
251
- shape: { shape: 'SQUARE', dimensions: { x: 1, y: 1 } },
252
- body: { dynamic: false, mass: 0 },
253
- }, { position: { x: 0, y: -3 } });
290
+ const square1 = world.factory.createRigidBody(
291
+ {
292
+ shape: { shape: 'SQUARE', dimensions: { x: 1, y: 1 } },
293
+ body: { dynamic: false, mass: 0 },
294
+ },
295
+ { position: { x: 0, y: -3 } },
296
+ );
254
297
  square1.addToWorld({ physicsWorld: world } as any);
255
298
 
256
- const square2 = world.factory.createRigidBody({
257
- shape: { shape: 'SQUARE', dimensions: { x: 1, y: 1 } },
258
- body: { dynamic: false, mass: 0 },
259
- }, { position: { x: 0, y: -7 } });
299
+ const square2 = world.factory.createRigidBody(
300
+ {
301
+ shape: { shape: 'SQUARE', dimensions: { x: 1, y: 1 } },
302
+ body: { dynamic: false, mass: 0 },
303
+ },
304
+ { position: { x: 0, y: -7 } },
305
+ );
260
306
  square2.addToWorld({ physicsWorld: world } as any);
261
307
 
262
308
  world.simulate(1);
@@ -275,10 +321,13 @@ describe('MatterWorldComponent', () => {
275
321
 
276
322
  it('should handle edge case with ray starting inside an object', () => {
277
323
  // Create a square
278
- const square = world.factory.createRigidBody({
279
- shape: { shape: 'SQUARE', dimensions: { x: 4, y: 4 } },
280
- body: { dynamic: false, mass: 0 },
281
- }, { position: { x: 0, y: 0 } });
324
+ const square = world.factory.createRigidBody(
325
+ {
326
+ shape: { shape: 'SQUARE', dimensions: { x: 4, y: 4 } },
327
+ body: { dynamic: false, mass: 0 },
328
+ },
329
+ { position: { x: 0, y: 0 } },
330
+ );
282
331
  square.addToWorld({ physicsWorld: world } as any);
283
332
 
284
333
  world.simulate(1);
@@ -298,4 +347,4 @@ describe('MatterWorldComponent', () => {
298
347
  expect(result).toBeDefined();
299
348
  });
300
349
  });
301
- });
350
+ });