@gg-web-engine/rapier3d 0.0.63 → 0.0.64

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.
@@ -0,0 +1,131 @@
1
+ import { CharacterController3dOptions, CollisionGroup, DebugBody3DSettings, Entity3d, ICharacterController3dComponent, Point3, Point4 } from '@gg-web-engine/core';
2
+ import { Collider, InteractionGroups, KinematicCharacterController, RigidBody, RigidBodyDesc } from '@dimforge/rapier3d-compat';
3
+ import { Rapier3dWorldComponent } from './rapier-3d-world.component';
4
+ import { Rapier3dGgWorld, Rapier3dPhysicsTypeDocRepo } from '../types';
5
+ /**
6
+ * A capsule-shaped kinematic character controller backed by Rapier's own `KinematicCharacterController`
7
+ * (`world.createCharacterController`). `move()` is made fully synchronous (see
8
+ * `ICharacterController3dComponent`'s doc for why this matters) by never relying on
9
+ * `setNextKinematicTranslation` + a later `world.step()` to actually reposition the body - the plain
10
+ * (non-"next") `RigidBody.setTranslation`/`setRotation` is used instead, immediately followed by
11
+ * `World.propagateModifiedBodyPositionsToColliders` so the capsule's new position is visible to
12
+ * Rapier's collider state (and thus to the *next* `move()` call, or to any raycast) without needing a
13
+ * simulation step in between. `setNextKinematicTranslation`/`setNextKinematicRotation` are
14
+ * additionally set to the same target so that dynamic bodies pushed by the character still get a
15
+ * reasonable velocity estimate on whatever `world.step()` happens to run afterwards - this is a
16
+ * nice-to-have, not load-bearing for the synchronous contract.
17
+ *
18
+ * Note: a collider only enters Rapier's broad-phase as part of a `World.step()` - a level's static
19
+ * geometry (or this character itself) created and never stepped even once is invisible to `move()`'s
20
+ * sweep test, exactly as it would be to `world.raycast()`. This is a pre-existing engine property, not
21
+ * specific to this component; a normal per-frame game loop that calls `physicsWorld.simulate()`
22
+ * every tick already satisfies it after the first tick.
23
+ *
24
+ * Note: unlike `Rapier3dRigidBodyComponent`/`Rapier3dTriggerComponent`, this component's native body
25
+ * handle is *not* registered in `Rapier3dWorldComponent.handleIdEntityMap` - `world.raycast()` cannot
26
+ * currently resolve a hit against a character controller back to this component (it will simply be
27
+ * absent from `RaycastResult.hitBody`). Wiring that up would require widening the reverse-map's and
28
+ * `raycast()`'s return-type generics repo-wide for a corner case outside this interface's contract;
29
+ * left as a documented limitation rather than done speculatively.
30
+ */
31
+ export declare class Rapier3dCharacterControllerComponent implements ICharacterController3dComponent<Rapier3dPhysicsTypeDocRepo> {
32
+ protected readonly world: Rapier3dWorldComponent;
33
+ protected readonly options: Required<CharacterController3dOptions>;
34
+ protected _bodyDescr: RigidBodyDesc;
35
+ entity: Entity3d | null;
36
+ name: string;
37
+ readonly radius: number;
38
+ readonly centersDistance: number;
39
+ private _up;
40
+ get up(): Point3;
41
+ set up(value: Point3);
42
+ private _isGrounded;
43
+ get isGrounded(): boolean;
44
+ private _groundNormal;
45
+ get groundNormal(): Point3 | null;
46
+ private static warnedMissingDtForPush;
47
+ protected _nativeBody: RigidBody | null;
48
+ protected _nativeCollider: Collider | null;
49
+ protected _nativeController: KinematicCharacterController | null;
50
+ get nativeBody(): RigidBody | null;
51
+ get nativeCollider(): Collider | null;
52
+ get nativeController(): KinematicCharacterController | null;
53
+ readonly debugBodySettings: DebugBody3DSettings;
54
+ get position(): Point3;
55
+ set position(value: Point3);
56
+ get rotation(): Point4;
57
+ set rotation(value: Point4);
58
+ protected collisionGroups: InteractionGroups;
59
+ get interactWithCollisionGroups(): ReadonlyArray<CollisionGroup>;
60
+ set interactWithCollisionGroups(value: ReadonlyArray<CollisionGroup> | 'all');
61
+ get ownCollisionGroups(): ReadonlyArray<CollisionGroup>;
62
+ set ownCollisionGroups(value: ReadonlyArray<CollisionGroup> | 'all');
63
+ constructor(world: Rapier3dWorldComponent, options: Required<CharacterController3dOptions>, _bodyDescr: RigidBodyDesc);
64
+ /**
65
+ * Makes any position/rotation change applied directly to `_nativeBody` (outside of `move()`, e.g.
66
+ * via the `position`/`rotation` setters) immediately visible to Rapier's collider state, without
67
+ * requiring a `world.step()` - see the class doc for why this matters. The pinned
68
+ * `@dimforge/rapier3d-compat` build only exposes `propagateModifiedBodyPositionsToColliders()` for
69
+ * this (no separate `QueryPipeline`/`updateSceneQueries` object to rebuild - the character
70
+ * controller queries `World`'s live `broadPhase`/`narrowPhase` directly), so that's the only call
71
+ * needed here.
72
+ */
73
+ private syncColliderTransform;
74
+ move(desiredTranslation: Point3, dt?: number): void;
75
+ /**
76
+ * Shoves any dynamic body this tick's sweep bumped into - see `addToWorld`'s doc for why this is
77
+ * hand-rolled rather than Rapier's own `setApplyImpulsesToDynamicBodies`. Mirrors
78
+ * `AmmoCharacterControllerComponent.pushDynamicBody` exactly: models the contact as a simple
79
+ * inelastic collision against a virtual body of mass `options.pushMass` moving at `characterSpeed`
80
+ * (this tick's *horizontal* displacement - vertical/jump motion never pushes anything sideways -
81
+ * converted to a real m/s via `dt`, not a raw per-tick distance), driving the hit body's velocity
82
+ * along the push direction towards `characterSpeed * pushMass / (pushMass + bodyMass)` and only
83
+ * ever adding forward velocity, never removing any (so a body already outrunning the character in
84
+ * that direction is left alone). `computedCollision()` already has everything needed - populated
85
+ * by the `computeColliderMovement` call above regardless of this method's own logic, so no extra
86
+ * sweep/query is needed to reach it.
87
+ */
88
+ private pushDynamicBodies;
89
+ /**
90
+ * Rapier's character controller doesn't expose a single "ground normal" directly - only a list of
91
+ * per-obstacle collisions (`computedCollision`) from the last `computeColliderMovement` call, each
92
+ * with its own contact normal. Best-effort approach: scan those collisions and return whichever
93
+ * normal points *most* nearly along `up` (i.e. the most floor-like of the bunch, however steep it
94
+ * actually is) - this deliberately does **not** discard a candidate merely for being steep (e.g.
95
+ * balanced on the flank of a sphere/cylinder, far past `maxSlopeClimbAngleRad`): that judgment
96
+ * belongs entirely to `CharacterController3dEntity.isWalkableGround` at the core level, which
97
+ * needs the *real* contact normal to make it, not a value already pre-filtered down here. An
98
+ * earlier version discarded any candidate with `dot(normal, up) <= 0.1` and fell back to the plain
99
+ * `up` vector when nothing cleared that bar - which silently reported perfectly-flat ground for a
100
+ * character resting against a normal steep enough to fail that same threshold, defeating
101
+ * `isWalkableGround` entirely (confirmed empirically: a character run-and-jumped onto the flank of
102
+ * a static sphere, landing on a contact whose true outward normal was ~70° off `up` - well past the
103
+ * default ~50° `maxSlopeClimbAngleRad` - permanently reported `groundNormal: {0,0,1}` instead, so
104
+ * the core entity kept treating it as resting on flat ground and it never slid off, visibly stuck
105
+ * balanced on a sliver of the sphere even with every input released).
106
+ *
107
+ * `numComputedCollisions()` itself is frequently `0` on a call that is still genuinely grounded -
108
+ * `computeColliderMovement` doesn't record an entry for a character caught by snap-to-ground alone
109
+ * (no obstacle actually blocked the *desired* movement that call), which in practice is most idle
110
+ * ticks: a character standing still (`desiredTranslation` exactly `{0,0,0}`, e.g. player released
111
+ * every key) has nothing for the sweep to hit, so it settles into being grounded via snap alone,
112
+ * over and over, tick after tick, without ever producing a fresh collision entry again. Guessing
113
+ * flat `up` on every such tick is exactly the bug above, just via a different, far more common
114
+ * path than "no collision was ever recorded" suggests - it's not a rare edge case, it's what happens
115
+ * the very first idle tick after any landing (including this one, right after the collision that
116
+ * *did* populate the true steep normal above). Fix: on a `0`-collision grounded call, reuse
117
+ * whichever normal this same field already held **before** this call (the character's own contact
118
+ * geometry hasn't changed just because this particular call didn't happen to re-sweep it) rather
119
+ * than guessing - `move()` only overwrites `this._groundNormal` with this method's return value
120
+ * *after* calling it, so reading the field here still sees the previous call's result. Only when
121
+ * there is no prior normal to reuse either (the very first grounded call ever, landing exactly via
122
+ * snap with nothing recorded yet) does this fall back to the plain `up` vector. Returns `null` if
123
+ * not grounded at all - `_groundNormal` naturally clears itself the moment the character goes
124
+ * airborne, so a later landing never reuses a stale value from a previous, unrelated surface.
125
+ */
126
+ private computeGroundNormal;
127
+ clone(): Rapier3dCharacterControllerComponent;
128
+ addToWorld(world: Rapier3dGgWorld): void;
129
+ removeFromWorld(world: Rapier3dGgWorld, dispose?: boolean): void;
130
+ dispose(): void;
131
+ }
@@ -0,0 +1,398 @@
1
+ import { BitMask, DebugBody3DSettings, Pnt3, Qtrn, } from '@gg-web-engine/core';
2
+ import { Quaternion, RigidBodyDesc, Vector3, } from '@dimforge/rapier3d-compat';
3
+ /**
4
+ * A capsule-shaped kinematic character controller backed by Rapier's own `KinematicCharacterController`
5
+ * (`world.createCharacterController`). `move()` is made fully synchronous (see
6
+ * `ICharacterController3dComponent`'s doc for why this matters) by never relying on
7
+ * `setNextKinematicTranslation` + a later `world.step()` to actually reposition the body - the plain
8
+ * (non-"next") `RigidBody.setTranslation`/`setRotation` is used instead, immediately followed by
9
+ * `World.propagateModifiedBodyPositionsToColliders` so the capsule's new position is visible to
10
+ * Rapier's collider state (and thus to the *next* `move()` call, or to any raycast) without needing a
11
+ * simulation step in between. `setNextKinematicTranslation`/`setNextKinematicRotation` are
12
+ * additionally set to the same target so that dynamic bodies pushed by the character still get a
13
+ * reasonable velocity estimate on whatever `world.step()` happens to run afterwards - this is a
14
+ * nice-to-have, not load-bearing for the synchronous contract.
15
+ *
16
+ * Note: a collider only enters Rapier's broad-phase as part of a `World.step()` - a level's static
17
+ * geometry (or this character itself) created and never stepped even once is invisible to `move()`'s
18
+ * sweep test, exactly as it would be to `world.raycast()`. This is a pre-existing engine property, not
19
+ * specific to this component; a normal per-frame game loop that calls `physicsWorld.simulate()`
20
+ * every tick already satisfies it after the first tick.
21
+ *
22
+ * Note: unlike `Rapier3dRigidBodyComponent`/`Rapier3dTriggerComponent`, this component's native body
23
+ * handle is *not* registered in `Rapier3dWorldComponent.handleIdEntityMap` - `world.raycast()` cannot
24
+ * currently resolve a hit against a character controller back to this component (it will simply be
25
+ * absent from `RaycastResult.hitBody`). Wiring that up would require widening the reverse-map's and
26
+ * `raycast()`'s return-type generics repo-wide for a corner case outside this interface's contract;
27
+ * left as a documented limitation rather than done speculatively.
28
+ */
29
+ export class Rapier3dCharacterControllerComponent {
30
+ get up() {
31
+ return this._up;
32
+ }
33
+ set up(value) {
34
+ var _a;
35
+ this._up = Pnt3.norm(value);
36
+ (_a = this._nativeController) === null || _a === void 0 ? void 0 : _a.setUp(new Vector3(this._up.x, this._up.y, this._up.z));
37
+ }
38
+ get isGrounded() {
39
+ return this._isGrounded;
40
+ }
41
+ get groundNormal() {
42
+ return this._groundNormal;
43
+ }
44
+ get nativeBody() {
45
+ return this._nativeBody;
46
+ }
47
+ get nativeCollider() {
48
+ return this._nativeCollider;
49
+ }
50
+ get nativeController() {
51
+ return this._nativeController;
52
+ }
53
+ get position() {
54
+ return Pnt3.clone(this._nativeBody ? this._nativeBody.translation() : this._bodyDescr.translation);
55
+ }
56
+ set position(value) {
57
+ if (this._nativeBody) {
58
+ const v = new Vector3(value.x, value.y, value.z);
59
+ this._nativeBody.setTranslation(v, true);
60
+ this._nativeBody.setNextKinematicTranslation(v);
61
+ this.syncColliderTransform();
62
+ }
63
+ else {
64
+ this._bodyDescr.setTranslation(value.x, value.y, value.z);
65
+ }
66
+ }
67
+ get rotation() {
68
+ return Qtrn.clone(this._nativeBody ? this._nativeBody.rotation() : this._bodyDescr.rotation);
69
+ }
70
+ set rotation(value) {
71
+ if (this._nativeBody) {
72
+ const q = new Quaternion(value.x, value.y, value.z, value.w);
73
+ this._nativeBody.setRotation(q, true);
74
+ this._nativeBody.setNextKinematicRotation(q);
75
+ this.syncColliderTransform();
76
+ }
77
+ else {
78
+ this._bodyDescr.setRotation(new Quaternion(value.x, value.y, value.z, value.w));
79
+ }
80
+ }
81
+ get interactWithCollisionGroups() {
82
+ return BitMask.unpack(this.collisionGroups, 16);
83
+ }
84
+ set interactWithCollisionGroups(value) {
85
+ var _a;
86
+ let mask = value === 'all' ? BitMask.full(16) : BitMask.pack(value, 16);
87
+ mask = mask | (this.collisionGroups & (BitMask.full(16) << 16));
88
+ if (mask === this.collisionGroups) {
89
+ return;
90
+ }
91
+ this.collisionGroups = mask;
92
+ (_a = this._nativeCollider) === null || _a === void 0 ? void 0 : _a.setCollisionGroups(this.collisionGroups);
93
+ }
94
+ get ownCollisionGroups() {
95
+ return BitMask.unpack(this.collisionGroups >> 16, 16);
96
+ }
97
+ set ownCollisionGroups(value) {
98
+ var _a;
99
+ let mask = value === 'all' ? BitMask.full(16) : BitMask.pack(value, 16);
100
+ mask = (mask << 16) | (this.collisionGroups & BitMask.full(16));
101
+ if (mask === this.collisionGroups) {
102
+ return;
103
+ }
104
+ this.collisionGroups = mask;
105
+ (_a = this._nativeCollider) === null || _a === void 0 ? void 0 : _a.setCollisionGroups(this.collisionGroups);
106
+ }
107
+ constructor(world, options, _bodyDescr) {
108
+ this.world = world;
109
+ this.options = options;
110
+ this._bodyDescr = _bodyDescr;
111
+ this.entity = null;
112
+ this.name = '';
113
+ this._isGrounded = false;
114
+ this._groundNormal = null;
115
+ this._nativeBody = null;
116
+ this._nativeCollider = null;
117
+ this._nativeController = null;
118
+ this.collisionGroups = BitMask.full(32);
119
+ this.radius = options.radius;
120
+ this.centersDistance = options.centersDistance;
121
+ this._up = Pnt3.norm(options.up);
122
+ this.debugBodySettings = new DebugBody3DSettings({ type: 'RIGID_DYNAMIC', sleeping: () => false }, { shape: 'CAPSULE', radius: this.radius, centersDistance: this.centersDistance });
123
+ this.ownCollisionGroups = options.ownCollisionGroups;
124
+ this.interactWithCollisionGroups = options.interactWithCollisionGroups;
125
+ }
126
+ /**
127
+ * Makes any position/rotation change applied directly to `_nativeBody` (outside of `move()`, e.g.
128
+ * via the `position`/`rotation` setters) immediately visible to Rapier's collider state, without
129
+ * requiring a `world.step()` - see the class doc for why this matters. The pinned
130
+ * `@dimforge/rapier3d-compat` build only exposes `propagateModifiedBodyPositionsToColliders()` for
131
+ * this (no separate `QueryPipeline`/`updateSceneQueries` object to rebuild - the character
132
+ * controller queries `World`'s live `broadPhase`/`narrowPhase` directly), so that's the only call
133
+ * needed here.
134
+ */
135
+ syncColliderTransform() {
136
+ this.world.nativeWorld.propagateModifiedBodyPositionsToColliders();
137
+ }
138
+ move(desiredTranslation, dt) {
139
+ if (!this._nativeBody || !this._nativeCollider || !this._nativeController) {
140
+ // not yet added to the world - nothing to sweep against (matches
141
+ // `AmmoCharacterControllerComponent.move`'s no-op contract, see `ICharacterController3dComponent`)
142
+ return;
143
+ }
144
+ // make sure collider positions reflect any obstacle moved (by anything) since the last
145
+ // world.step()/propagate call, so the upcoming sweep test is accurate
146
+ this.syncColliderTransform();
147
+ // Rapier's own snap-to-ground, left enabled unconditionally, would otherwise undo a jump
148
+ // takeoff the very next tick: `computeColliderMovement` treats a character within
149
+ // `snapToGroundDistance` of the floor it just left as still grounded and pulls it right back
150
+ // down onto it, and a jump's own per-tick rise (`jumpSpeed * dt`) starts out far smaller than
151
+ // the default 0.3 snap distance - so every jump was silently cancelled before it ever left the
152
+ // ground. This is the exact same failure mode `AmmoCharacterControllerComponent`'s own
153
+ // hand-rolled mover hit and fixed (see that class's `move()` doc's `movingUp` guard); toggling
154
+ // snap-to-ground off for ticks that are actively rising, back on otherwise, mirrors it here.
155
+ //
156
+ // Autostep needs the identical guard, for a related but distinct reason: it only misbehaves
157
+ // while jumping *and* simultaneously blocked horizontally by something taller than
158
+ // `maxStepHeight` (e.g. running at a barrier and jumping right as you reach it, rather than
159
+ // jumping in open space) - confirmed empirically, a jump that looked perfect in the open turned
160
+ // into a small up-then-snap-back-down "flick" the instant the same jump was attempted pressed
161
+ // up against such an obstacle, the rise stopping right around `maxStepHeight` itself before
162
+ // reverting to standing height. Autostep's own "raise up to maxStepHeight, retry the blocked
163
+ // horizontal move, keep the raise only if that retry actually clears" evaluation runs as part of
164
+ // the very same `computeColliderMovement` call handling the jump's vertical component - when the
165
+ // retry still doesn't clear (barrier taller than the raise), whatever it does to "give back" the
166
+ // failed step attempt isn't scoped to just the horizontal axis, so it cancels the deliberate
167
+ // vertical rise sharing that same call too. There's no real reason to want auto-step-climbing
168
+ // active while already deliberately jumping, so disable it under the same condition.
169
+ const movingUp = Pnt3.dot(desiredTranslation, this._up) > 1e-9;
170
+ if (movingUp) {
171
+ this._nativeController.disableSnapToGround();
172
+ this._nativeController.disableAutostep();
173
+ }
174
+ else {
175
+ if (this.options.snapToGroundDistance > 0) {
176
+ this._nativeController.enableSnapToGround(this.options.snapToGroundDistance);
177
+ }
178
+ if (this.options.maxStepHeight > 0) {
179
+ this._nativeController.enableAutostep(this.options.maxStepHeight, this.options.minStepWidth, true);
180
+ }
181
+ }
182
+ const desired = new Vector3(desiredTranslation.x, desiredTranslation.y, desiredTranslation.z);
183
+ this._nativeController.computeColliderMovement(this._nativeCollider, desired);
184
+ const computed = this._nativeController.computedMovement();
185
+ const current = this._nativeBody.translation();
186
+ const next = new Vector3(current.x + computed.x, current.y + computed.y, current.z + computed.z);
187
+ this._nativeBody.setTranslation(next, true);
188
+ this._nativeBody.setNextKinematicTranslation(next);
189
+ this.syncColliderTransform();
190
+ this._isGrounded = this._nativeController.computedGrounded();
191
+ this._groundNormal = this.computeGroundNormal();
192
+ this.pushDynamicBodies(desiredTranslation, dt);
193
+ }
194
+ /**
195
+ * Shoves any dynamic body this tick's sweep bumped into - see `addToWorld`'s doc for why this is
196
+ * hand-rolled rather than Rapier's own `setApplyImpulsesToDynamicBodies`. Mirrors
197
+ * `AmmoCharacterControllerComponent.pushDynamicBody` exactly: models the contact as a simple
198
+ * inelastic collision against a virtual body of mass `options.pushMass` moving at `characterSpeed`
199
+ * (this tick's *horizontal* displacement - vertical/jump motion never pushes anything sideways -
200
+ * converted to a real m/s via `dt`, not a raw per-tick distance), driving the hit body's velocity
201
+ * along the push direction towards `characterSpeed * pushMass / (pushMass + bodyMass)` and only
202
+ * ever adding forward velocity, never removing any (so a body already outrunning the character in
203
+ * that direction is left alone). `computedCollision()` already has everything needed - populated
204
+ * by the `computeColliderMovement` call above regardless of this method's own logic, so no extra
205
+ * sweep/query is needed to reach it.
206
+ */
207
+ pushDynamicBodies(desiredTranslation, dt) {
208
+ var _a;
209
+ const pushMass = this.options.pushMass;
210
+ if (pushMass <= 0 || !this._nativeController) {
211
+ return;
212
+ }
213
+ const vertical = Pnt3.scalarMult(this._up, Pnt3.dot(desiredTranslation, this._up));
214
+ const horizontal = Pnt3.sub(desiredTranslation, vertical);
215
+ const horizLen = Pnt3.len(horizontal);
216
+ if (horizLen <= 1e-9) {
217
+ return;
218
+ }
219
+ // `dt` is required to recover a real m/s speed from `horizLen` (see
220
+ // `ICharacterController3dComponent.move()`'s doc). Falling back to the raw per-tick
221
+ // displacement as if it were already a speed would understate push force by roughly a factor of
222
+ // `dt` - silently wrong, not just imprecise - so skip the push for this tick instead when `dt`
223
+ // isn't available, same as `AmmoCharacterControllerComponent.pushDynamicBody`.
224
+ if (!dt || dt <= 1e-9) {
225
+ if (!Rapier3dCharacterControllerComponent.warnedMissingDtForPush) {
226
+ Rapier3dCharacterControllerComponent.warnedMissingDtForPush = true;
227
+ console.warn('[Rapier3dCharacterControllerComponent] move() was called without `dt` while `pushMass` > ' +
228
+ '0 - skipping this dynamic-body push rather than approximating character speed from raw ' +
229
+ 'per-tick displacement (which would understate push force by roughly 1/dt). Pass the ' +
230
+ 'real tick delta (seconds) as the third argument to move() to enable pushing dynamic bodies.');
231
+ }
232
+ return;
233
+ }
234
+ const direction = Pnt3.scalarMult(horizontal, 1 / horizLen);
235
+ const characterSpeed = horizLen / dt;
236
+ const count = this._nativeController.numComputedCollisions();
237
+ for (let i = 0; i < count; i++) {
238
+ const collision = this._nativeController.computedCollision(i);
239
+ const body = (_a = collision === null || collision === void 0 ? void 0 : collision.collider) === null || _a === void 0 ? void 0 : _a.parent();
240
+ if (!body || !body.isDynamic()) {
241
+ continue;
242
+ }
243
+ const bodyMass = body.mass();
244
+ if (bodyMass <= 0) {
245
+ continue;
246
+ }
247
+ const pushSpeed = characterSpeed * (pushMass / (pushMass + bodyMass));
248
+ const v = body.linvel();
249
+ const currentAlong = v.x * direction.x + v.y * direction.y + v.z * direction.z;
250
+ if (pushSpeed <= currentAlong) {
251
+ continue;
252
+ }
253
+ const delta = pushSpeed - currentAlong;
254
+ body.setLinvel({ x: v.x + direction.x * delta, y: v.y + direction.y * delta, z: v.z + direction.z * delta }, true);
255
+ }
256
+ }
257
+ /**
258
+ * Rapier's character controller doesn't expose a single "ground normal" directly - only a list of
259
+ * per-obstacle collisions (`computedCollision`) from the last `computeColliderMovement` call, each
260
+ * with its own contact normal. Best-effort approach: scan those collisions and return whichever
261
+ * normal points *most* nearly along `up` (i.e. the most floor-like of the bunch, however steep it
262
+ * actually is) - this deliberately does **not** discard a candidate merely for being steep (e.g.
263
+ * balanced on the flank of a sphere/cylinder, far past `maxSlopeClimbAngleRad`): that judgment
264
+ * belongs entirely to `CharacterController3dEntity.isWalkableGround` at the core level, which
265
+ * needs the *real* contact normal to make it, not a value already pre-filtered down here. An
266
+ * earlier version discarded any candidate with `dot(normal, up) <= 0.1` and fell back to the plain
267
+ * `up` vector when nothing cleared that bar - which silently reported perfectly-flat ground for a
268
+ * character resting against a normal steep enough to fail that same threshold, defeating
269
+ * `isWalkableGround` entirely (confirmed empirically: a character run-and-jumped onto the flank of
270
+ * a static sphere, landing on a contact whose true outward normal was ~70° off `up` - well past the
271
+ * default ~50° `maxSlopeClimbAngleRad` - permanently reported `groundNormal: {0,0,1}` instead, so
272
+ * the core entity kept treating it as resting on flat ground and it never slid off, visibly stuck
273
+ * balanced on a sliver of the sphere even with every input released).
274
+ *
275
+ * `numComputedCollisions()` itself is frequently `0` on a call that is still genuinely grounded -
276
+ * `computeColliderMovement` doesn't record an entry for a character caught by snap-to-ground alone
277
+ * (no obstacle actually blocked the *desired* movement that call), which in practice is most idle
278
+ * ticks: a character standing still (`desiredTranslation` exactly `{0,0,0}`, e.g. player released
279
+ * every key) has nothing for the sweep to hit, so it settles into being grounded via snap alone,
280
+ * over and over, tick after tick, without ever producing a fresh collision entry again. Guessing
281
+ * flat `up` on every such tick is exactly the bug above, just via a different, far more common
282
+ * path than "no collision was ever recorded" suggests - it's not a rare edge case, it's what happens
283
+ * the very first idle tick after any landing (including this one, right after the collision that
284
+ * *did* populate the true steep normal above). Fix: on a `0`-collision grounded call, reuse
285
+ * whichever normal this same field already held **before** this call (the character's own contact
286
+ * geometry hasn't changed just because this particular call didn't happen to re-sweep it) rather
287
+ * than guessing - `move()` only overwrites `this._groundNormal` with this method's return value
288
+ * *after* calling it, so reading the field here still sees the previous call's result. Only when
289
+ * there is no prior normal to reuse either (the very first grounded call ever, landing exactly via
290
+ * snap with nothing recorded yet) does this fall back to the plain `up` vector. Returns `null` if
291
+ * not grounded at all - `_groundNormal` naturally clears itself the moment the character goes
292
+ * airborne, so a later landing never reuses a stale value from a previous, unrelated surface.
293
+ */
294
+ computeGroundNormal() {
295
+ var _a;
296
+ if (!this._isGrounded || !this._nativeController) {
297
+ return null;
298
+ }
299
+ const count = this._nativeController.numComputedCollisions();
300
+ let best = null;
301
+ let bestDot = -Infinity;
302
+ for (let i = 0; i < count; i++) {
303
+ const collision = this._nativeController.computedCollision(i);
304
+ if (!(collision === null || collision === void 0 ? void 0 : collision.normal1)) {
305
+ continue;
306
+ }
307
+ const normal = Pnt3.clone(collision.normal1);
308
+ const dot = Pnt3.dot(normal, this._up);
309
+ if (dot > bestDot) {
310
+ bestDot = dot;
311
+ best = normal;
312
+ }
313
+ }
314
+ return (_a = best !== null && best !== void 0 ? best : this._groundNormal) !== null && _a !== void 0 ? _a : Pnt3.clone(this._up);
315
+ }
316
+ clone() {
317
+ // read the CURRENT position/rotation, not `_bodyDescr`'s construction-time values - once
318
+ // `_nativeBody` exists, the `position`/`rotation` setters write straight to it and never touch
319
+ // `_bodyDescr` again (see those setters above), so `_bodyDescr` alone would be stale for any
320
+ // controller that has moved since being added to the world.
321
+ const pos = this.position;
322
+ const rot = this.rotation;
323
+ const bd = RigidBodyDesc.kinematicPositionBased();
324
+ bd.setTranslation(pos.x, pos.y, pos.z);
325
+ bd.setRotation(new Quaternion(rot.x, rot.y, rot.z, rot.w));
326
+ const comp = new Rapier3dCharacterControllerComponent(this.world, this.options, bd);
327
+ comp.collisionGroups = this.collisionGroups;
328
+ return comp;
329
+ }
330
+ addToWorld(world) {
331
+ if (world.physicsWorld != this.world) {
332
+ throw new Error('Rapier3D bodies cannot be shared between different worlds');
333
+ }
334
+ const nativeWorld = this.world.nativeWorld;
335
+ this._nativeBody = nativeWorld.createRigidBody(this._bodyDescr);
336
+ const colliderDescr = this.world.factory.createColliderDescr({
337
+ shape: 'CAPSULE',
338
+ radius: this.radius,
339
+ centersDistance: this.centersDistance,
340
+ })[0];
341
+ this._nativeCollider = nativeWorld.createCollider(colliderDescr, this._nativeBody);
342
+ this._nativeCollider.setCollisionGroups(this.collisionGroups);
343
+ this._nativeController = nativeWorld.createCharacterController(this.options.offset);
344
+ this._nativeController.setUp(new Vector3(this._up.x, this._up.y, this._up.z));
345
+ this._nativeController.setMaxSlopeClimbAngle(this.options.maxSlopeClimbAngleRad);
346
+ if (this.options.maxStepHeight > 0) {
347
+ this._nativeController.enableAutostep(this.options.maxStepHeight, this.options.minStepWidth, true);
348
+ }
349
+ if (this.options.snapToGroundDistance > 0) {
350
+ this._nativeController.enableSnapToGround(this.options.snapToGroundDistance);
351
+ }
352
+ // Rapier's own `KinematicCharacterController` has a built-in equivalent of `pushMass`
353
+ // (`setApplyImpulsesToDynamicBodies(true)` + `setCharacterMass(...)`) that looked like the
354
+ // obvious way to implement pushing here - no hand-rolled logic needed, unlike
355
+ // `AmmoCharacterControllerComponent` (whose ghost-based mover has no native equivalent at all).
356
+ // It was tried first, but is deliberately **not** used: confirmed empirically, enabling it on
357
+ // this kinematic-position-based character body doesn't just get the push physics wrong (mass
358
+ // ordering inverted - a *heavier* box ended up moving further than a lighter one at otherwise
359
+ // identical settings) but genuinely explodes - a pushed box's position jumped by 5+ meters in a
360
+ // single 16ms tick and kept climbing indefinitely tick after tick, not settling. Root cause not
361
+ // fully identified (plausibly `characterMass`'s override interacting badly with this body's own
362
+ // `mass()`, which a kinematic body reports as `0`, somewhere in Rapier's impulse resolution -
363
+ // not something this package's pinned `@dimforge/rapier3d-compat` build exposes enough to debug
364
+ // further from JS). `pushDynamicBodies` below is a hand-rolled equivalent instead, mirroring
365
+ // `AmmoCharacterControllerComponent.pushDynamicBody`'s own formula and contract exactly (down to
366
+ // the same `pushMass <= 0` "disable pushing" convention) - built on `computedCollision()`, which
367
+ // `computeColliderMovement` already populates every `move()` call regardless of this native
368
+ // feature being enabled, so no extra query is needed to reach it.
369
+ this.world.added$.next(this);
370
+ }
371
+ removeFromWorld(world, dispose) {
372
+ if (world.physicsWorld != this.world) {
373
+ throw new Error('Rapier3D bodies cannot be shared between different worlds');
374
+ }
375
+ if (this._nativeController) {
376
+ this.world.nativeWorld.removeCharacterController(this._nativeController);
377
+ this._nativeController = null;
378
+ }
379
+ if (this._nativeBody) {
380
+ if (this._nativeCollider) {
381
+ this.world.nativeWorld.removeCollider(this._nativeCollider, false);
382
+ this._nativeCollider = null;
383
+ }
384
+ this.world.nativeWorld.removeRigidBody(this._nativeBody);
385
+ this._nativeBody = null;
386
+ }
387
+ this.world.removed$.next(this);
388
+ }
389
+ dispose() {
390
+ if (this._nativeBody) {
391
+ this.removeFromWorld({ physicsWorld: this.world }, true);
392
+ }
393
+ }
394
+ }
395
+ // Module-wide (not per-instance) so a scene with several characters all being driven without
396
+ // `dt` still only logs once, not once per character per tick - see `pushDynamicBodies`'s
397
+ // missing-`dt` handling below.
398
+ Rapier3dCharacterControllerComponent.warnedMissingDtForPush = false;
@@ -12,7 +12,7 @@ export declare class Rapier3dRaycastVehicleComponent extends Rapier3dRigidBodyCo
12
12
  constructor(world: Rapier3dWorldComponent, chassisBody: Rapier3dRigidBodyComponent);
