@gg-web-engine/rapier3d 0.0.63 → 0.0.65
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/dist/components/rapier-3d-character-controller.component.d.ts +131 -0
- package/dist/components/rapier-3d-character-controller.component.js +398 -0
- package/dist/components/rapier-3d-raycast-vehicle.component.d.ts +1 -1
- package/dist/components/rapier-3d-raycast-vehicle.component.js +5 -2
- package/dist/components/rapier-3d-rigid-body.component.d.ts +1 -1
- package/dist/components/rapier-3d-rigid-body.component.js +2 -2
- package/dist/components/rapier-3d-trigger.component.d.ts +1 -1
- package/dist/components/rapier-3d-trigger.component.js +2 -2
- package/dist/components/rapier-3d-world.component.d.ts +6 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/rapier-3d-factory.d.ts +6 -1
- package/dist/rapier-3d-factory.js +23 -0
- package/dist/types.d.ts +2 -0
- package/package.json +3 -3
- package/test/components/rapier-3d-character-controller-freefall.spec.ts +43 -0
- package/test/components/rapier-3d-character-controller.component.spec.ts +250 -0
- package/test/components/rapier-3d-player-character-controller-integration.spec.ts +181 -0
- package/test/rapier-3d-factory.spec.ts +68 -0
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './components/rapier-3d-rigid-body.component';
|
|
2
2
|
export * from './components/rapier-3d-trigger.component';
|
|
3
3
|
// export * from './components/rapier-3d-raycast-vehicle.component';
|
|
4
|
+
export * from './components/rapier-3d-character-controller.component';
|
|
4
5
|
export * from './components/rapier-3d-world.component';
|
|
5
6
|
export * from './rapier-3d-factory';
|
|
6
7
|
export * from './rapier-3d-loader';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { Body3DOptions, BodyShape3DDescriptor, IPhysicsBody3dComponentFactory, Point3, Point4, Shape3DDescriptor } from '@gg-web-engine/core';
|
|
1
|
+
import { Body3DOptions, BodyShape3DDescriptor, CharacterController3dOptions, IPhysicsBody3dComponentFactory, Point3, Point4, Shape3DDescriptor } from '@gg-web-engine/core';
|
|
2
2
|
import { ColliderDesc, RigidBodyDesc } from '@dimforge/rapier3d-compat';
|
|
3
3
|
import { Rapier3dRigidBodyComponent } from './components/rapier-3d-rigid-body.component';
|
|
4
4
|
import { Rapier3dTriggerComponent } from './components/rapier-3d-trigger.component';
|
|
5
|
+
import { Rapier3dCharacterControllerComponent } from './components/rapier-3d-character-controller.component';
|
|
5
6
|
import { Rapier3dWorldComponent } from './components/rapier-3d-world.component';
|
|
6
7
|
import { Rapier3dPhysicsTypeDocRepo } from './types';
|
|
7
8
|
export declare class Rapier3dFactory implements IPhysicsBody3dComponentFactory<Rapier3dPhysicsTypeDocRepo> {
|
|
@@ -16,6 +17,10 @@ export declare class Rapier3dFactory implements IPhysicsBody3dComponentFactory<R
|
|
|
16
17
|
rotation?: Point4;
|
|
17
18
|
}): Rapier3dTriggerComponent;
|
|
18
19
|
createRaycastVehicle(chassis: Rapier3dRigidBodyComponent): never;
|
|
20
|
+
createCharacterController(options: CharacterController3dOptions, transform?: {
|
|
21
|
+
position?: Point3;
|
|
22
|
+
rotation?: Point4;
|
|
23
|
+
}): Rapier3dCharacterControllerComponent;
|
|
19
24
|
createColliderDescr(descriptor: Shape3DDescriptor): ColliderDesc[];
|
|
20
25
|
createRigidBodyDescr(options: Partial<Body3DOptions>, transform?: {
|
|
21
26
|
position?: Point3;
|
|
@@ -2,6 +2,21 @@ import { Pnt3, Qtrn, } from '@gg-web-engine/core';
|
|
|
2
2
|
import { ActiveEvents, ColliderDesc, Quaternion, RigidBodyDesc } from '@dimforge/rapier3d-compat';
|
|
3
3
|
import { Rapier3dRigidBodyComponent } from './components/rapier-3d-rigid-body.component';
|
|
4
4
|
import { Rapier3dTriggerComponent } from './components/rapier-3d-trigger.component';
|
|
5
|
+
import { Rapier3dCharacterControllerComponent } from './components/rapier-3d-character-controller.component';
|
|
6
|
+
const DEFAULT_CHARACTER_CONTROLLER_OPTIONS = {
|
|
7
|
+
offset: 0.01,
|
|
8
|
+
maxStepHeight: 0.3,
|
|
9
|
+
minStepWidth: 0.2,
|
|
10
|
+
maxSlopeClimbAngleRad: (50 * Math.PI) / 180,
|
|
11
|
+
snapToGroundDistance: 0.3,
|
|
12
|
+
up: Pnt3.Z,
|
|
13
|
+
ownCollisionGroups: 'all',
|
|
14
|
+
interactWithCollisionGroups: 'all',
|
|
15
|
+
// `Rapier3dCharacterControllerComponent.addToWorld` wires this straight into Rapier's own
|
|
16
|
+
// `KinematicCharacterController.setCharacterMass` + `setApplyImpulsesToDynamicBodies(true)` -
|
|
17
|
+
// unlike the ammo adapter, no hand-rolled push logic needed, Rapier already computes it.
|
|
18
|
+
pushMass: 80,
|
|
19
|
+
};
|
|
5
20
|
export class Rapier3dFactory {
|
|
6
21
|
constructor(world) {
|
|
7
22
|
this.world = world;
|
|
@@ -20,6 +35,14 @@ export class Rapier3dFactory {
|
|
|
20
35
|
createRaycastVehicle(chassis) {
|
|
21
36
|
throw new Error('Raycast vehicle bindings for rapier3D are not implemented');
|
|
22
37
|
}
|
|
38
|
+
createCharacterController(options, transform) {
|
|
39
|
+
const resolvedOptions = Object.assign(Object.assign(Object.assign({}, DEFAULT_CHARACTER_CONTROLLER_OPTIONS), { ownCollisionGroups: [this.world.mainCollisionGroup] }), options);
|
|
40
|
+
const bodyDescr = RigidBodyDesc.kinematicPositionBased();
|
|
41
|
+
const pos = (transform === null || transform === void 0 ? void 0 : transform.position) || Pnt3.O;
|
|
42
|
+
const rot = (transform === null || transform === void 0 ? void 0 : transform.rotation) || Qtrn.O;
|
|
43
|
+
bodyDescr.setTranslation(pos.x, pos.y, pos.z).setRotation(new Quaternion(rot.x, rot.y, rot.z, rot.w));
|
|
44
|
+
return new Rapier3dCharacterControllerComponent(this.world, resolvedOptions, bodyDescr);
|
|
45
|
+
}
|
|
23
46
|
createColliderDescr(descriptor) {
|
|
24
47
|
const yToZUp = Qtrn.rotAround(Qtrn.O, Pnt3.X, Math.PI / 2);
|
|
25
48
|
switch (descriptor.shape) {
|
package/dist/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Rapier3dFactory } from './rapier-3d-factory';
|
|
|
2
2
|
import { Rapier3dLoader } from './rapier-3d-loader';
|
|
3
3
|
import { Rapier3dRigidBodyComponent } from './components/rapier-3d-rigid-body.component';
|
|
4
4
|
import { Rapier3dTriggerComponent } from './components/rapier-3d-trigger.component';
|
|
5
|
+
import { Rapier3dCharacterControllerComponent } from './components/rapier-3d-character-controller.component';
|
|
5
6
|
import { Gg3dWorld, Gg3dWorldSceneTypeDocPPatch, Gg3dWorldTypeDocPPatch } from '@gg-web-engine/core';
|
|
6
7
|
import { Rapier3dWorldComponent } from './components/rapier-3d-world.component';
|
|
7
8
|
export type Rapier3dPhysicsTypeDocRepo = {
|
|
@@ -10,6 +11,7 @@ export type Rapier3dPhysicsTypeDocRepo = {
|
|
|
10
11
|
rigidBody: Rapier3dRigidBodyComponent;
|
|
11
12
|
trigger: Rapier3dTriggerComponent;
|
|
12
13
|
raycastVehicle: never;
|
|
14
|
+
characterController: Rapier3dCharacterControllerComponent;
|
|
13
15
|
};
|
|
14
16
|
export type Rapier3dTypeDoc = Gg3dWorldTypeDocPPatch<Rapier3dPhysicsTypeDocRepo>;
|
|
15
17
|
export type Rapier3dSceneTypeDoc = Gg3dWorldSceneTypeDocPPatch<Rapier3dPhysicsTypeDocRepo, Rapier3dWorldComponent>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gg-web-engine/rapier3d",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.65",
|
|
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/rapier3d-compat": "0.20.0",
|
|
31
|
-
"@gg-web-engine/core": "0.0.
|
|
31
|
+
"@gg-web-engine/core": "0.0.65",
|
|
32
32
|
"@types/jest": "^30.0.0",
|
|
33
33
|
"@types/node": "^26.4.0",
|
|
34
34
|
"jest": "^30.5.0",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@dimforge/rapier3d-compat": "0.20.0",
|
|
43
|
-
"@gg-web-engine/core": "0.0.
|
|
43
|
+
"@gg-web-engine/core": "0.0.65",
|
|
44
44
|
"rxjs": "7.8.2"
|
|
45
45
|
},
|
|
46
46
|
"jest": {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { CharacterController3dEntity } from '@gg-web-engine/core';
|
|
2
|
+
import { Rapier3dWorldComponent } from '../../src';
|
|
3
|
+
|
|
4
|
+
describe('Rapier3dCharacterControllerComponent - free fall sanity', () => {
|
|
5
|
+
it('falls under gravity in empty space (no floor at all) at a physically plausible rate', async () => {
|
|
6
|
+
const world = new Rapier3dWorldComponent();
|
|
7
|
+
await world.init();
|
|
8
|
+
// Leave `world.gravity` at its default (-9.82 along z) and the entity's own `gravity` option
|
|
9
|
+
// unset (undefined) - this is the normal/default wiring (see `gravity`'s doc on
|
|
10
|
+
// `CharacterController3dEntityOptions`): `CharacterController3dEntity` reads
|
|
11
|
+
// `physicsWorld.gravity` live every tick and integrates it itself, since a kinematic character
|
|
12
|
+
// controller is never affected by the native engine's own gravity integration (that only
|
|
13
|
+
// applies to dynamic rigid bodies - see `ICharacterController3dComponent`'s doc), regardless of
|
|
14
|
+
// what `world.gravity` is set to. This test is what actually proves the character obeys world
|
|
15
|
+
// gravity by default, not just that an explicit numeric override falls correctly.
|
|
16
|
+
|
|
17
|
+
const startZ = 1000; // high up, nothing below at all - pure free fall, no ground anywhere
|
|
18
|
+
const characterController = world.factory.createCharacterController(
|
|
19
|
+
{ radius: 0.4, centersDistance: 1.0 },
|
|
20
|
+
{ position: { x: 0, y: 0, z: startZ } },
|
|
21
|
+
);
|
|
22
|
+
const entity = new CharacterController3dEntity({ radius: 0.4, centersDistance: 1.0 }, null, characterController);
|
|
23
|
+
entity.onSpawned({ physicsWorld: world } as any);
|
|
24
|
+
|
|
25
|
+
const dtMs = 16;
|
|
26
|
+
const steps = 60; // ~1 simulated second
|
|
27
|
+
for (let i = 0; i < steps; i++) {
|
|
28
|
+
entity.tick$.next([i * dtMs, dtMs]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const elapsedSeconds = (steps * dtMs) / 1000;
|
|
32
|
+
const expectedDrop = 0.5 * 9.82 * elapsedSeconds * elapsedSeconds; // free fall from rest, ~4.9m after 1s
|
|
33
|
+
const actualDrop = startZ - entity.position.z;
|
|
34
|
+
|
|
35
|
+
console.log(`expected drop after ${elapsedSeconds}s: ~${expectedDrop.toFixed(2)}m, actual: ${actualDrop.toFixed(2)}m`);
|
|
36
|
+
|
|
37
|
+
// generous tolerance for discrete Euler integration - this is a sanity bound, not a physics
|
|
38
|
+
// precision test: it must catch "falls absurdly fast" (an order of magnitude or more off), not
|
|
39
|
+
// nitpick integration error
|
|
40
|
+
expect(actualDrop).toBeGreaterThan(expectedDrop * 0.5);
|
|
41
|
+
expect(actualDrop).toBeLessThan(expectedDrop * 2);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { CharacterController3dOptions, Pnt3 } from '@gg-web-engine/core';
|
|
2
|
+
import { Rapier3dFactory, Rapier3dWorldComponent } from '../../src';
|
|
3
|
+
|
|
4
|
+
describe('Rapier3dCharacterControllerComponent', () => {
|
|
5
|
+
let world: Rapier3dWorldComponent;
|
|
6
|
+
let factory: Rapier3dFactory;
|
|
7
|
+
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
if (world) {
|
|
10
|
+
world.dispose();
|
|
11
|
+
}
|
|
12
|
+
world = new Rapier3dWorldComponent();
|
|
13
|
+
factory = new Rapier3dFactory(world);
|
|
14
|
+
await world.init();
|
|
15
|
+
world.gravity = Pnt3.O; // this component never relies on gravity - see the class doc
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
world.dispose();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const CHAR_OPTIONS: CharacterController3dOptions = { 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 at
|
|
27
|
+
// world-space `topZ`.
|
|
28
|
+
const addFloor = (centerX: number, sizeX: number, topZ: number, thickness = 1) => {
|
|
29
|
+
const floor = factory.createRigidBody(
|
|
30
|
+
{
|
|
31
|
+
shape: { shape: 'BOX', dimensions: { x: sizeX, y: 20, z: thickness } },
|
|
32
|
+
body: { dynamic: false, mass: 0 },
|
|
33
|
+
},
|
|
34
|
+
{ position: { x: centerX, y: 0, z: topZ - 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, z: 5 } },
|
|
45
|
+
body: { dynamic: false, mass: 0 },
|
|
46
|
+
},
|
|
47
|
+
{ position: { x: x + thickness / 2, y: 0, z: 2.5 } },
|
|
48
|
+
);
|
|
49
|
+
wall.addToWorld({ physicsWorld: world } as any);
|
|
50
|
+
return wall;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Rapier only registers a freshly-created collider's AABB with its broad-phase as part of running
|
|
54
|
+
// World.step() at least once (there is no manual "rebuild broad-phase now" call exposed on this
|
|
55
|
+
// build of @dimforge/rapier3d-compat) - so a collider created and never stepped is invisible to
|
|
56
|
+
// computeColliderMovement's sweep, exactly like it would be to a raycast (see
|
|
57
|
+
// Rapier3dWorldComponent's own raycast tests, which all call world.simulate() first for the same
|
|
58
|
+
// reason). A zero-length timestep step is enough to register geometry without moving anything, and
|
|
59
|
+
// stands in for "the world/level was already running before this character got added" - the one
|
|
60
|
+
// thing the synchronous `move()` contract does NOT need to hold is any *additional* simulate() call
|
|
61
|
+
// in between/after move() calls, which is what the tests below actually exercise.
|
|
62
|
+
const settleWorld = () => world.simulate(0);
|
|
63
|
+
|
|
64
|
+
it('should create a character controller and add it to the world', () => {
|
|
65
|
+
const character = factory.createCharacterController(CHAR_OPTIONS, { position: { x: 0, y: 0, z: 5 } });
|
|
66
|
+
expect(character.radius).toBe(0.4);
|
|
67
|
+
expect(character.centersDistance).toBe(1.0);
|
|
68
|
+
character.addToWorld({ physicsWorld: world } as any);
|
|
69
|
+
expect(world.children).toContain(character);
|
|
70
|
+
expect(character.position).toEqual({ x: 0, y: 0, z: 5 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should settle on a flat floor and report grounded state', () => {
|
|
74
|
+
addFloor(0, 20, 0);
|
|
75
|
+
const character = factory.createCharacterController(CHAR_OPTIONS, { position: { x: 0, y: 0, z: 5 } });
|
|
76
|
+
character.addToWorld({ physicsWorld: world } as any);
|
|
77
|
+
settleWorld();
|
|
78
|
+
|
|
79
|
+
expect(character.isGrounded).toBe(false);
|
|
80
|
+
|
|
81
|
+
character.move({ x: 0, y: 0, z: -10 });
|
|
82
|
+
|
|
83
|
+
// resolved fully synchronously - no world.simulate() call happened after settleWorld() above
|
|
84
|
+
expect(character.isGrounded).toBe(true);
|
|
85
|
+
expect(character.position.z).toBeCloseTo(HALF_HEIGHT, 1);
|
|
86
|
+
expect(character.groundNormal).not.toBeNull();
|
|
87
|
+
expect(character.groundNormal!.z).toBeGreaterThan(0.9);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('reports the real (steep) contact normal when resting against the flank of a sphere, not a flattened `up`', () => {
|
|
91
|
+
// A sphere resting on the floor, center at (0,0,1) - the character is placed a hair above the
|
|
92
|
+
// point on its flank at x=1.2 (well off the apex) and nudged down by a small amount, landing on
|
|
93
|
+
// a contact whose true outward normal is steep (~59° off `up`, past any reasonable
|
|
94
|
+
// `maxSlopeClimbAngleRad`), not flat. A large single downward move (like the flat-floor test
|
|
95
|
+
// above uses) would just slide the capsule all the way down and off the sphere onto the floor
|
|
96
|
+
// instead of stopping on the flank - not representative of the real symptom, which is a small
|
|
97
|
+
// per-tick settle move (as `CharacterController3dEntity`'s gravity integration does) landing in
|
|
98
|
+
// continued contact with the steep surface.
|
|
99
|
+
addFloor(0, 20, 0);
|
|
100
|
+
const sphere = factory.createRigidBody(
|
|
101
|
+
{ shape: { shape: 'SPHERE', radius: 1 }, body: { dynamic: false, mass: 0 } },
|
|
102
|
+
{ position: { x: 0, y: 0, z: 1 } },
|
|
103
|
+
);
|
|
104
|
+
sphere.addToWorld({ physicsWorld: world } as any);
|
|
105
|
+
// touching distance between capsule (radius 0.4) and sphere (radius 1) centers is 1.4; at
|
|
106
|
+
// dx=1.2, dz = sqrt(1.4^2 - 1.2^2) ~= 0.721, so the capsule center touches at z ~= 1.721
|
|
107
|
+
const character = factory.createCharacterController(CHAR_OPTIONS, { position: { x: 1.2, y: 0, z: 1.73 } });
|
|
108
|
+
character.addToWorld({ physicsWorld: world } as any);
|
|
109
|
+
settleWorld();
|
|
110
|
+
|
|
111
|
+
character.move({ x: 0, y: 0, z: -0.05 });
|
|
112
|
+
|
|
113
|
+
expect(character.isGrounded).toBe(true);
|
|
114
|
+
expect(character.groundNormal).not.toBeNull();
|
|
115
|
+
// the true contact normal here (capsule radius 0.4 against a unit sphere, touching along the
|
|
116
|
+
// line between their centers, combined radius 1.4) has a `z` around ~0.5 - well below both a
|
|
117
|
+
// flat-floor normal's z (~1) and the ~0.64 that a 50° maxSlopeClimbAngleRad would allow. An
|
|
118
|
+
// earlier version of computeGroundNormal() discarded any candidate normal with
|
|
119
|
+
// `dot(normal, up) <= 0.1` and fell back to the plain `up` vector instead - which would have
|
|
120
|
+
// reported `groundNormal.z` close to 1 here, exactly the bug this guards against (see
|
|
121
|
+
// gg-engine-physics-adapter-rapier's "computeGroundNormal" pitfall).
|
|
122
|
+
expect(character.groundNormal!.z).toBeGreaterThan(0.1);
|
|
123
|
+
expect(character.groundNormal!.z).toBeLessThan(0.6);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should slide to a stop against a wall instead of passing through it', () => {
|
|
127
|
+
addFloor(0, 20, 0);
|
|
128
|
+
addWall(2);
|
|
129
|
+
const character = factory.createCharacterController(CHAR_OPTIONS, {
|
|
130
|
+
position: { x: 0, y: 0, z: HALF_HEIGHT + 0.5 },
|
|
131
|
+
});
|
|
132
|
+
character.addToWorld({ physicsWorld: world } as any);
|
|
133
|
+
settleWorld();
|
|
134
|
+
// settle onto the floor first
|
|
135
|
+
character.move({ x: 0, y: 0, z: -1 });
|
|
136
|
+
expect(character.isGrounded).toBe(true);
|
|
137
|
+
|
|
138
|
+
character.move({ x: 5, y: 0, z: 0 });
|
|
139
|
+
|
|
140
|
+
// wall's near face is at x=2; the capsule (radius 0.4) plus its skin offset must stop short of it
|
|
141
|
+
expect(character.position.x).toBeLessThan(1.6);
|
|
142
|
+
expect(character.position.x).toBeGreaterThan(0.5);
|
|
143
|
+
// remained grounded throughout - sliding along the wall, not falling
|
|
144
|
+
expect(character.isGrounded).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should step up a ledge shorter than maxStepHeight without getting stuck', () => {
|
|
148
|
+
const maxStepHeight = 0.3;
|
|
149
|
+
const stepZ = 0.2; // below maxStepHeight
|
|
150
|
+
addFloor(-5, 10, 0); // lower floor, spans x in [-10, 0]
|
|
151
|
+
addFloor(5, 10, stepZ); // upper ledge, spans x in [0, 10], step wall at x=0
|
|
152
|
+
|
|
153
|
+
const character = factory.createCharacterController(
|
|
154
|
+
{ ...CHAR_OPTIONS, maxStepHeight },
|
|
155
|
+
{ position: { x: -5, y: 0, z: HALF_HEIGHT + 0.5 } },
|
|
156
|
+
);
|
|
157
|
+
character.addToWorld({ physicsWorld: world } as any);
|
|
158
|
+
settleWorld();
|
|
159
|
+
character.move({ x: 0, y: 0, z: -1 });
|
|
160
|
+
expect(character.isGrounded).toBe(true);
|
|
161
|
+
expect(character.position.z).toBeCloseTo(HALF_HEIGHT, 1);
|
|
162
|
+
|
|
163
|
+
// walk forward across the step in small increments, each with a slight downward bias so the
|
|
164
|
+
// character does not need to rely on gravity (which this component never applies itself)
|
|
165
|
+
for (let i = 0; i < 60; i++) {
|
|
166
|
+
character.move({ x: 0.15, y: 0, z: -0.05 });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
expect(character.isGrounded).toBe(true);
|
|
170
|
+
expect(character.position.x).toBeGreaterThan(2); // made meaningful forward progress past the step
|
|
171
|
+
expect(character.position.z).toBeCloseTo(stepZ + HALF_HEIGHT, 1); // stepped up onto the ledge
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
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', () => {
|
|
175
|
+
addFloor(0, 20, 0);
|
|
176
|
+
const character = factory.createCharacterController(CHAR_OPTIONS, {
|
|
177
|
+
position: { x: 0, y: 0, z: HALF_HEIGHT + 0.5 },
|
|
178
|
+
});
|
|
179
|
+
character.addToWorld({ physicsWorld: world } as any);
|
|
180
|
+
settleWorld();
|
|
181
|
+
character.move({ x: 0, y: 0, z: -1 });
|
|
182
|
+
expect(character.isGrounded).toBe(true);
|
|
183
|
+
const groundedZ = character.position.z;
|
|
184
|
+
|
|
185
|
+
// one tick's worth of a typical jumpSpeed (5 m/s) at 60fps - well under the 0.3 snapToGroundDistance
|
|
186
|
+
character.move({ x: 0, y: 0, z: 0.083 });
|
|
187
|
+
|
|
188
|
+
expect(character.position.z).toBeCloseTo(groundedZ + 0.083, 2);
|
|
189
|
+
expect(character.isGrounded).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("move()'s effects must be visible immediately, without an extra world.step() in between", () => {
|
|
193
|
+
addFloor(0, 20, 0);
|
|
194
|
+
addWall(1);
|
|
195
|
+
const character = factory.createCharacterController(CHAR_OPTIONS, {
|
|
196
|
+
position: { x: 0, y: 0, z: HALF_HEIGHT + 0.01 },
|
|
197
|
+
});
|
|
198
|
+
character.addToWorld({ physicsWorld: world } as any);
|
|
199
|
+
settleWorld();
|
|
200
|
+
|
|
201
|
+
// no world.simulate() call below this point - move() alone must fully resolve state
|
|
202
|
+
character.move({ x: 10, y: 0, z: 0 });
|
|
203
|
+
|
|
204
|
+
expect(character.position.x).toBeLessThan(0.6);
|
|
205
|
+
expect(character.position.x).toBeGreaterThan(-0.1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should clone with the same shape/options but independent from the world', () => {
|
|
209
|
+
const character = factory.createCharacterController(
|
|
210
|
+
{ radius: 0.3, centersDistance: 0.8, maxStepHeight: 0.25 },
|
|
211
|
+
{ position: { x: 1, y: 2, z: 3 } },
|
|
212
|
+
);
|
|
213
|
+
const clone = character.clone();
|
|
214
|
+
expect(clone.radius).toBe(character.radius);
|
|
215
|
+
expect(clone.centersDistance).toBe(character.centersDistance);
|
|
216
|
+
expect(clone.position).toEqual(character.position);
|
|
217
|
+
expect(clone.nativeBody).toBeNull();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should clone from the CURRENT position/rotation, not the construction-time spawn point', () => {
|
|
221
|
+
const character = factory.createCharacterController(CHAR_OPTIONS, { position: { x: 0, y: 0, z: 5 } });
|
|
222
|
+
character.addToWorld({ physicsWorld: world } as any);
|
|
223
|
+
settleWorld();
|
|
224
|
+
// move the character away from its construction-time position via the position setter, the
|
|
225
|
+
// same code path that leaves `_bodyDescr` stale once `_nativeBody` exists
|
|
226
|
+
character.position = { x: 10, y: 20, z: 30 };
|
|
227
|
+
|
|
228
|
+
const clone = character.clone();
|
|
229
|
+
|
|
230
|
+
expect(clone.position).toEqual(character.position);
|
|
231
|
+
expect(clone.position).not.toEqual({ x: 0, y: 0, z: 5 });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('move() before addToWorld() should be a silent no-op, not throw', () => {
|
|
235
|
+
const character = factory.createCharacterController(CHAR_OPTIONS, { position: { x: 0, y: 0, z: 5 } });
|
|
236
|
+
expect(() => character.move({ x: 1, y: 0, z: 0 })).not.toThrow();
|
|
237
|
+
expect(character.position).toEqual({ x: 0, y: 0, z: 5 });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should normalize a non-unit-length up vector, matching the Ammo adapter', () => {
|
|
241
|
+
const character = factory.createCharacterController(
|
|
242
|
+
{ ...CHAR_OPTIONS, up: { x: 0, y: 0, z: 2 } },
|
|
243
|
+
{ position: { x: 0, y: 0, z: 5 } },
|
|
244
|
+
);
|
|
245
|
+
expect(character.up).toEqual({ x: 0, y: 0, z: 1 });
|
|
246
|
+
|
|
247
|
+
character.up = { x: 0, y: 0, z: 3 };
|
|
248
|
+
expect(character.up).toEqual({ x: 0, y: 0, z: 1 });
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { CharacterController3dEntity, KeyboardInput, Pnt3, PlayerCharacterController, Qtrn } from '@gg-web-engine/core';
|
|
2
|
+
import { Rapier3dWorldComponent } from '../../src';
|
|
3
|
+
|
|
4
|
+
// A camera looking horizontally toward +Y - identity rotation would look straight down (local -Z
|
|
5
|
+
// maps to world -Z at identity, in this Z-up engine), a degenerate/arbitrary case for
|
|
6
|
+
// `PlayerCharacterController.reset()`'s yaw derivation; every real app sets an actual look
|
|
7
|
+
// direction, so this is the representative case.
|
|
8
|
+
const fakeCamera = () => ({ position: { x: 0, y: 0, z: 0 }, rotation: Qtrn.lookAt(Pnt3.O, Pnt3.Y) }) as any;
|
|
9
|
+
|
|
10
|
+
const setup = async () => {
|
|
11
|
+
const world = new Rapier3dWorldComponent();
|
|
12
|
+
await world.init();
|
|
13
|
+
world.gravity = { x: 0, y: 0, z: 0 };
|
|
14
|
+
const floor = world.factory.createRigidBody(
|
|
15
|
+
{ shape: { shape: 'BOX', dimensions: { x: 100, y: 100, z: 1 } }, body: { dynamic: false, mass: 0 } },
|
|
16
|
+
{ position: { x: 0, y: 0, z: -0.5 } },
|
|
17
|
+
);
|
|
18
|
+
floor.addToWorld({ physicsWorld: world } as any);
|
|
19
|
+
|
|
20
|
+
const characterController = world.factory.createCharacterController(
|
|
21
|
+
{ radius: 0.4, centersDistance: 1.0 },
|
|
22
|
+
{ position: { x: 0, y: 0, z: 1.0 } },
|
|
23
|
+
);
|
|
24
|
+
const character = new CharacterController3dEntity(
|
|
25
|
+
{ radius: 0.4, centersDistance: 1.0, walkSpeed: 4, jumpSpeed: 5, gravity: 9.82 },
|
|
26
|
+
null,
|
|
27
|
+
characterController,
|
|
28
|
+
);
|
|
29
|
+
character.onSpawned({ physicsWorld: world } as any);
|
|
30
|
+
world.simulate(1);
|
|
31
|
+
|
|
32
|
+
const keyboard = new KeyboardInput();
|
|
33
|
+
keyboard.start();
|
|
34
|
+
const camera = fakeCamera();
|
|
35
|
+
const controller = new PlayerCharacterController(keyboard, character, camera);
|
|
36
|
+
await controller.onSpawned({ physicsWorld: world } as any);
|
|
37
|
+
|
|
38
|
+
const tickBoth = (elapsed: number, dt: number) => {
|
|
39
|
+
character.tick$.next([elapsed, dt]);
|
|
40
|
+
controller.tick$.next([elapsed, dt]);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < 10; i++) tickBoth(i * 16, 16);
|
|
44
|
+
expect(character.isGrounded).toBe(true);
|
|
45
|
+
|
|
46
|
+
return { world, character, keyboard, controller, tickBoth };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
describe('PlayerCharacterController + Rapier3dCharacterControllerComponent - end-to-end', () => {
|
|
50
|
+
it('walks forward the expected distance and jumps when the real physics is driven through the real input controller', async () => {
|
|
51
|
+
const { character, keyboard, tickBoth } = await setup();
|
|
52
|
+
const settledY = character.position.y;
|
|
53
|
+
const settledZ = character.position.z;
|
|
54
|
+
|
|
55
|
+
keyboard.emulateKeyDown('KeyW');
|
|
56
|
+
const dtMs = 16;
|
|
57
|
+
const steps = 60;
|
|
58
|
+
const groundedThroughout: boolean[] = [];
|
|
59
|
+
for (let i = 0; i < steps; i++) {
|
|
60
|
+
tickBoth(i * dtMs, dtMs);
|
|
61
|
+
groundedThroughout.push(character.isGrounded);
|
|
62
|
+
}
|
|
63
|
+
keyboard.emulateKeyUp('KeyW');
|
|
64
|
+
|
|
65
|
+
const elapsedSeconds = (steps * dtMs) / 1000;
|
|
66
|
+
const expectedDistance = 4 * elapsedSeconds;
|
|
67
|
+
const actualDistance = character.position.y - settledY;
|
|
68
|
+
console.log(`forward: expected ~${expectedDistance.toFixed(2)}m, actual ${actualDistance.toFixed(2)}m`);
|
|
69
|
+
expect(groundedThroughout.every(g => g)).toBe(true);
|
|
70
|
+
expect(actualDistance).toBeGreaterThan(expectedDistance * 0.8);
|
|
71
|
+
expect(actualDistance).toBeLessThan(expectedDistance * 1.2);
|
|
72
|
+
|
|
73
|
+
expect(character.isGrounded).toBe(true);
|
|
74
|
+
keyboard.emulateKeyDown('Space');
|
|
75
|
+
keyboard.emulateKeyUp('Space');
|
|
76
|
+
let peakZ = character.position.z;
|
|
77
|
+
for (let i = 0; i < 30; i++) {
|
|
78
|
+
tickBoth((steps + i) * dtMs, dtMs);
|
|
79
|
+
peakZ = Math.max(peakZ, character.position.z);
|
|
80
|
+
}
|
|
81
|
+
console.log(`jump: settled z ${settledZ.toFixed(2)}, peak z during jump ${peakZ.toFixed(2)}`);
|
|
82
|
+
expect(peakZ).toBeGreaterThan(settledZ + 0.3);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('walks backward the expected distance, without resistance', async () => {
|
|
86
|
+
const { character, keyboard, tickBoth } = await setup();
|
|
87
|
+
const settledY = character.position.y;
|
|
88
|
+
|
|
89
|
+
keyboard.emulateKeyDown('KeyS');
|
|
90
|
+
const dtMs = 16;
|
|
91
|
+
const steps = 60;
|
|
92
|
+
const groundedThroughout: boolean[] = [];
|
|
93
|
+
for (let i = 0; i < steps; i++) {
|
|
94
|
+
tickBoth(i * dtMs, dtMs);
|
|
95
|
+
groundedThroughout.push(character.isGrounded);
|
|
96
|
+
}
|
|
97
|
+
keyboard.emulateKeyUp('KeyS');
|
|
98
|
+
|
|
99
|
+
const elapsedSeconds = (steps * dtMs) / 1000;
|
|
100
|
+
const expectedDistance = 4 * elapsedSeconds;
|
|
101
|
+
const actualDistance = settledY - character.position.y;
|
|
102
|
+
console.log(`backward: expected ~${expectedDistance.toFixed(2)}m, actual ${actualDistance.toFixed(2)}m`);
|
|
103
|
+
expect(groundedThroughout.every(g => g)).toBe(true);
|
|
104
|
+
expect(actualDistance).toBeGreaterThan(expectedDistance * 0.8);
|
|
105
|
+
expect(actualDistance).toBeLessThan(expectedDistance * 1.2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it.each([
|
|
109
|
+
['right', 'KeyD', 1],
|
|
110
|
+
['left', 'KeyA', -1],
|
|
111
|
+
])('strafes %s the expected distance, without resistance', async (_label, key, sign) => {
|
|
112
|
+
const { character, keyboard, tickBoth } = await setup();
|
|
113
|
+
const settledX = character.position.x;
|
|
114
|
+
|
|
115
|
+
keyboard.emulateKeyDown(key);
|
|
116
|
+
const dtMs = 16;
|
|
117
|
+
const steps = 60;
|
|
118
|
+
const groundedThroughout: boolean[] = [];
|
|
119
|
+
for (let i = 0; i < steps; i++) {
|
|
120
|
+
tickBoth(i * dtMs, dtMs);
|
|
121
|
+
groundedThroughout.push(character.isGrounded);
|
|
122
|
+
}
|
|
123
|
+
keyboard.emulateKeyUp(key);
|
|
124
|
+
|
|
125
|
+
const elapsedSeconds = (steps * dtMs) / 1000;
|
|
126
|
+
const expectedDistance = 4 * elapsedSeconds;
|
|
127
|
+
const actualDistance = (character.position.x - settledX) * sign;
|
|
128
|
+
console.log(`strafe ${_label}: expected ~${expectedDistance.toFixed(2)}m, actual ${actualDistance.toFixed(2)}m`);
|
|
129
|
+
expect(groundedThroughout.every(g => g)).toBe(true);
|
|
130
|
+
expect(actualDistance).toBeGreaterThan(expectedDistance * 0.8);
|
|
131
|
+
expect(actualDistance).toBeLessThan(expectedDistance * 1.2);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('CharacterController3dEntity + Rapier3dCharacterControllerComponent - slope rejection', () => {
|
|
136
|
+
it('slides off a too-steep sphere flank under gravity instead of staying stuck with no input', async () => {
|
|
137
|
+
// No PlayerCharacterController/keyboard involved here - this is purely gravity vs.
|
|
138
|
+
// `isWalkableGround`, driven with real (nonzero) gravity, unlike this file's other tests.
|
|
139
|
+
const world = new Rapier3dWorldComponent();
|
|
140
|
+
await world.init();
|
|
141
|
+
world.gravity = { x: 0, y: 0, z: -9.82 };
|
|
142
|
+
const floor = world.factory.createRigidBody(
|
|
143
|
+
{ shape: { shape: 'BOX', dimensions: { x: 100, y: 100, z: 1 } }, body: { dynamic: false, mass: 0 } },
|
|
144
|
+
{ position: { x: 0, y: 0, z: -0.5 } },
|
|
145
|
+
);
|
|
146
|
+
floor.addToWorld({ physicsWorld: world } as any);
|
|
147
|
+
const sphere = world.factory.createRigidBody(
|
|
148
|
+
{ shape: { shape: 'SPHERE', radius: 1 }, body: { dynamic: false, mass: 0 } },
|
|
149
|
+
{ position: { x: 0, y: 0, z: 1 } },
|
|
150
|
+
);
|
|
151
|
+
sphere.addToWorld({ physicsWorld: world } as any);
|
|
152
|
+
|
|
153
|
+
// touching position at dx=1.2 from sphere center: dz=sqrt(1.4^2-1.2^2)~=0.721 -> z~=1.721, a
|
|
154
|
+
// contact whose true outward normal is ~59° off `up` - past the default ~50°
|
|
155
|
+
// `maxSlopeClimbAngleRad`, so the character should slide straight off it, not stand there.
|
|
156
|
+
const characterController = world.factory.createCharacterController(
|
|
157
|
+
{ radius: 0.4, centersDistance: 1.0 },
|
|
158
|
+
{ position: { x: 1.2, y: 0, z: 1.73 } },
|
|
159
|
+
);
|
|
160
|
+
const character = new CharacterController3dEntity(
|
|
161
|
+
{ radius: 0.4, centersDistance: 1.0, walkSpeed: 4, jumpSpeed: 4 },
|
|
162
|
+
null,
|
|
163
|
+
characterController,
|
|
164
|
+
);
|
|
165
|
+
character.onSpawned({ physicsWorld: world } as any);
|
|
166
|
+
world.simulate(1);
|
|
167
|
+
|
|
168
|
+
const dtMs = 16;
|
|
169
|
+
for (let i = 0; i < 200; i++) {
|
|
170
|
+
character.tick$.next([i * dtMs, dtMs]);
|
|
171
|
+
world.simulate(dtMs);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// must not remain perched on the sphere flank (z would stay well above the floor's resting
|
|
175
|
+
// height, ~0.91, if stuck) - regression test for computeGroundNormal() falling back to a flat
|
|
176
|
+
// `up` normal on the many idle ticks that find zero fresh collisions while still (correctly)
|
|
177
|
+
// grounded via snap - see gg-engine-physics-adapter-rapier's "computeGroundNormal" pitfall.
|
|
178
|
+
expect(character.position.z).toBeLessThan(1.2);
|
|
179
|
+
expect(character.isGrounded).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Pnt3 } from '@gg-web-engine/core';
|
|
2
|
+
import { Rapier3dFactory, Rapier3dWorldComponent } from '../src';
|
|
3
|
+
|
|
4
|
+
describe('Rapier3dFactory', () => {
|
|
5
|
+
let world: Rapier3dWorldComponent;
|
|
6
|
+
let factory: Rapier3dFactory;
|
|
7
|
+
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
if (world) {
|
|
10
|
+
world.dispose();
|
|
11
|
+
}
|
|
12
|
+
world = new Rapier3dWorldComponent();
|
|
13
|
+
factory = new Rapier3dFactory(world);
|
|
14
|
+
await world.init();
|
|
15
|
+
world.gravity = Pnt3.O;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
world.dispose();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('createCharacterController', () => {
|
|
23
|
+
it('should produce a component usable without any extra setup', () => {
|
|
24
|
+
const character = factory.createCharacterController(
|
|
25
|
+
{ radius: 0.4, centersDistance: 1.0 },
|
|
26
|
+
{ position: { x: 0, y: 0, z: 5 } },
|
|
27
|
+
);
|
|
28
|
+
expect(character.radius).toBe(0.4);
|
|
29
|
+
expect(character.centersDistance).toBe(1.0);
|
|
30
|
+
expect(character.position).toEqual({ x: 0, y: 0, z: 5 });
|
|
31
|
+
expect(character.ownCollisionGroups).toEqual([world.mainCollisionGroup]);
|
|
32
|
+
expect(character.interactWithCollisionGroups).toEqual(Array.from({ length: 16 }, (_, i) => i)); // 'all' default
|
|
33
|
+
|
|
34
|
+
character.addToWorld({ physicsWorld: world } as any);
|
|
35
|
+
expect(character.nativeBody).not.toBeNull();
|
|
36
|
+
expect(character.nativeCollider).not.toBeNull();
|
|
37
|
+
expect(character.nativeController).not.toBeNull();
|
|
38
|
+
|
|
39
|
+
// works: a downward move against a floor grounds it, fully synchronously
|
|
40
|
+
const floor = factory.createRigidBody(
|
|
41
|
+
{ shape: { shape: 'BOX', dimensions: { x: 20, y: 20, z: 1 } }, body: { dynamic: false, mass: 0 } },
|
|
42
|
+
{ position: { x: 0, y: 0, z: -0.5 } },
|
|
43
|
+
);
|
|
44
|
+
floor.addToWorld({ physicsWorld: world } as any);
|
|
45
|
+
// a freshly-created static collider only enters Rapier's broad-phase as part of a world step;
|
|
46
|
+
// a zero-length step registers it without moving anything (see the character-controller spec
|
|
47
|
+
// for the full explanation)
|
|
48
|
+
world.simulate(0);
|
|
49
|
+
character.move({ x: 0, y: 0, z: -10 });
|
|
50
|
+
expect(character.isGrounded).toBe(true);
|
|
51
|
+
|
|
52
|
+
character.removeFromWorld({ physicsWorld: world } as any);
|
|
53
|
+
expect(character.nativeBody).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should respect explicit collision group overrides', () => {
|
|
57
|
+
const group = world.registerCollisionGroup();
|
|
58
|
+
const character = factory.createCharacterController({
|
|
59
|
+
radius: 0.4,
|
|
60
|
+
centersDistance: 1.0,
|
|
61
|
+
ownCollisionGroups: [group],
|
|
62
|
+
interactWithCollisionGroups: [group],
|
|
63
|
+
});
|
|
64
|
+
expect(character.ownCollisionGroups).toEqual([group]);
|
|
65
|
+
expect(character.interactWithCollisionGroups).toEqual([group]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|