@gg-web-engine/rapier2d 0.0.57 → 0.0.58

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,7 +1,7 @@
1
- import { Body2DOptions, CollisionGroup, DebugBody2DSettings, Entity2d, Gg2dWorld, IRigidBody2dComponent, Point2, Shape2DDescriptor, VisualTypeDocRepo2D } from '@gg-web-engine/core';
1
+ import { Body2DOptions, CollisionGroup, DebugBody2DSettings, Entity2d, IRigidBody2dComponent, Point2, Shape2DDescriptor } from '@gg-web-engine/core';
2
2
  import { Collider, ColliderDesc, InteractionGroups, RigidBody, RigidBodyDesc } from '@dimforge/rapier2d-compat';
3
3
  import { Rapier2dWorldComponent } from './rapier-2d-world.component';
4
- import { Rapier2dPhysicsTypeDocRepo } from '../types';
4
+ import { Rapier2dGgWorld, Rapier2dPhysicsTypeDocRepo } from '../types';
5
5
  export declare class Rapier2dRigidBodyComponent implements IRigidBody2dComponent<Rapier2dPhysicsTypeDocRepo> {
6
6
  protected readonly world: Rapier2dWorldComponent;
7
7
  protected _colliderDescr: ColliderDesc[];
@@ -36,8 +36,8 @@ export declare class Rapier2dRigidBodyComponent implements IRigidBody2dComponent
36
36
  get ownCollisionGroups(): ReadonlyArray<CollisionGroup>;
37
37
  set ownCollisionGroups(value: ReadonlyArray<CollisionGroup> | 'all');
38
38
  clone(): Rapier2dRigidBodyComponent;
39
- addToWorld(world: Gg2dWorld<VisualTypeDocRepo2D, Rapier2dPhysicsTypeDocRepo>): void;
40
- removeFromWorld(world: Gg2dWorld<VisualTypeDocRepo2D, Rapier2dPhysicsTypeDocRepo>): void;
39
+ addToWorld(world: Rapier2dGgWorld): void;
40
+ removeFromWorld(world: Rapier2dGgWorld): void;
41
41
  resetMotion(): void;
42
42
  dispose(): void;
43
43
  }
@@ -127,6 +127,7 @@ export class Rapier2dRigidBodyComponent {
127
127
  const col = this.world.nativeWorld.createCollider(c, this._nativeBody);
128
128
  col.setFriction(this._colliderOptions.friction);
129
129
  col.setRestitution(this._colliderOptions.restitution);
130
+ col.setCollisionGroups(this.collisionGroups);
130
131
  return col;
131
132
  });
132
133
  this.world.handleIdEntityMap.set(this._nativeBody.handle, this);
@@ -1,22 +1,24 @@
1
1
  import { Observable, Subject } from 'rxjs';
2
2
  import { ColliderDesc, RigidBodyDesc } from '@dimforge/rapier2d-compat';
3
3
  import { Rapier2dRigidBodyComponent } from './rapier-2d-rigid-body.component';
4
- import { DebugBody2DSettings, Gg2dWorld, ITrigger2dComponent, Shape2DDescriptor, VisualTypeDocRepo2D } from '@gg-web-engine/core';
4
+ import { DebugBody2DSettings, ITrigger2dComponent, Shape2DDescriptor } from '@gg-web-engine/core';
5
5
  import { Rapier2dWorldComponent } from './rapier-2d-world.component';
6
- import { Rapier2dPhysicsTypeDocRepo } from '../types';
6
+ import { Rapier2dGgWorld, Rapier2dPhysicsTypeDocRepo } from '../types';
7
7
  export declare class Rapier2dTriggerComponent extends Rapier2dRigidBodyComponent implements ITrigger2dComponent<Rapier2dPhysicsTypeDocRepo> {
8
8
  protected readonly world: Rapier2dWorldComponent;
9
9
  protected _colliderDescr: ColliderDesc[];
10
10
  readonly shape: Shape2DDescriptor;
11
11
  protected _bodyDescr: RigidBodyDesc;
12
+ readonly debugBodySettings: DebugBody2DSettings;
12
13
  get onEntityEntered(): Observable<Rapier2dRigidBodyComponent>;
13
14
  get onEntityLeft(): Observable<Rapier2dRigidBodyComponent>;
15
+ protected readonly overlaps: Set<Rapier2dRigidBodyComponent>;
14
16
  protected readonly onEnter$: Subject<Rapier2dRigidBodyComponent>;
15
17
  protected readonly onLeft$: Subject<Rapier2dRigidBodyComponent>;
16
- readonly debugBodySettings: DebugBody2DSettings;
17
- protected intersectionsAmount: number;
18
18
  constructor(world: Rapier2dWorldComponent, _colliderDescr: ColliderDesc[], shape: Shape2DDescriptor, _bodyDescr: RigidBodyDesc);
19
- addToWorld(world: Gg2dWorld<VisualTypeDocRepo2D, Rapier2dPhysicsTypeDocRepo>): void;
19
+ addToWorld(world: Rapier2dGgWorld): void;
20
+ removeFromWorld(world: Rapier2dGgWorld): void;
20
21
  checkOverlaps(): void;
21
22
  clone(): Rapier2dTriggerComponent;
23
+ dispose(): void;
22
24
  }