13
13
  get wheelSpeed(): number;
14
14
  addToWorld(world: Rapier3dGgWorld): void;
15
- removeFromWorld(world: Rapier3dGgWorld): void;
15
+ removeFromWorld(world: Rapier3dGgWorld, dispose?: boolean): void;
16
16
  addWheel(options: WheelOptions, suspensionOptions: SuspensionOptions): void;
17
17
  setSteering(wheelIndex: number, steering: number): void;
18
18
  applyEngineForce(wheelIndex: number, force: number): void;
@@ -26,14 +26,17 @@ export class Rapier3dRaycastVehicleComponent extends Rapier3dRigidBodyComponent
26
26
  }
27
27
  setInterval(() => this._nativeVehicle.updateVehicle(0.01), 10);
28
28
  }
29
- removeFromWorld(world) {
29
+ removeFromWorld(world, dispose) {
30
30
  if (world.physicsWorld != this.world) {
31
31
  throw new Error('Rapier3D bodies cannot be shared between different worlds');
32
32
  }
33
33
  if (this.nativeVehicle) {
34
34
  world.physicsWorld.nativeWorld.removeVehicleController(this.nativeVehicle);
35
35
  }
36
- super.removeFromWorld(world);
36
+ super.removeFromWorld(world, dispose);
37
+ if (dispose) {
38
+ this.dispose();
39
+ }
37
40
  }
38
41
  addWheel(options, suspensionOptions) {
39
42
  const descr = [
@@ -37,7 +37,7 @@ export declare class Rapier3dRigidBodyComponent implements IRigidBody3dComponent
37
37
  set ownCollisionGroups(value: ReadonlyArray<CollisionGroup> | 'all');
38
38
  clone(): Rapier3dRigidBodyComponent;
39
39
  addToWorld(world: Rapier3dGgWorld): void;
40
- removeFromWorld(world: Rapier3dGgWorld): void;
40
+ removeFromWorld(world: Rapier3dGgWorld, dispose?: boolean): void;
41
41
  resetMotion(): void;
42
42
  dispose(): void;
43
43
  }
@@ -150,7 +150,7 @@ export class Rapier3dRigidBodyComponent {
150
150
  this.world.handleIdEntityMap.set(this._nativeBody.handle, this);
151
151
  this.world.added$.next(this);
152
152
  }
153
- removeFromWorld(world) {
153
+ removeFromWorld(world, dispose) {
154
154
  if (world.physicsWorld != this.world) {
155
155
  throw new Error('Rapier3D bodies cannot be shared between different worlds');
156
156
  }
@@ -171,7 +171,7 @@ export class Rapier3dRigidBodyComponent {
171
171
  }
172
172
  dispose() {
173
173
  if (this.nativeBody) {
174
- this.removeFromWorld({ physicsWorld: this.world });
174
+ this.removeFromWorld({ physicsWorld: this.world }, true);
175
175
  }
176
176
  }
177
177
  }
@@ -17,7 +17,7 @@ export declare class Rapier3dTriggerComponent extends Rapier3dRigidBodyComponent
17
17
  protected readonly onLeft$: Subject<Rapier3dRigidBodyComponent>;
18
18
  constructor(world: Rapier3dWorldComponent, _colliderDescr: ColliderDesc[], shape: Shape3DDescriptor, _bodyDescr: RigidBodyDesc);
19
19
  addToWorld(world: Rapier3dGgWorld): void;
20
- removeFromWorld(world: Rapier3dGgWorld): void;
20
+ removeFromWorld(world: Rapier3dGgWorld, dispose?: boolean): void;
21
21
  checkOverlaps(): void;
22
22
  clone(): Rapier3dTriggerComponent;
23
23
  dispose(): void;
@@ -29,12 +29,12 @@ export class Rapier3dTriggerComponent extends Rapier3dRigidBodyComponent {
29
29
  this.world.handleIdEntityMap.set(this._nativeBody.handle, this);
30
30
  this.world.added$.next(this);
31
31
  }
32
- removeFromWorld(world) {
32
+ removeFromWorld(world, dispose) {
33
33
  for (const body of this.overlaps) {
34
34
  this.onLeft$.next(body);
35
35
  }
36
36
  this.overlaps.clear();
37
- super.removeFromWorld(world);
37
+ super.removeFromWorld(world, dispose);
38
38
  }
39
39
  checkOverlaps() {
40
40
  this.world.eventQueue.drainCollisionEvents((h1, h2, started) => {
@@ -1,18 +1,20 @@
1
1
  import { CollisionGroup, IPhysicsWorld3dComponent, Point3, RaycastOptions, RaycastResult } from '@gg-web-engine/core';
2
2
  import { EventQueue, World } from '@dimforge/rapier3d-compat';
3
3
  import { Rapier3dRigidBodyComponent } from './rapier-3d-rigid-body.component';
4
+ import { Rapier3dCharacterControllerComponent } from './rapier-3d-character-controller.component';
4
5
  import { Rapier3dFactory } from '../rapier-3d-factory';
5
6
  import { Rapier3dLoader } from '../rapier-3d-loader';
6
7
  import { Rapier3dPhysicsTypeDocRepo } from '../types';
7
8
  import { Subject } from 'rxjs';
9
+ type Rapier3dWorldChild = Rapier3dRigidBodyComponent | Rapier3dCharacterControllerComponent;
8
10
  export declare class Rapier3dWorldComponent implements IPhysicsWorld3dComponent<Rapier3dPhysicsTypeDocRepo> {
9
11
  private _factory;
10
12
  get factory(): Rapier3dFactory;
11
13
  private _loader;
12
14
  get loader(): Rapier3dLoader;
13
- readonly added$: Subject<Rapier3dRigidBodyComponent>;
14
- readonly removed$: Subject<Rapier3dRigidBodyComponent>;
15
- readonly children: Rapier3dRigidBodyComponent[];
15
+ readonly added$: Subject<Rapier3dWorldChild>;
16
+ readonly removed$: Subject<Rapier3dWorldChild>;
17
+ readonly children: Rapier3dWorldChild[];
16
18
  private _gravity;
17
19
  get gravity(): Point3;
18
20
  set gravity(value: Point3);
@@ -31,3 +33,4 @@ export declare class Rapier3dWorldComponent implements IPhysicsWorld3dComponent<
31
33
  raycast(options: RaycastOptions<Point3>): RaycastResult<Point3, Rapier3dRigidBodyComponent>;
32
34
  dispose(): void;
33
35
  }
36
+ export {};
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './components/rapier-3d-rigid-body.component';
2
2
  export * from './components/rapier-3d-trigger.component';
3
+ export * from './components/rapier-3d-character-controller.component';
3
4
  export * from './components/rapier-3d-world.component';
4
5
  export * from './rapier-3d-factory';
5
6
  export * from './rapier-3d-loader';