@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gg-web-engine/matter",
3
- "version": "0.0.72",
3
+ "version": "0.0.73",
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",
@@ -27,11 +27,12 @@
27
27
  },
28
28
  "homepage": "https://github.com/AndyGura/gg-web-engine#readme",
29
29
  "dependencies": {
30
+ "poly-decomp": "^0.3.0"
30
31
  },
31
32
  "devDependencies": {
32
- "@types/matter-js": "0.20.2",
33
- "@gg-web-engine/core": "0.0.72",
33
+ "@gg-web-engine/core": "0.0.73",
34
34
  "@types/jest": "^30.0.0",
35
+ "@types/matter-js": "0.20.2",
35
36
  "@types/node": "^26.4.0",
36
37
  "jest": "^30.5.0",
37
38
  "jest-environment-jsdom": "^30.5.0",
@@ -42,7 +43,7 @@
42
43
  "typescript": "~6.0.3"
43
44
  },
44
45
  "peerDependencies": {
45
- "@gg-web-engine/core": "0.0.72",
46
+ "@gg-web-engine/core": "0.0.73",
46
47
  "matter-js": "0.20.0",
47
48
  "rxjs": "7.8.2"
48
49
  },
@@ -0,0 +1,298 @@
1
+ import { CharacterController2dOptions, Pnt2 } from '@gg-web-engine/core';
2
+ import { MatterFactory, MatterWorldComponent } from '../../src';
3
+
4
+ describe('MatterCharacterControllerComponent', () => {
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; // this component never relies on gravity - see the interface's own doc
16
+ });
17
+
18
+ afterAll(() => {
19
+ world.dispose();
20
+ });
21
+
22
+ const CHAR_OPTIONS: CharacterController2dOptions = { radius: 0.4, centersDistance: 1.0 };
23
+ // center-to-feet distance for the capsule above: radius + centersDistance / 2
24
+ const HALF_HEIGHT = CHAR_OPTIONS.radius + CHAR_OPTIONS.centersDistance / 2;
25
+
26
+ // A static floor slab spanning [centerX - sizeX/2, centerX + sizeX/2] on X, with its top face (the
27
+ // side facing `up` = `{x:0,y:-1}`, i.e. the smaller-Y side) at world-space `topY`.
28
+ const addFloor = (centerX: number, sizeX: number, topY: number, thickness = 1) => {
29
+ const floor = factory.createRigidBody(
30
+ {
31
+ shape: { shape: 'BOX', dimensions: { x: sizeX, y: thickness } },
32
+ body: { bodyType: 'static', mass: 0 },
33
+ },
34
+ { position: { x: centerX, y: topY + thickness / 2 } },
35
+ );
36
+ floor.addToWorld({ physicsWorld: world } as any);
37
+ return floor;
38
+ };
39
+
40
+ // A static vertical wall whose near face is at world-space X `x`.
41
+ const addWall = (x: number, thickness = 1) => {
42
+ const wall = factory.createRigidBody(
43
+ {
44
+ shape: { shape: 'BOX', dimensions: { x: thickness, y: 20 } },
45
+ body: { bodyType: 'static', mass: 0 },
46
+ },
47
+ { position: { x: x + thickness / 2, y: 0 } },
48
+ );
49
+ wall.addToWorld({ physicsWorld: world } as any);
50
+ return wall;
51
+ };
52
+
53
+ // Unlike Ammo/Rapier, matter-js has no broad-phase-registration lag at all - `Composite.add`
54
+ // synchronously updates `Composite.allBodies`, which is exactly what this component's own
55
+ // `Query.collides`-based queries read - so there is no "world hasn't stepped yet" pitfall here to
56
+ // work around with a settle call. Kept only as a documented no-op for structural parity with the
57
+ // other adapters' test suites.
58
+ const settleWorld = () => world.simulate(0);
59
+
60
+ it('should create a character controller and add it to the world', () => {
61
+ const character = factory.createCharacterController(CHAR_OPTIONS, { position: { x: 0, y: -5 } });
62
+ expect(character.radius).toBe(0.4);
63
+ expect(character.centersDistance).toBe(1.0);
64
+ character.addToWorld({ physicsWorld: world } as any);
65
+ expect(world.children).toContain(character);
66
+ expect(character.position).toEqual({ x: 0, y: -5 });
67
+ });
68
+
69
+ it('should settle on a flat floor and report grounded state', () => {
70
+ addFloor(0, 20, 0);
71
+ const character = factory.createCharacterController(CHAR_OPTIONS, { position: { x: 0, y: -5 } });
72
+ character.addToWorld({ physicsWorld: world } as any);
73
+ settleWorld();
74
+
75
+ expect(character.isGrounded).toBe(false);
76
+
77
+ character.move({ x: 0, y: 10 });
78
+
79
+ // resolved fully synchronously - no world.simulate() call happened after settleWorld() above
80
+ expect(character.isGrounded).toBe(true);
81
+ expect(character.position.y).toBeCloseTo(-HALF_HEIGHT, 1);
82
+ expect(character.groundNormal).not.toBeNull();
83
+ expect(character.groundNormal!.y).toBeLessThan(-0.9);
84
+ });
85
+
86
+ it('should slide to a stop against a wall instead of passing through it', () => {
87
+ addFloor(0, 20, 0);
88
+ addWall(2);
89
+ const character = factory.createCharacterController(CHAR_OPTIONS, {
90
+ position: { x: 0, y: -(HALF_HEIGHT + 0.5) },
91
+ });
92
+ character.addToWorld({ physicsWorld: world } as any);
93
+ settleWorld();
94
+ // settle onto the floor first
95
+ character.move({ x: 0, y: 1 });
96
+ expect(character.isGrounded).toBe(true);
97
+
98
+ character.move({ x: 5, y: 0 });
99
+
100
+ // wall's near face is at x=2; the capsule (radius 0.4) plus its skin offset must stop short of it
101
+ expect(character.position.x).toBeLessThan(1.6);
102
+ expect(character.position.x).toBeGreaterThan(0.5);
103
+ // remained grounded throughout - sliding along the wall, not falling
104
+ expect(character.isGrounded).toBe(true);
105
+ });
106
+
107
+ it(
108
+ 'two character controllers should block each other instead of passing through (regression: a ' +
109
+ "character's own phantom body is never added to Composite/engine.world, so it was invisible to " +
110
+ "any other character's obstacle collection, which only ever enumerated Composite.allBodies)",
111
+ () => {
112
+ addFloor(0, 20, 0);
113
+ const characterA = factory.createCharacterController(CHAR_OPTIONS, {
114
+ position: { x: 0, y: -(HALF_HEIGHT + 0.5) },
115
+ });
116
+ const characterB = factory.createCharacterController(CHAR_OPTIONS, {
117
+ position: { x: 3, y: -(HALF_HEIGHT + 0.5) },
118
+ });
119
+ characterA.addToWorld({ physicsWorld: world } as any);
120
+ characterB.addToWorld({ physicsWorld: world } as any);
121
+ settleWorld();
122
+ characterA.move({ x: 0, y: 1 });
123
+ characterB.move({ x: 0, y: 1 });
124
+
125
+ for (let i = 0; i < 20; i++) {
126
+ characterA.move({ x: 0.2, y: 0 });
127
+ }
128
+
129
+ // characterB sits at x=3 and never moves - characterA must be blocked well short of it, not
130
+ // walk straight through to make its full requested 4 units of horizontal progress.
131
+ expect(characterA.position.x).toBeLessThan(2.5);
132
+ expect(characterB.position.x).toBe(3);
133
+ },
134
+ );
135
+
136
+ it(
137
+ 'walks straight through a body added to ignoredBodies instead of sliding to a stop against it ' +
138
+ "(regression: collision groups alone can't express excluding just one specific body while both " +
139
+ 'it and the character still need to collide with the rest of the world - see ' +
140
+ '`ICharacterController2dComponent.ignoredBodies`\'s doc)',
141
+ () => {
142
+ addFloor(0, 20, 0);
143
+ const wall = addWall(2);
144
+ const character = factory.createCharacterController(CHAR_OPTIONS, {
145
+ position: { x: 0, y: -(HALF_HEIGHT + 0.5) },
146
+ });
147
+ character.addToWorld({ physicsWorld: world } as any);
148
+ character.ignoredBodies.add(wall);
149
+ settleWorld();
150
+ character.move({ x: 0, y: 1 });
151
+
152
+ character.move({ x: 5, y: 0 });
153
+
154
+ // unlike the baseline test above, nothing stopped it short of the wall's x=2 near face
155
+ expect(character.position.x).toBeCloseTo(5);
156
+ },
157
+ );
158
+
159
+ it('removing a body from ignoredBodies makes it block the character again', () => {
160
+ addFloor(0, 20, 0);
161
+ const wall = addWall(2);
162
+ const character = factory.createCharacterController(CHAR_OPTIONS, {
163
+ position: { x: 0, y: -(HALF_HEIGHT + 0.5) },
164
+ });
165
+ character.addToWorld({ physicsWorld: world } as any);
166
+ character.ignoredBodies.add(wall);
167
+ character.ignoredBodies.delete(wall);
168
+ settleWorld();
169
+ character.move({ x: 0, y: 1 });
170
+
171
+ character.move({ x: 5, y: 0 });
172
+
173
+ expect(character.position.x).toBeLessThan(1.6);
174
+ });
175
+
176
+ it('should step up a ledge shorter than maxStepHeight without getting stuck', () => {
177
+ const maxStepHeight = 0.3;
178
+ const stepHeight = 0.2; // below maxStepHeight
179
+ addFloor(-5, 10, 0); // lower floor, spans x in [-10, 0]
180
+ addFloor(5, 10, -stepHeight); // upper ledge, spans x in [0, 10], step wall at x=0
181
+
182
+ const character = factory.createCharacterController(
183
+ { ...CHAR_OPTIONS, maxStepHeight },
184
+ { position: { x: -5, y: -(HALF_HEIGHT + 0.5) } },
185
+ );
186
+ character.addToWorld({ physicsWorld: world } as any);
187
+ settleWorld();
188
+ character.move({ x: 0, y: 1 });
189
+ expect(character.isGrounded).toBe(true);
190
+ expect(character.position.y).toBeCloseTo(-HALF_HEIGHT, 1);
191
+
192
+ // walk forward across the step in small increments, each with a slight downward bias so the
193
+ // character does not need to rely on gravity (which this component never applies itself)
194
+ for (let i = 0; i < 60; i++) {
195
+ character.move({ x: 0.15, y: 0.05 });
196
+ }
197
+
198
+ expect(character.isGrounded).toBe(true);
199
+ expect(character.position.x).toBeGreaterThan(2); // made meaningful forward progress past the step
200
+ expect(character.position.y).toBeCloseTo(-(stepHeight + HALF_HEIGHT), 1); // stepped up onto the ledge
201
+ });
202
+
203
+ it('a small upward move (a jump takeoff tick) actually rises instead of being snapped back to the floor, even though it stays well within the default snapToGroundDistance of 0.3', () => {
204
+ addFloor(0, 20, 0);
205
+ const character = factory.createCharacterController(CHAR_OPTIONS, {
206
+ position: { x: 0, y: -(HALF_HEIGHT + 0.5) },
207
+ });
208
+ character.addToWorld({ physicsWorld: world } as any);
209
+ settleWorld();
210
+ character.move({ x: 0, y: 1 });
211
+ expect(character.isGrounded).toBe(true);
212
+ const groundedY = character.position.y;
213
+
214
+ // one tick's worth of a typical jumpSpeed (5 units/s) at 60fps - well under the 0.3 snapToGroundDistance
215
+ character.move({ x: 0, y: -0.083 });
216
+
217
+ expect(character.position.y).toBeCloseTo(groundedY - 0.083, 2);
218
+ expect(character.isGrounded).toBe(false);
219
+ });
220
+
221
+ it("move()'s effects must be visible immediately, without an extra world.step() in between", () => {
222
+ addFloor(0, 20, 0);
223
+ addWall(1);
224
+ const character = factory.createCharacterController(CHAR_OPTIONS, {
225
+ position: { x: 0, y: -(HALF_HEIGHT + 0.01) },
226
+ });
227
+ character.addToWorld({ physicsWorld: world } as any);
228
+ settleWorld();
229
+
230
+ // no world.simulate() call below this point - move() alone must fully resolve state
231
+ character.move({ x: 10, y: 0 });
232
+
233
+ expect(character.position.x).toBeLessThan(0.6);
234
+ expect(character.position.x).toBeGreaterThan(-0.1);
235
+ });
236
+
237
+ it('should clone with the same shape/options but independent from the world', () => {
238
+ const character = factory.createCharacterController(
239
+ { radius: 0.3, centersDistance: 0.8, maxStepHeight: 0.25 },
240
+ { position: { x: 1, y: 2 } },
241
+ );
242
+ const clone = character.clone();
243
+ expect(clone.radius).toBe(character.radius);
244
+ expect(clone.centersDistance).toBe(character.centersDistance);
245
+ expect(clone.position).toEqual(character.position);
246
+ });
247
+
248
+ it('should clone from the CURRENT position/rotation, not the construction-time spawn point', () => {
249
+ const character = factory.createCharacterController(CHAR_OPTIONS, { position: { x: 0, y: -5 } });
250
+ character.addToWorld({ physicsWorld: world } as any);
251
+ settleWorld();
252
+ // move the character away from its construction-time position via the position setter, the
253
+ // same code path that leaves any construction-time transform stale once the component is added
254
+ // to a world and driven directly
255
+ character.position = { x: 10, y: 20 };
256
+
257
+ const clone = character.clone();
258
+
259
+ expect(clone.position).toEqual(character.position);
260
+ expect(clone.position).not.toEqual({ x: 0, y: -5 });
261
+ });
262
+
263
+ it(
264
+ 'should clone from the CURRENT up/ownCollisionGroups/interactWithCollisionGroups, not whatever ' +
265
+ 'was passed at construction time (regression: clone() used to rebuild from the frozen ' +
266
+ "construction-time options object, silently discarding any of these three fields' live " +
267
+ 'mutations via their own setters)',
268
+ () => {
269
+ const character = factory.createCharacterController(CHAR_OPTIONS, { position: { x: 0, y: -5 } });
270
+ character.up = { x: 1, y: 0 };
271
+ character.ownCollisionGroups = [world.registerCollisionGroup()];
272
+ character.interactWithCollisionGroups = [world.registerCollisionGroup()];
273
+
274
+ const clone = character.clone();
275
+
276
+ expect(clone.up).toEqual(character.up);
277
+ expect(clone.ownCollisionGroups).toEqual(character.ownCollisionGroups);
278
+ expect(clone.interactWithCollisionGroups).toEqual(character.interactWithCollisionGroups);
279
+ },
280
+ );
281
+
282
+ it('move() before addToWorld() should be a silent no-op, not throw', () => {
283
+ const character = factory.createCharacterController(CHAR_OPTIONS, { position: { x: 0, y: -5 } });
284
+ expect(() => character.move({ x: 1, y: 0 })).not.toThrow();
285
+ expect(character.position).toEqual({ x: 0, y: -5 });
286
+ });
287
+
288
+ it('should normalize a non-unit-length up vector, matching the 3D adapter', () => {
289
+ const character = factory.createCharacterController(
290
+ { ...CHAR_OPTIONS, up: { x: 0, y: 2 } },
291
+ { position: { x: 0, y: -5 } },
292
+ );
293
+ expect(character.up).toEqual({ x: 0, y: 1 });
294
+
295
+ character.up = { x: 0, y: 3 };
296
+ expect(character.up).toEqual({ x: 0, y: 1 });
297
+ });
298
+ });
@@ -22,7 +22,7 @@ describe('MatterRigidBodyComponent collision events', () => {
22
22
  it('should fire onCollisionStart reciprocally, with a sane position/normal, when a falling body lands on a floor', () => {
23
23
  // static floor, top edge sits at y = -5
24
24
  const floor = factory.createRigidBody(
25
- { shape: { shape: 'SQUARE', dimensions: { x: 50, y: 10 } }, body: { bodyType: 'static', mass: 0 } },
25
+ { shape: { shape: 'BOX', dimensions: { x: 50, y: 10 } }, body: { bodyType: 'static', mass: 0 } },
26
26
  { position: { x: 0, y: 0 } },
27
27
  );
28
28
  floor.addToWorld({ physicsWorld: world } as any);
@@ -67,18 +67,25 @@ describe('MatterRigidBodyComponent collision events', () => {
67
67
 
68
68
  it('should not keep re-firing onCollisionStart while a body rests stably on another', () => {
69
69
  const floor = factory.createRigidBody(
70
- { shape: { shape: 'SQUARE', dimensions: { x: 50, y: 10 } }, body: { bodyType: 'static', mass: 0 } },
70
+ { shape: { shape: 'BOX', dimensions: { x: 50, y: 10 } }, body: { bodyType: 'static', mass: 0 } },
71
71
  { position: { x: 0, y: 0 } },
72
72
  );
73
73
  floor.addToWorld({ physicsWorld: world } as any);
74
74
 
75
+ // A BOX, not a CIRCLE: a circle has no flat resting face, so under this engine's default (no
76
+ // sleeping - see `gg-engine-physics-adapter-matter`'s own note) it keeps finding tiny amounts of
77
+ // torque to roll away on indefinitely instead of ever truly settling, which is exactly the kind
78
+ // of never-stops-touching-and-un-touching motion this test's own premise ("rests stably") needs
79
+ // to rule out by construction. Also left at matter's own default `frictionAir` (not zeroed like
80
+ // the other tests in this file) - some velocity damping is what actually lets a resting body's
81
+ // energy bleed off instead of slowly building up over many hundreds of steps (see this file's own
82
+ // history for how thoroughly this was verified empirically).
75
83
  const ball = factory.createRigidBody(
76
- { shape: { shape: 'CIRCLE', radius: 1 }, body: { bodyType: 'dynamic', mass: 1 } },
84
+ { shape: { shape: 'BOX', dimensions: { x: 2, y: 2 } }, body: { bodyType: 'dynamic', mass: 1 } },
77
85
  { position: { x: 0, y: -20 } },
78
86
  );
79
87
  ball.addToWorld({ physicsWorld: world } as any);
80
88
  ball.linearVelocity = { x: 0, y: 10 };
81
- ball.nativeBody.frictionAir = 0;
82
89
 
83
90
  world.gravity = { x: 0, y: 1 };
84
91
 
@@ -101,28 +108,35 @@ describe('MatterRigidBodyComponent collision events', () => {
101
108
 
102
109
  it('should fire onCollisionEnd when a body is knocked away and separates', () => {
103
110
  const floor = factory.createRigidBody(
104
- { shape: { shape: 'SQUARE', dimensions: { x: 50, y: 10 } }, body: { bodyType: 'static', mass: 0 } },
111
+ { shape: { shape: 'BOX', dimensions: { x: 50, y: 10 } }, body: { bodyType: 'static', mass: 0 } },
105
112
  { position: { x: 0, y: 0 } },
106
113
  );
107
114
  floor.addToWorld({ physicsWorld: world } as any);
108
115
 
116
+ // BOX, not CIRCLE - see the previous test's own doc for why.
109
117
  const ball = factory.createRigidBody(
110
- { shape: { shape: 'CIRCLE', radius: 1 }, body: { bodyType: 'dynamic', mass: 1 } },
118
+ { shape: { shape: 'BOX', dimensions: { x: 2, y: 2 } }, body: { bodyType: 'dynamic', mass: 1 } },
111
119
  { position: { x: 0, y: -20 } },
112
120
  );
113
121
  ball.addToWorld({ physicsWorld: world } as any);
114
122
  ball.linearVelocity = { x: 0, y: 10 };
115
- ball.nativeBody.frictionAir = 0;
116
123
 
117
124
  let endEvents: (MatterRigidBodyComponent | null)[] = [];
125
+ // Knock it away the instant it actually touches the floor (rather than after some fixed,
126
+ // speed/distance-dependent step count guessed to be "enough time to have touched by now") - the
127
+ // exact number of steps a fall of this distance/speed takes to make real contact isn't this
128
+ // test's concern and shouldn't be baked into a magic constant.
129
+ let knocked = false;
130
+ ball.onCollisionStart.subscribe(() => {
131
+ if (!knocked) {
132
+ knocked = true;
133
+ ball.linearVelocity = { x: 0, y: -10 };
134
+ }
135
+ });
118
136
  ball.onCollisionEnd.subscribe(e => endEvents.push(e));
119
137
 
120
138
  for (let i = 0; i < 200 && endEvents.length === 0; i++) {
121
139
  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
140
  }
127
141
 
128
142
  expect(endEvents.length).toBeGreaterThan(0);
@@ -131,7 +145,7 @@ describe('MatterRigidBodyComponent collision events', () => {
131
145
 
132
146
  it('should emit onCollisionEnd(null) on the remaining body when the other body is removed from the world while still touching', () => {
133
147
  const floor = factory.createRigidBody(
134
- { shape: { shape: 'SQUARE', dimensions: { x: 50, y: 10 } }, body: { bodyType: 'static', mass: 0 } },
148
+ { shape: { shape: 'BOX', dimensions: { x: 50, y: 10 } }, body: { bodyType: 'static', mass: 0 } },
135
149
  { position: { x: 0, y: 0 } },
136
150
  );
137
151
  floor.addToWorld({ physicsWorld: world } as any);
@@ -161,7 +175,7 @@ describe('MatterRigidBodyComponent collision events', () => {
161
175
  });
162
176
 
163
177
  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 } });
178
+ const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 50, y: 10 } }, { position: { x: 0, y: 0 } });
165
179
  trigger.addToWorld({ physicsWorld: world } as any);
166
180
 
167
181
  const ball = factory.createRigidBody(
@@ -0,0 +1,58 @@
1
+ import { Pnt2 } from '@gg-web-engine/core';
2
+ import { MatterFactory, MatterWorldComponent } from '../../src';
3
+
4
+ describe('MatterRigidBodyComponent.bodyOptions', () => {
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('reads back the mass/friction/restitution a dynamic body was actually created with', () => {
23
+ const body = factory.createRigidBody(
24
+ { shape: { shape: 'CIRCLE', radius: 1 }, body: { bodyType: 'dynamic', mass: 5, friction: 0.3, restitution: 0.7 } },
25
+ { position: { x: 0, y: 0 } },
26
+ );
27
+ body.addToWorld({ physicsWorld: world } as any);
28
+
29
+ expect(body.bodyOptions.bodyType).toBe('dynamic');
30
+ expect(body.bodyOptions.mass).toBe(5);
31
+ expect(body.bodyOptions.friction).toBe(0.3);
32
+ expect(body.bodyOptions.restitution).toBe(0.7);
33
+ expect(body.bodyOptions.ccd).toBe(false);
34
+ });
35
+
36
+ it('echoes the originally-requested bodyType/ccd even though matter-js degrades both', () => {
37
+ const body = factory.createRigidBody(
38
+ { shape: { shape: 'BOX', dimensions: { x: 1, y: 1 } }, body: { bodyType: 'kinematic_pos', mass: 1, ccd: true } },
39
+ { position: { x: 0, y: 0 } },
40
+ );
41
+
42
+ expect(body.bodyOptions.bodyType).toBe('kinematic_pos');
43
+ expect(body.bodyOptions.ccd).toBe(true);
44
+ });
45
+
46
+ it('reflects a mutated ownCollisionGroups/interactWithCollisionGroups live', () => {
47
+ const body = factory.createRigidBody(
48
+ { shape: { shape: 'CIRCLE', radius: 1 }, body: { bodyType: 'static', mass: 0 } },
49
+ { position: { x: 0, y: 0 } },
50
+ );
51
+
52
+ body.ownCollisionGroups = [3];
53
+ body.interactWithCollisionGroups = [5, 6];
54
+
55
+ expect(body.bodyOptions.ownCollisionGroups).toEqual([3]);
56
+ expect(body.bodyOptions.interactWithCollisionGroups).toEqual([5, 6]);
57
+ });
58
+ });
@@ -19,12 +19,9 @@ describe(`MatterTriggerComponent`, () => {
19
19
  world.dispose();
20
20
  });
21
21
 
22
- // FIXME matter.js logic is kinda not intuitive. Spawning objects on some coordinates seems to cause collisions with
23
- // all the objects that intersect the line between (0, 0) and desired position, sensors are flying away to the
24
- // infinity just by setting it's position and other wonderful miracles. Investigate why these tests fail and try to fix.
25
22
  // Note: this test is identical to rapier2d trigger test, and it is expected
26
- it.skip(`should detect object intersection`, async () => {
27
- const trigger = factory.createTrigger({ shape: 'SQUARE', dimensions: { x: 10, y: 10 } });
23
+ it(`should detect object intersection`, async () => {
24
+ const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10 } });
28
25
  trigger.addToWorld({ physicsWorld: world } as any);
29
26
  const circle = factory.createRigidBody({
30
27
  shape: { shape: 'CIRCLE', radius: 1 },
@@ -52,8 +49,8 @@ describe(`MatterTriggerComponent`, () => {
52
49
  expect(exitRegistered).toBe(false);
53
50
  });
54
51
 
55
- it.skip(`should detect end of object intersection`, async () => {
56
- const trigger = factory.createTrigger({ shape: 'SQUARE', dimensions: { x: 10, y: 10 } });
52
+ it(`should detect end of object intersection`, async () => {
53
+ const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10 } });
57
54
  trigger.addToWorld({ physicsWorld: world } as any);
58
55
  const circle = factory.createRigidBody({
59
56
  shape: { shape: 'CIRCLE', radius: 1 },
@@ -74,8 +71,8 @@ describe(`MatterTriggerComponent`, () => {
74
71
  expect(exitRegistered).toBe(true);
75
72
  });
76
73
 
77
- it.skip(`should fire object intersection if spawned inside`, async () => {
78
- const trigger = factory.createTrigger({ shape: 'SQUARE', dimensions: { x: 10, y: 10 } });
74
+ it(`should fire object intersection if spawned inside`, async () => {
75
+ const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10 } });
79
76
  trigger.addToWorld({ physicsWorld: world } as any);
80
77
  const circle = factory.createRigidBody({
81
78
  shape: { shape: 'CIRCLE', radius: 1 },
@@ -91,8 +88,8 @@ describe(`MatterTriggerComponent`, () => {
91
88
  expect(enterRegistered).toBe(true);
92
89
  });
93
90
 
94
- it.skip(`should fire end of object intersection if trigger removed`, async () => {
95
- const trigger = factory.createTrigger({ shape: 'SQUARE', dimensions: { x: 10, y: 10 } });
91
+ it(`should fire end of object intersection if trigger removed`, async () => {
92
+ const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10 } });
96
93
  trigger.addToWorld({ physicsWorld: world } as any);
97
94
  const circle = factory.createRigidBody({
98
95
  shape: { shape: 'CIRCLE', radius: 1 },
@@ -109,8 +106,8 @@ describe(`MatterTriggerComponent`, () => {
109
106
  expect(exitRegistered).toBe(true);
110
107
  });
111
108
 
112
- it.skip(`should fire end of object intersection if object removed`, async () => {
113
- const trigger = factory.createTrigger({ shape: 'SQUARE', dimensions: { x: 10, y: 10 } });
109
+ it(`should fire end of object intersection if object removed`, async () => {
110
+ const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10 } });
114
111
  trigger.addToWorld({ physicsWorld: world } as any);
115
112
  const circle = factory.createRigidBody({
116
113
  shape: { shape: 'CIRCLE', radius: 1 },
@@ -124,8 +121,39 @@ describe(`MatterTriggerComponent`, () => {
124
121
  world.simulate(1);
125
122
  trigger.checkOverlaps();
126
123
  circle.removeFromWorld({ physicsWorld: world } as any);
124
+ // the removed$ reaction drops the overlap synchronously but emits onEntityLeft on a microtask
125
+ // (see MatterTriggerComponent.removedSub's own doc) - nothing a later step/checkOverlaps() poll
126
+ // could still notice, so give that microtask a chance to run
127
+ await Promise.resolve();
127
128
  world.simulate(1);
128
129
  trigger.checkOverlaps();
129
130
  expect(exitRegistered).toBe(true);
130
131
  });
132
+
133
+ it(`should fire end of object intersection on the next microtask when the object is removed, with no step or checkOverlaps() poll`, async () => {
134
+ // Composite.remove never fires a native collisionEnd for the body it removes, so the only thing
135
+ // that can fire onEntityLeft for it is the trigger's own removed$ reaction. That reaction must
136
+ // not emit inline from inside the removal's own call stack (a removal can happen partway
137
+ // through an unrelated component's own lifecycle operation, e.g. a character's crouch/stand
138
+ // capsule swap - see MatterTriggerComponent.removedSub's own doc), so the emission is deferred
139
+ // to a microtask: not yet observable synchronously after removeFromWorld, observable right after
140
+ // one microtask hop, with no simulate()/checkOverlaps() call in between at all.
141
+ const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10 } });
142
+ trigger.addToWorld({ physicsWorld: world } as any);
143
+ const circle = factory.createRigidBody({
144
+ shape: { shape: 'CIRCLE', radius: 1 },
145
+ body: { bodyType: 'dynamic', mass: 1 },
146
+ }, { position: Pnt2.O });
147
+ circle.addToWorld({ physicsWorld: world } as any);
148
+ world.simulate(1);
149
+ trigger.checkOverlaps();
150
+ let exitRegistered = false;
151
+ trigger.onEntityLeft.subscribe(((obj) => {
152
+ exitRegistered = obj === circle;
153
+ }));
154
+ circle.removeFromWorld({ physicsWorld: world } as any);
155
+ expect(exitRegistered).toBe(false);
156
+ await Promise.resolve();
157
+ expect(exitRegistered).toBe(true);
158
+ });
131
159
  });