@@ -1,6 +1,6 @@
1
- import { map, merge, Subject } from 'rxjs';
1
+ import { Subject } from 'rxjs';
2
2
  import { Rapier2dRigidBodyComponent } from './rapier-2d-rigid-body.component';
3
- import { DebugBody2DSettings, } from '@gg-web-engine/core';
3
+ import { DebugBody2DSettings } from '@gg-web-engine/core';
4
4
  export class Rapier2dTriggerComponent extends Rapier2dRigidBodyComponent {
5
5
  get onEntityEntered() {
6
6
  return this.onEnter$.asObservable();
@@ -14,29 +14,28 @@ export class Rapier2dTriggerComponent extends Rapier2dRigidBodyComponent {
14
14
  this._colliderDescr = _colliderDescr;
15
15
  this.shape = shape;
16
16
  this._bodyDescr = _bodyDescr;
17
+ this.debugBodySettings = new DebugBody2DSettings({ type: 'TRIGGER', activated: () => this.overlaps.size > 0 }, this.shape);
18
+ this.overlaps = new Set();
17
19
  this.onEnter$ = new Subject();
18
20
  this.onLeft$ = new Subject();
19
- this.debugBodySettings = new DebugBody2DSettings({ type: 'TRIGGER', activated: () => this.intersectionsAmount > 0 }, this.shape);
20
- this.intersectionsAmount = 0;
21
- merge(this.onEnter$.pipe(map(() => true)), this.onLeft$.pipe(map(() => false))).subscribe(enter => {
22
- if (enter) {
23
- this.intersectionsAmount++;
24
- }
25
- else {
26
- this.intersectionsAmount--;
27
- }
28
- });
29
21
  }
30
22
  addToWorld(world) {
31
23
  if (world.physicsWorld != this.world) {
32
24
  throw new Error('Rapier2D bodies cannot be shared between different worlds');
33
25
  }
34
- this.intersectionsAmount = 0;
26
+ this.overlaps.clear();
35
27
  this._nativeBody = this.world.nativeWorld.createRigidBody(this._bodyDescr);
36
28
  this._nativeBodyColliders = this._colliderDescr.map(c => this.world.nativeWorld.createCollider(c, this._nativeBody));
37
29
  this.world.handleIdEntityMap.set(this._nativeBody.handle, this);
38
30
  this.world.added$.next(this);
39
31
  }
32
+ removeFromWorld(world) {
33
+ for (const body of this.overlaps) {
34
+ this.onLeft$.next(body);
35
+ }
36
+ this.overlaps.clear();
37
+ super.removeFromWorld(world);
38
+ }
40
39
  checkOverlaps() {
41
40
  this.world.eventQueue.drainCollisionEvents((h1, h2, started) => {
42
41
  var _a, _b;
@@ -49,8 +48,21 @@ export class Rapier2dTriggerComponent extends Rapier2dRigidBodyComponent {
49
48
  }
50
49
  if (!otherBody)
51
50
  return;
52
- (started ? this.onEnter$ : this.onLeft$).next(otherBody);
51
+ if (started) {
52
+ this.overlaps.add(otherBody);
53
+ this.onEnter$.next(otherBody);
54
+ }
55
+ else {
56
+ this.overlaps.delete(otherBody);
57
+ this.onLeft$.next(otherBody);
58
+ }
53
59
  });
60
+ for (const body of this.overlaps.keys()) {
61
+ if (!body.nativeBody) {
62
+ this.overlaps.delete(body);
63
+ this.onLeft$.next(body);
64
+ }
65
+ }
54
66
  }
55
67
  clone() {
56
68
  const [colliderDescr, sd, bd] = super.factoryProps;
@@ -59,4 +71,8 @@ export class Rapier2dTriggerComponent extends Rapier2dRigidBodyComponent {
59
71
  component.interactWithCollisionGroups = this.interactWithCollisionGroups;
60
72
  return component;
61
73
  }
74
+ dispose() {
75
+ this.overlaps.clear();
76
+ super.dispose();
77
+ }
62
78
  }
@@ -1,4 +1,4 @@
1
- import { CollisionGroup, IPhysicsWorld2dComponent, Point2 } from '@gg-web-engine/core';
1
+ import { CollisionGroup, IPhysicsWorld2dComponent, Point2, RaycastOptions, RaycastResult } from '@gg-web-engine/core';
2
2
  import { EventQueue, World } from '@dimforge/rapier2d-compat';
3
3
  import { Rapier2dRigidBodyComponent } from './rapier-2d-rigid-body.component';
4
4
  import { Rapier2dFactory } from '../rapier-2d-factory';
@@ -26,5 +26,6 @@ export declare class Rapier2dWorldComponent implements IPhysicsWorld2dComponent<
26
26
  protected lockedCollisionGroups: number[];
27
27
  registerCollisionGroup(): CollisionGroup;
28
28
  deregisterCollisionGroup(group: CollisionGroup): void;
29
+ raycast(options: RaycastOptions<Point2>): RaycastResult<Point2, Rapier2dRigidBodyComponent>;
29
30
  dispose(): void;
30
31
  }
@@ -7,7 +7,7 @@ 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';
10
+ import { BitMask, Pnt2, Pnt3, } from '@gg-web-engine/core';
11
11
  import { EventQueue, init, Vector2, World } from '@dimforge/rapier2d-compat';
12
12
  import { Rapier2dFactory } from '../rapier-2d-factory';
13
13
  import { Subject } from 'rxjs';
@@ -80,6 +80,48 @@ export class Rapier2dWorldComponent {
80
80
  deregisterCollisionGroup(group) {
81
81
  this.lockedCollisionGroups = this.lockedCollisionGroups.filter(x => x !== group);
82
82
  }
83
+ raycast(options) {
84
+ if (!this._nativeWorld) {
85
+ return { hasHit: false };
86
+ }
87
+ const origin = options.from;
88
+ let direction = Pnt2.sub(options.to, options.from);
89
+ const rayLength = Pnt2.len(direction);
90
+ direction = Pnt2.norm(direction);
91
+ const ray = {
92
+ origin: new Vector2(...Pnt2.spr(origin)),
93
+ dir: new Vector2(...Pnt2.spr(direction)),
94
+ pointAt: (t) => {
95
+ return new Vector2(origin.x + direction.x * t, origin.y + direction.y * t);
96
+ },
97
+ };
98
+ const mask = (groups) => {
99
+ return groups ? BitMask.pack(groups, 16) : BitMask.full(16);
100
+ };
101
+ const hit = this._nativeWorld.castRay(ray, rayLength, true, undefined, (mask(options.collisionFilterGroups) << 16) | mask(options.collisionFilterMask));
102
+ if (!hit) {
103
+ return { hasHit: false };
104
+ }
105
+ const result = {
106
+ hasHit: true,
107
+ };
108
+ const collider = hit.collider;
109
+ if (collider) {
110
+ const rigidBody = collider.parent();
111
+ if (!rigidBody) {
112
+ return { hasHit: false };
113
+ }
114
+ result.hitBody = this.handleIdEntityMap.get(rigidBody.handle);
115
+ result.hitDistance = hit.timeOfImpact;
116
+ result.hitPoint = Pnt2.add(origin, Pnt2.scalarMult(direction, hit.timeOfImpact));
117
+ const rayIntersection = hit.collider.castRayAndGetNormal(ray, hit.timeOfImpact, true);
118
+ result.hitNormal = Pnt2.clone((rayIntersection === null || rayIntersection === void 0 ? void 0 : rayIntersection.normal) || Pnt3.O);
119
+ }
120
+ else {
121
+ result.hasHit = false;
122
+ }
123
+ return result;
124
+ }
83
125
  dispose() {
84
126
  var _a;
85
127
  (_a = this._nativeWorld) === null || _a === void 0 ? void 0 : _a.free();
package/dist/types.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { Rapier2dFactory } from './rapier-2d-factory';
2
2
  import { Rapier2dRigidBodyComponent } from './components/rapier-2d-rigid-body.component';
3
3
  import { Rapier2dTriggerComponent } from './components/rapier-2d-trigger.component';
4
+ import { Gg2dWorld, Gg2dWorldSceneTypeDocPPatch, Gg2dWorldTypeDocPPatch } from '@gg-web-engine/core';
5
+ import { Rapier2dWorldComponent } from './components/rapier-2d-world.component';
4
6
  export type Rapier2dPhysicsTypeDocRepo = {
5
7
  factory: Rapier2dFactory;
6
8
  rigidBody: Rapier2dRigidBodyComponent;
7
9
  trigger: Rapier2dTriggerComponent;
8
10
  };
11
+ export type Rapier2dGgWorld = Gg2dWorld<Gg2dWorldTypeDocPPatch<Rapier2dPhysicsTypeDocRepo>, Gg2dWorldSceneTypeDocPPatch<Rapier2dPhysicsTypeDocRepo, Rapier2dWorldComponent>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gg-web-engine/rapier2d",
3
- "version": "0.0.57",
3
+ "version": "0.0.58",
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",
@@ -28,7 +28,7 @@
28
28
  "homepage": "https://github.com/AndyGura/gg-web-engine#readme",
29
29
  "devDependencies": {
30
30
  "@dimforge/rapier2d-compat": "0.0.0-50223ff-20240729",
31
- "@gg-web-engine/core": "0.0.57",
31
+ "@gg-web-engine/core": "0.0.58",
32
32
  "@types/jest": "^29.5.12",
33
33
  "jest": "^29.7.0",
34
34
  "jest-environment-jsdom": "^29.7.0",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@dimforge/rapier2d-compat": "0.0.0-50223ff-20240729",
42
- "@gg-web-engine/core": "0.0.57",
42
+ "@gg-web-engine/core": "0.0.58",
43
43
  "rxjs": "7.8.1"
44
44
  },
45
45
  "jest": {
@@ -1,5 +1,5 @@
1
- import { Rapier2dFactory, Rapier2dWorldComponent } from '../../src';
2
1
  import { Pnt2 } from '@gg-web-engine/core';
2
+ import { Rapier2dFactory, Rapier2dWorldComponent } from '../../src';
3
3
 
4
4
  describe(`Rapier2dTriggerComponent`, () => {
5
5
 
@@ -22,19 +22,19 @@ describe(`Rapier2dTriggerComponent`, () => {
22
22
  it(`should detect object intersection`, async () => {
23
23
  const trigger = factory.createTrigger({ shape: 'SQUARE', dimensions: { x: 10, y: 10 } });
24
24
  trigger.addToWorld({ physicsWorld: world } as any);
25
- const ball = factory.createRigidBody({
25
+ const circle = factory.createRigidBody({
26
26
  shape: { shape: 'CIRCLE', radius: 1 },
27
27
  body: { dynamic: true, mass: 1 },
28
28
  }, { position: { x: 0, y: 12 } });
29
- ball.addToWorld({ physicsWorld: world } as any);
30
- ball.linearVelocity = { x: 0, y: -10 };
29
+ circle.addToWorld({ physicsWorld: world } as any);
30
+ circle.linearVelocity = { x: 0, y: -10 };
31
31
  let enterRegistered = false;
32
32
  let exitRegistered = false;
33
33
  trigger.onEntityEntered.subscribe(((obj) => {
34
- enterRegistered = obj === ball;
34
+ enterRegistered = obj === circle;
35
35
  }));
36
36
  trigger.onEntityLeft.subscribe(((obj) => {
37
- exitRegistered = obj === ball;
37
+ exitRegistered = obj === circle;
38
38
  }));
39
39
  world.simulate(500);
40
40
  trigger.checkOverlaps(); // trigger entity performs that on tick
@@ -50,15 +50,15 @@ describe(`Rapier2dTriggerComponent`, () => {
50
50
  it(`should detect end of object intersection`, async () => {
51
51
  const trigger = factory.createTrigger({ shape: 'SQUARE', dimensions: { x: 10, y: 10 } });
52
52
  trigger.addToWorld({ physicsWorld: world } as any);
53
- const ball = factory.createRigidBody({
53
+ const circle = factory.createRigidBody({
54
54
  shape: { shape: 'CIRCLE', radius: 1 },
55
55
  body: { dynamic: true, mass: 1 },
56
56
  }, { position: { x: 0, y: 12 } });
57
- ball.addToWorld({ physicsWorld: world } as any);
58
- ball.linearVelocity = { x: 0, y: -10 };
57
+ circle.addToWorld({ physicsWorld: world } as any);
58
+ circle.linearVelocity = { x: 0, y: -10 };
59
59
  let exitRegistered = false;
60
60
  trigger.onEntityLeft.subscribe(((obj) => {
61
- exitRegistered = obj === ball;
61
+ exitRegistered = obj === circle;
62
62
  }));
63
63
  world.simulate(1000);
64
64
  trigger.checkOverlaps();
@@ -71,54 +71,55 @@ describe(`Rapier2dTriggerComponent`, () => {
71
71
  it(`should fire object intersection if spawned inside`, async () => {
72
72
  const trigger = factory.createTrigger({ shape: 'SQUARE', dimensions: { x: 10, y: 10 } });
73
73
  trigger.addToWorld({ physicsWorld: world } as any);
74
- const ball = factory.createRigidBody({
74
+ const circle = factory.createRigidBody({
75
75
  shape: { shape: 'CIRCLE', radius: 1 },
76
76
  body: { dynamic: true, mass: 1 },
77
- }, { position: { x: 0, y: 0 } });
78
- ball.addToWorld({ physicsWorld: world } as any);
77
+ }, { position: Pnt2.O });
78
+ circle.addToWorld({ physicsWorld: world } as any);
79
79
  let enterRegistered = false;
80
80
  trigger.onEntityEntered.subscribe(((obj) => {
81
- enterRegistered = obj === ball;
81
+ enterRegistered = obj === circle;
82
82
  }));
83
83
  world.simulate(1000);
84
84
  trigger.checkOverlaps();
85
85
  expect(enterRegistered).toBe(true);
86
86
  });
87
87
 
88
- // TODO implement functionality below
89
- // it(`should fire end of object intersection if trigger removed`, async () => {
90
- // const trigger = factory.createTrigger({ shape: 'SQUARE', dimensions: { x: 10, y: 10 } });
91
- // trigger.addToWorld({ physicsWorld: world } as any);
92
- // const ball = factory.createRigidBody({
93
- // shape: { shape: 'CIRCLE', radius: 1 },
94
- // body: { dynamic: true, mass: 1 },
95
- // }, { position: { x: 0, y: 0 } });
96
- // ball.addToWorld({ physicsWorld: world } as any);
97
- // let exitRegistered = false;
98
- // trigger.onEntityLeft.subscribe(((obj) => {
99
- // exitRegistered = obj === ball;
100
- // }));
101
- // world.simulate(1000);
102
- // trigger.checkOverlaps();
103
- // trigger.removeFromWorld({ physicsWorld: world } as any);
104
- // expect(exitRegistered).toBe(true);
105
- // });
106
- //
107
- // it(`should fire end of object intersection if object removed`, async () => {
108
- // const trigger = factory.createTrigger({ shape: 'SQUARE', dimensions: { x: 10, y: 10 } });
109
- // trigger.addToWorld({ physicsWorld: world } as any);
110
- // const ball = factory.createRigidBody({
111
- // shape: { shape: 'CIRCLE', radius: 1 },
112
- // body: { dynamic: true, mass: 1 },
113
- // }, { position: { x: 0, y: 0 } });
114
- // ball.addToWorld({ physicsWorld: world } as any);
115
- // let exitRegistered = false;
116
- // trigger.onEntityLeft.subscribe(((obj) => {
117
- // exitRegistered = obj === ball;
118
- // }));
119
- // world.simulate(1000);
120
- // trigger.checkOverlaps();
121
- // ball.removeFromWorld({ physicsWorld: world } as any);
122
- // expect(exitRegistered).toBe(true);
123
- // });
88
+ it(`should fire end of object intersection if trigger removed`, async () => {
89
+ const trigger = factory.createTrigger({ shape: 'SQUARE', dimensions: { x: 10, y: 10 } });
90
+ trigger.addToWorld({ physicsWorld: world } as any);
91
+ const circle = factory.createRigidBody({
92
+ shape: { shape: 'CIRCLE', radius: 1 },
93
+ body: { dynamic: true, mass: 1 },
94
+ }, { position: Pnt2.O });
95
+ circle.addToWorld({ physicsWorld: world } as any);
96
+ let exitRegistered = false;
97
+ trigger.onEntityLeft.subscribe(((obj) => {
98
+ exitRegistered = obj === circle;
99
+ }));
100
+ world.simulate(1);
101
+ trigger.checkOverlaps();
102
+ trigger.removeFromWorld({ physicsWorld: world } as any);
103
+ expect(exitRegistered).toBe(true);
104
+ });
105
+
106
+ it(`should fire end of object intersection if object removed`, async () => {
107
+ const trigger = factory.createTrigger({ shape: 'SQUARE', dimensions: { x: 10, y: 10 } });
108
+ trigger.addToWorld({ physicsWorld: world } as any);
109
+ const circle = factory.createRigidBody({
110
+ shape: { shape: 'CIRCLE', radius: 1 },
111
+ body: { dynamic: true, mass: 1 },
112
+ }, { position: Pnt2.O });
113
+ circle.addToWorld({ physicsWorld: world } as any);
114
+ let exitRegistered = false;
115
+ trigger.onEntityLeft.subscribe(((obj) => {
116
+ exitRegistered = obj === circle;
117
+ }));
118
+ world.simulate(1);
119
+ trigger.checkOverlaps();
120
+ circle.removeFromWorld({ physicsWorld: world } as any);
121
+ world.simulate(1);
122
+ trigger.checkOverlaps();
123
+ expect(exitRegistered).toBe(true);
124
+ });
124
125
  });
@@ -0,0 +1,296 @@
1
+ import { Point2, RaycastOptions } from '@gg-web-engine/core';
2
+ import { Rapier2dWorldComponent } from '../../src';
3
+
4
+ describe('Rapier2dWorldComponent', () => {
5
+ let world: Rapier2dWorldComponent;
6
+
7
+ beforeEach(async () => {
8
+ if (world) {
9
+ world.dispose();
10
+ }
11
+ world = new Rapier2dWorldComponent();
12
+ await world.init();
13
+ world.gravity = { x: 0, y: 0 }; // Set gravity to zero for predictable physics
14
+ });
15
+
16
+ afterAll(() => {
17
+ world.dispose();
18
+ });
19
+
20
+ describe('Rigid bodies', () => {
21
+
22
+ 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 } });
28
+ circle.addToWorld({ physicsWorld: world } as any);
29
+ circle.linearVelocity = { x: 1, y: 0 };
30
+
31
+ // simulate for 6 seconds
32
+ for (let i = 0; i < 100; i++) {
33
+ world.simulate(60);
34
+ }
35
+ expect(circle.position.x).toBeCloseTo(1);
36
+ });
37
+
38
+ it('should simulate collision of two rigid bodies', () => {
39
+
40
+ const circle0 = world.factory.createRigidBody({
41
+ shape: { shape: 'CIRCLE', radius: 1 },
42
+ body: { dynamic: true, mass: 5 },
43
+ }, { position: { x: -5, y: 0 } });
44
+ circle0.addToWorld({ physicsWorld: world } as any);
45
+ circle0.linearVelocity = { x: 1, y: 0 };
46
+
47
+ const circle1 = world.factory.createRigidBody({
48
+ shape: { shape: 'CIRCLE', radius: 1 },
49
+ body: { dynamic: true, mass: 5 },
50
+ }, { position: { x: 5, y: 0 } });
51
+ circle1.addToWorld({ physicsWorld: world } as any);
52
+ circle1.linearVelocity = { x: -1, y: 0 };
53
+
54
+ // simulate for 6 seconds
55
+ for (let i = 0; i < 100; i++) {
56
+ world.simulate(60);
57
+ }
58
+ expect(circle0.position.x).toBeLessThan(0);
59
+ expect(circle1.position.x).toBeGreaterThan(0);
60
+ });
61
+
62
+ it('should not simulate collision of two rigid bodies with different collision groups', () => {
63
+ const cg0 = world.registerCollisionGroup();
64
+ const circle0 = world.factory.createRigidBody({
65
+ shape: { shape: 'CIRCLE', radius: 1 },
66
+ body: { dynamic: true, mass: 5 },
67
+ }, { position: { x: -5, y: 0 } });
68
+ circle0.addToWorld({ physicsWorld: world } as any);
69
+ circle0.linearVelocity = { x: 1, y: 0 };
70
+ circle0.ownCollisionGroups = circle0.interactWithCollisionGroups = [cg0];
71
+
72
+ const cg1 = world.registerCollisionGroup();
73
+ const circle1 = world.factory.createRigidBody({
74
+ shape: { shape: 'CIRCLE', radius: 1 },
75
+ body: { dynamic: true, mass: 5 },
76
+ }, { position: { x: 5, y: 0 } });
77
+ circle1.addToWorld({ physicsWorld: world } as any);
78
+ circle1.linearVelocity = { x: -1, y: 0 };
79
+ circle1.ownCollisionGroups = circle1.interactWithCollisionGroups = [cg1];
80
+
81
+ // simulate for 6 seconds
82
+ for (let i = 0; i < 100; i++) {
83
+ world.simulate(60);
84
+ }
85
+ expect(circle0.position.x).toBeCloseTo(1);
86
+ expect(circle1.position.x).toBeCloseTo(-1);
87
+ });
88
+ });
89
+
90
+ describe('Raycast', () => {
91
+
92
+ it('should return no hit when ray does not intersect any object', () => {
93
+ // Create a square far away from the ray
94
+ const square = world.factory.createRigidBody({
95
+ shape: { shape: 'SQUARE', dimensions: { x: 1, y: 1 } },
96
+ body: { dynamic: false, mass: 0 },
97
+ }, { position: { x: 10, y: 10 } });
98
+ square.addToWorld({ physicsWorld: world } as any);
99
+
100
+ world.simulate(1);
101
+
102
+ // Cast a ray that doesn't hit anything
103
+ const raycastOptions: RaycastOptions<Point2> = {
104
+ from: { x: 0, y: 0 },
105
+ to: { x: 0, y: -10 },
106
+ };
107
+
108
+ const result = world.raycast(raycastOptions);
109
+ expect(result.hasHit).toBe(false);
110
+ expect(result.hitPoint).toBeUndefined();
111
+ expect(result.hitNormal).toBeUndefined();
112
+ expect(result.hitDistance).toBeUndefined();
113
+ });
114
+
115
+ it('should detect hit when ray intersects an object', () => {
116
+ // Create a square in the path of the ray
117
+ const square = world.factory.createRigidBody({
118
+ shape: { shape: 'SQUARE', dimensions: { x: 2, y: 2 } },
119
+ body: { dynamic: false, mass: 0 },
120
+ }, { position: { x: 0, y: -5 } });
121
+ square.addToWorld({ physicsWorld: world } as any);
122
+
123
+ world.simulate(1);
124
+
125
+ // Cast a ray that hits the square
126
+ const raycastOptions: RaycastOptions<Point2> = {
127
+ from: { x: 0, y: 0 },
128
+ to: { x: 0, y: -10 },
129
+ };
130
+
131
+ const result = world.raycast(raycastOptions);
132
+ expect(result.hasHit).toBe(true);
133
+ expect(result.hitBody).toBe(square);
134
+ expect(result.hitPoint).toBeDefined();
135
+ expect(result.hitNormal).toBeDefined();
136
+ expect(result.hitDistance).toBeDefined();
137
+
138
+ // The hit should be at approximately y = -4 (box at y = -5 with size 2)
139
+ expect(result.hitPoint!.y).toBeCloseTo(-4, 0.1);
140
+
141
+ // The normal should point up (towards the ray origin)
142
+ expect(result.hitNormal!.y).toBeGreaterThan(0);
143
+ });
144
+
145
+ it('should respect collision filtering', () => {
146
+ // Register collision groups
147
+ const group1 = world.registerCollisionGroup();
148
+ const group2 = world.registerCollisionGroup();
149
+
150
+ // Create a square that only belongs to group1
151
+ const square1 = world.factory.createRigidBody({
152
+ shape: { shape: 'SQUARE', dimensions: { x: 2, y: 2 } },
153
+ body: {
154
+ dynamic: false,
155
+ mass: 0,
156
+ ownCollisionGroups: [group1],
157
+ interactWithCollisionGroups: [group1, group2],
158
+ },
159
+ }, { position: { x: 0, y: -5 } });
160
+ square1.addToWorld({ physicsWorld: world } as any);
161
+
162
+ world.simulate(1);
163
+
164
+ // Ray that only checks against group2 should not hit
165
+ const rayOptions1: RaycastOptions<Point2> = {
166
+ from: { x: 0, y: 0 },
167
+ to: { x: 0, y: -10 },
168
+ collisionFilterGroups: [group2],
169
+ collisionFilterMask: [group2],
170
+ };
171
+
172
+ const result1 = world.raycast(rayOptions1);
173
+ expect(result1.hasHit).toBe(false);
174
+
175
+ // Ray that checks against group1 should hit
176
+ const rayOptions2: RaycastOptions<Point2> = {
177
+ from: { x: 0, y: 0 },
178
+ to: { x: 0, y: -10 },
179
+ collisionFilterGroups: [group1],
180
+ collisionFilterMask: [group1],
181
+ };
182
+
183
+ const result2 = world.raycast(rayOptions2);
184
+ expect(result2.hasHit).toBe(true);
185
+ expect(result2.hitBody).toBe(square1);
186
+ });
187
+
188
+ it('should calculate hit distance correctly', () => {
189
+ // Create a square at a known distance
190
+ const square = world.factory.createRigidBody({
191
+ shape: { shape: 'SQUARE', dimensions: { x: 2, y: 2 } },
192
+ body: { dynamic: false, mass: 0 },
193
+ }, { position: { x: 0, y: -5 } });
194
+ square.addToWorld({ physicsWorld: world } as any);
195
+
196
+ world.simulate(1);
197
+
198
+ // Cast a ray from origin to y = -10
199
+ const raycastOptions: RaycastOptions<Point2> = {
200
+ from: { x: 0, y: 0 },
201
+ to: { x: 0, y: -10 },
202
+ };
203
+
204
+ const result = world.raycast(raycastOptions);
205
+ expect(result.hasHit).toBe(true);
206
+
207
+ // The hit distance should be approximately 4 units
208
+ // (from origin to the edge of the square at y = -4)
209
+ expect(result.hitDistance).toBeCloseTo(4, 0.1);
210
+ });
211
+
212
+ it('should handle array of collision groups correctly', () => {
213
+ // Register collision groups
214
+ const group1 = world.registerCollisionGroup();
215
+ const group2 = world.registerCollisionGroup();
216
+
217
+ // Create a square that belongs to group1
218
+ const square = world.factory.createRigidBody({
219
+ shape: { shape: 'SQUARE', dimensions: { x: 2, y: 2 } },
220
+ body: {
221
+ dynamic: false,
222
+ mass: 0,
223
+ ownCollisionGroups: [group1],
224
+ interactWithCollisionGroups: [group1, group2],
225
+ },
226
+ }, { position: { x: 0, y: -5 } });
227
+ square.addToWorld({ physicsWorld: world } as any);
228
+
229
+ world.simulate(1);
230
+
231
+ // Ray that checks against both groups should hit
232
+ const rayOptions: RaycastOptions<Point2> = {
233
+ from: { x: 0, y: 0 },
234
+ to: { x: 0, y: -10 },
235
+ collisionFilterGroups: [group1, group2],
236
+ collisionFilterMask: [group1, group2],
237
+ };
238
+
239
+ const result = world.raycast(rayOptions);
240
+ expect(result.hasHit).toBe(true);
241
+ });
242
+
243
+ it('should return correct hit body', () => {
244
+ // Create two squares at different positions
245
+ const square1 = world.factory.createRigidBody({
246
+ shape: { shape: 'SQUARE', dimensions: { x: 1, y: 1 } },
247
+ body: { dynamic: false, mass: 0 },
248
+ }, { position: { x: 0, y: -3 } });
249
+ square1.addToWorld({ physicsWorld: world } as any);
250
+
251
+ const square2 = world.factory.createRigidBody({
252
+ shape: { shape: 'SQUARE', dimensions: { x: 1, y: 1 } },
253
+ body: { dynamic: false, mass: 0 },
254
+ }, { position: { x: 0, y: -7 } });
255
+ square2.addToWorld({ physicsWorld: world } as any);
256
+
257
+ world.simulate(1);
258
+
259
+ // Cast a ray that should hit square1 first
260
+ const raycastOptions: RaycastOptions<Point2> = {
261
+ from: { x: 0, y: 0 },
262
+ to: { x: 0, y: -10 },
263
+ };
264
+
265
+ const result = world.raycast(raycastOptions);
266
+ expect(result.hasHit).toBe(true);
267
+ expect(result.hitBody).toBe(square1);
268
+ expect(result.hitPoint!.y).toBeCloseTo(-2.5, 0.1); // square1 edge is at y = -2.5
269
+ });
270
+
271
+ it('should handle edge case with ray starting inside an object', () => {
272
+ // Create a square
273
+ const square = world.factory.createRigidBody({
274
+ shape: { shape: 'SQUARE', dimensions: { x: 4, y: 4 } },
275
+ body: { dynamic: false, mass: 0 },
276
+ }, { position: { x: 0, y: 0 } });
277
+ square.addToWorld({ physicsWorld: world } as any);
278
+
279
+ world.simulate(1);
280
+
281
+ // Cast a ray from inside the square
282
+ const raycastOptions: RaycastOptions<Point2> = {
283
+ from: { x: 0, y: 0 }, // Center of the square
284
+ to: { x: 0, y: -10 },
285
+ };
286
+
287
+ const result = world.raycast(raycastOptions);
288
+
289
+ // Behavior may vary depending on physics engine implementation
290
+ // Some engines might not detect hits when starting inside an object
291
+ // Others might detect the exit point
292
+ // We just verify the method doesn't crash and returns a valid result
293
+ expect(result).toBeDefined();
294
+ });
295
+ });
296
+ });