@forgeax/engine-physics-rapier3d 0.1.2

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,1499 @@
1
+ import { componentDefinition, Disabled, FixedTime, FixedUpdate } from '@forgeax/engine-ecs';
2
+ // @forgeax/engine-physics-rapier3d — RapierPhysicsWorld3D class and three-phase
3
+ // tick systems (syncBackend / stepSimulation / writeback).
4
+ //
5
+ // RapierPhysicsWorld3D implements the PhysicsWorld interface from
6
+ // @forgeax/engine-physics and holds a Rapier 3D World instance as its
7
+ // simulation backend.
8
+ //
9
+ // Three-phase pipeline (plan-strategy D-1):
10
+ // 1. syncBackend: apply pending teleports, update kinematic positions.
11
+ // 2. stepSimulation: call rapierWorld.step(eventQueue).
12
+ // 3. writeback: read Rapier body positions (dynamic only).
13
+ //
14
+ // Entity-to-body mapping (plan-strategy D-7): Rapier RigidBody.userData holds
15
+ // the ECS entity raw value for reverse lookup in collision events.
16
+ //
17
+ // Despawn cleanup (plan-strategy D-5): removeEntity() removes the Rapier body
18
+ // and colliders from the physics world.
19
+
20
+ import type { Component, EntityHandle, SystemHandle, World } from '@forgeax/engine-ecs';
21
+ import { defineSystem } from '@forgeax/engine-ecs';
22
+ import { createWorldProjection, type WorldProjection } from '@forgeax/engine-ecs/projection';
23
+ import { mat4, quat, type Vec3, vec3 } from '@forgeax/engine-math';
24
+ import type { PhysicsWorld, RaycastHit } from '@forgeax/engine-physics';
25
+ import {
26
+ CharacterController,
27
+ Collider,
28
+ CollidingEntities,
29
+ colliderShapeFromF32,
30
+ PHYSICS_ERROR_HINTS,
31
+ PhysicsError,
32
+ PhysicsSet,
33
+ RIGID_BODY_TYPE_STATIC,
34
+ RigidBody,
35
+ registerPhysicsComponents,
36
+ rigidBodyTypeFromF32,
37
+ } from '@forgeax/engine-physics';
38
+ import { ChildOf } from '@forgeax/engine-scene';
39
+ import type { Rapier3DModule } from './wasm-loader';
40
+
41
+ interface Rapier3DKinematicControllerState {
42
+ readonly entity: number;
43
+ readonly offset: number;
44
+ }
45
+
46
+ /**
47
+ * Per-entity physics record — tracks the Rapier body handle for
48
+ * each ECS entity.
49
+ */
50
+ interface PhysicsEntityRecord {
51
+ bodyHandle: number;
52
+ }
53
+
54
+ interface PhysicsTransform3D {
55
+ readonly position: { readonly x: number; readonly y: number; readonly z: number };
56
+ readonly rotation: {
57
+ readonly x: number;
58
+ readonly y: number;
59
+ readonly z: number;
60
+ readonly w: number;
61
+ };
62
+ readonly scale: { readonly x: number; readonly y: number; readonly z: number };
63
+ }
64
+
65
+ interface PhysicsCollider3D {
66
+ readonly shape: number;
67
+ readonly halfExtents: readonly [number, number, number];
68
+ readonly radius: number;
69
+ readonly halfHeight: number;
70
+ readonly friction: number;
71
+ readonly restitution: number;
72
+ readonly density: number;
73
+ readonly isSensor: number;
74
+ readonly collisionGroups: number;
75
+ readonly solverGroups: number;
76
+ }
77
+
78
+ interface PhysicsSyncQueryRow {
79
+ readonly entity: EntityHandle;
80
+ has(component: Component): boolean;
81
+ get(component: Component): Record<string, unknown> | undefined;
82
+ }
83
+
84
+ interface PhysicsSyncQuery extends Iterable<PhysicsSyncQueryRow> {
85
+ at(entity: EntityHandle): PhysicsSyncQueryRow | undefined;
86
+ }
87
+
88
+ interface PhysicsSyncState {
89
+ readonly world: World;
90
+ readonly transformComponent: Component;
91
+ readonly query: PhysicsSyncQuery;
92
+ readonly projection: WorldProjection;
93
+ initialized: boolean;
94
+ }
95
+
96
+ interface PhysicsSyncDescriptor {
97
+ readonly entity: EntityHandle;
98
+ readonly transform: PhysicsTransform3D;
99
+ readonly rigidBody: {
100
+ readonly type: number;
101
+ readonly mass: number;
102
+ readonly linearDamping: number;
103
+ readonly angularDamping: number;
104
+ readonly gravityScale: number;
105
+ readonly ccdEnabled: number;
106
+ };
107
+ readonly collider: PhysicsCollider3D;
108
+ readonly hasCharacterController: boolean;
109
+ readonly characterControllerOffset: number | undefined;
110
+ }
111
+
112
+ interface PhysicsEntityDelta {
113
+ transformChanged: boolean;
114
+ colliderChanged: boolean;
115
+ rigidBodyChanged: boolean;
116
+ characterControllerChanged: boolean;
117
+ characterControllerRemoved: boolean;
118
+ }
119
+
120
+ export interface Rapier3DCollisionEvent {
121
+ readonly type: 'started' | 'stopped';
122
+ readonly entityA: number;
123
+ readonly entityB: number;
124
+ }
125
+
126
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier types from dynamically loaded module
127
+ type RapierWorld = any;
128
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier types from dynamically loaded module
129
+ type RapierEventQueue = any;
130
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier types from dynamically loaded module
131
+ type RapierRigidBody = any;
132
+
133
+ /** CharacterController tuning fields read per moveAndSlide call (degrees + world units). */
134
+ interface CharacterControllerTuning {
135
+ offset: number;
136
+ maxSlopeClimbDeg: number;
137
+ minSlopeSlideDeg: number;
138
+ autoStepMaxHeight: number;
139
+ autoStepMinWidth: number;
140
+ snapToGroundDist: number;
141
+ }
142
+
143
+ const DEG_TO_RAD = Math.PI / 180;
144
+
145
+ /**
146
+ * Re-apply all KCC setters from the component tuning every call (plan-strategy
147
+ * D-7: full reset, no dirty tracking). Degrees -> radians for the two slope
148
+ * setters; offset / autostep / snap pass through as world units. A zero value
149
+ * for auto-step / snap calls `disable*()` rather than `enable*(0)`.
150
+ */
151
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier KinematicCharacterController from dynamic module
152
+ function applyKccTuning(ctrl: any, cc: CharacterControllerTuning): void {
153
+ ctrl.setMaxSlopeClimbAngle(cc.maxSlopeClimbDeg * DEG_TO_RAD);
154
+ ctrl.setMinSlopeSlideAngle(cc.minSlopeSlideDeg * DEG_TO_RAD);
155
+ ctrl.setSlideEnabled(true);
156
+ if (cc.autoStepMaxHeight === 0) {
157
+ ctrl.disableAutostep();
158
+ } else {
159
+ ctrl.enableAutostep(cc.autoStepMaxHeight, cc.autoStepMinWidth, false); // D-7: includeDynamicBodies=false
160
+ }
161
+ if (cc.snapToGroundDist === 0) {
162
+ ctrl.disableSnapToGround();
163
+ } else {
164
+ ctrl.enableSnapToGround(cc.snapToGroundDist);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Map a Rapier RigidBodyType enum value to the engine's string union for the
170
+ * `controller-requires-kinematic` error detail.
171
+ */
172
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier module enum from dynamic module
173
+ function rapierBodyTypeToString(rapier: any, bodyType: number): string {
174
+ if (bodyType === rapier.RigidBodyType.Dynamic) return 'dynamic';
175
+ if (bodyType === rapier.RigidBodyType.Fixed) return 'static';
176
+ return 'kinematic';
177
+ }
178
+
179
+ /**
180
+ * RapierPhysicsWorld3D — Rapier 3D WASM backend implementing the PhysicsWorld
181
+ * interface.
182
+ */
183
+ export class RapierPhysicsWorld3D implements PhysicsWorld {
184
+ /** Rapier 3D World instance owning all bodies, colliders, and pipeline. */
185
+ raw: RapierWorld;
186
+
187
+ private readonly rapierModule: Rapier3DModule;
188
+
189
+ /** Entity (raw number) -> PhysicsEntityRecord mapping. */
190
+ private readonly entityMap = new Map<number, PhysicsEntityRecord>();
191
+
192
+ /** Pending teleports: entity -> target position, applied on next sync. */
193
+ private readonly pendingTeleports = new Map<number, { x: number; y: number; z: number }>();
194
+
195
+ /** Event queue for collision events. */
196
+ private eventQueue: RapierEventQueue;
197
+
198
+ /**
199
+ * Active overlap set per entity, maintained by draining the event queue each
200
+ * step. `started` events add the pair both ways; `stopped` events remove it.
201
+ * Read out into each entity's `CollidingEntities` component by
202
+ * `writebackCollidingEntities`. Covers both solid contacts and sensor
203
+ * intersections (Rapier emits CollisionEvent for both).
204
+ */
205
+ private readonly collisionPairs = new Map<number, Set<number>>();
206
+
207
+ private readonly pendingCollisionEvents: Rapier3DCollisionEvent[] = [];
208
+
209
+ private readonly collisionEventHistory: Rapier3DCollisionEvent[] = [];
210
+
211
+ private currentGravity: { x: number; y: number; z: number };
212
+
213
+ /**
214
+ * Lazily-built Rapier KinematicCharacterController per character entity
215
+ * (plan-strategy D-1/D-3). `moveAndSlide` creates one on first call; the
216
+ * `Collider.onRemove` hook (registerPhysicsSystems) clears it on despawn.
217
+ * Public so AC-11 despawn tests can assert `kccCache.size === 0`.
218
+ */
219
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier KinematicCharacterController from dynamic module
220
+ readonly kccCache = new Map<number, any>();
221
+
222
+ private readonly kccOffsets = new Map<number, number>();
223
+
224
+ /**
225
+ * ECS World + components wired in by `registerPhysicsSystems`, so
226
+ * `moveAndSlide` can read CharacterController tuning and write Transform +
227
+ * grounded back. Undefined until systems are registered — the input-validation
228
+ * error paths (body / collider) fire before these are read, so direct
229
+ * `pw.moveAndSlide()` calls in error tests need no World.
230
+ */
231
+ private moveContext:
232
+ | { world: World; transform: Component; characterController: Component }
233
+ | undefined;
234
+
235
+ /** Persistent ECS query + projection cursor for incremental backend sync. */
236
+ private syncState: PhysicsSyncState | undefined;
237
+
238
+ private disposed = false;
239
+
240
+ constructor(rapier: Rapier3DModule) {
241
+ this.rapierModule = rapier;
242
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World constructor is a class exported from a namespace module
243
+ this.raw = new (rapier as any).World({ x: 0, y: -9.81, z: 0 }) as RapierWorld;
244
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier EventQueue constructor comes from a namespace module
245
+ this.eventQueue = new (rapier as any).EventQueue(true) as RapierEventQueue;
246
+ this.currentGravity = { x: 0, y: -9.81, z: 0 };
247
+ }
248
+
249
+ // ─── PhysicsWorld interface ────────────────────────────────────────────
250
+
251
+ setGravity(gravity: Vec3): void {
252
+ this.assertActive('setGravity');
253
+ const x = gravity[0] ?? 0;
254
+ const y = gravity[1] ?? 0;
255
+ const z = gravity[2] ?? 0;
256
+ this.raw.gravity = { x, y, z };
257
+ this.currentGravity = { x, y, z };
258
+ }
259
+
260
+ getGravity(): Vec3 {
261
+ const { x, y, z } = this.currentGravity;
262
+ return vec3.create(x, y, z);
263
+ }
264
+
265
+ raycast(
266
+ origin: Vec3,
267
+ direction: Vec3,
268
+ maxDist: number,
269
+ filterMask?: number,
270
+ ): RaycastHit | undefined {
271
+ this.assertActive('raycast');
272
+ const RAPIER = this.rapierModule;
273
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier Ray constructor comes from a namespace module
274
+ const RayCtor = (RAPIER as any).Ray as new (
275
+ origin: { x: number; y: number; z: number },
276
+ dir: { x: number; y: number; z: number },
277
+ ) => { pointAt(t: number): { x: number; y: number; z: number } };
278
+ const ray = new RayCtor(
279
+ { x: origin[0] ?? 0, y: origin[1] ?? 0, z: origin[2] ?? 0 },
280
+ { x: direction[0] ?? 0, y: direction[1] ?? 0, z: direction[2] ?? 0 },
281
+ );
282
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World castRayAndGetNormal
283
+ const hit = (this.raw as any).castRayAndGetNormal(
284
+ ray,
285
+ maxDist,
286
+ true,
287
+ undefined,
288
+ filterMask,
289
+ ) as {
290
+ collider: { parent(): { userData: number } | null };
291
+ timeOfImpact: number;
292
+ normal: { x: number; y: number; z: number };
293
+ } | null;
294
+
295
+ if (hit === null) return undefined;
296
+
297
+ const point = ray.pointAt(hit.timeOfImpact);
298
+ // `hit.collider.parent()` already returns the owning RigidBody OBJECT (compat
299
+ // build), whose userData holds the ECS entity — read it directly, mirroring
300
+ // `colliderHandleToEntity` (the proven CollidingEntities path). The prior code
301
+ // treated the object as a body HANDLE and re-resolved it via `bodies.get(...)`,
302
+ // which returned a DIFFERENT body → raycast reported the wrong entity.
303
+ const colliderParentBody = hit.collider.parent();
304
+ const entity = colliderParentBody !== null ? colliderParentBody.userData : 0;
305
+
306
+ return {
307
+ entity,
308
+ point: vec3.create(point.x, point.y, point.z),
309
+ normal: vec3.create(hit.normal.x, hit.normal.y, hit.normal.z),
310
+ timeOfImpact: hit.timeOfImpact,
311
+ };
312
+ }
313
+
314
+ teleport(entity: number, position: Vec3): void {
315
+ this.assertActive('teleport');
316
+ this.pendingTeleports.set(entity, {
317
+ x: position[0] ?? 0,
318
+ y: position[1] ?? 0,
319
+ z: position[2] ?? 0,
320
+ });
321
+ }
322
+
323
+ step(deltaTime: number): void {
324
+ this.assertActive('step');
325
+ void deltaTime;
326
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World.step
327
+ (this.raw as any).step(this.eventQueue);
328
+ this.drainRapierCollisionEvents();
329
+ }
330
+
331
+ /**
332
+ * Drain the Rapier event queue into `collisionPairs`. Each event names two
333
+ * collider handles + a `started` flag; we resolve each collider to its owning
334
+ * entity (collider.parent() -> body.userData) and add/remove the symmetric
335
+ * pair. This is what populates `CollidingEntities` for sensor pickup + contact
336
+ * queries (the queue is otherwise drained-on-overflow and never observed).
337
+ */
338
+ private drainRapierCollisionEvents(): void {
339
+ this.eventQueue.drainCollisionEvents((handle1: number, handle2: number, started: boolean) => {
340
+ const a = this.colliderHandleToEntity(handle1);
341
+ const b = this.colliderHandleToEntity(handle2);
342
+ if (a === undefined || b === undefined) return;
343
+ const changed = started ? this.addPair(a, b) : this.removePair(a, b);
344
+ if (!changed) return;
345
+ this.pushCollisionEvent({
346
+ type: started ? 'started' : 'stopped',
347
+ entityA: a,
348
+ entityB: b,
349
+ });
350
+ });
351
+ }
352
+
353
+ /** Resolve a Rapier collider handle to its owning ECS entity, or undefined. */
354
+ private colliderHandleToEntity(colliderHandle: number): number | undefined {
355
+ // getCollider(handle).parent() returns the owning RigidBody (compat build),
356
+ // whose userData holds the ECS entity raw value (set in ensureBody).
357
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World.getCollider from dynamic module
358
+ const collider = (this.raw as any).getCollider(colliderHandle) as {
359
+ parent(): { userData: number } | null;
360
+ } | null;
361
+ if (collider === null || collider === undefined) return undefined;
362
+ const body = collider.parent();
363
+ if (body === null || body === undefined) return undefined;
364
+ return body.userData;
365
+ }
366
+
367
+ private addPair(a: number, b: number): boolean {
368
+ let setA = this.collisionPairs.get(a);
369
+ if (!setA) {
370
+ setA = new Set<number>();
371
+ this.collisionPairs.set(a, setA);
372
+ }
373
+ if (setA.has(b)) return false;
374
+ setA.add(b);
375
+ let setB = this.collisionPairs.get(b);
376
+ if (!setB) {
377
+ setB = new Set<number>();
378
+ this.collisionPairs.set(b, setB);
379
+ }
380
+ setB.add(a);
381
+ return true;
382
+ }
383
+
384
+ private removePair(a: number, b: number): boolean {
385
+ const removedA = this.collisionPairs.get(a)?.delete(b) ?? false;
386
+ const removedB = this.collisionPairs.get(b)?.delete(a) ?? false;
387
+ return removedA || removedB;
388
+ }
389
+
390
+ private pushCollisionEvent(event: Rapier3DCollisionEvent): void {
391
+ const ordered =
392
+ event.entityA <= event.entityB
393
+ ? event
394
+ : { ...event, entityA: event.entityB, entityB: event.entityA };
395
+ this.pendingCollisionEvents.push(ordered);
396
+ this.collisionEventHistory.push(ordered);
397
+ }
398
+
399
+ /**
400
+ * Write the current overlap set into each entity's `CollidingEntities`
401
+ * component (entities that carry it). Called by the PhysicsCollisionSync
402
+ * system after writeback. Entities with no current overlaps get an empty set,
403
+ * so a Core that the player has left clears correctly. Only entities that own
404
+ * a CollidingEntities component are written (others are skipped).
405
+ */
406
+ writebackCollidingEntities(world: World, collidingComponent: Component): void {
407
+ for (const [entity, others] of this.collisionPairs) {
408
+ const handle = entity as EntityHandle;
409
+ if (!world.get(handle, collidingComponent).ok) continue;
410
+ world.set(handle, collidingComponent, { entities: [...others] });
411
+ }
412
+ }
413
+
414
+ drainCollisionEvents(): Rapier3DCollisionEvent[] {
415
+ return this.pendingCollisionEvents.splice(0);
416
+ }
417
+
418
+ getCollisionPairs(): Map<number, Set<number>> {
419
+ return new Map([...this.collisionPairs].map(([entity, others]) => [entity, new Set(others)]));
420
+ }
421
+
422
+ getCollisionEventHistory(): readonly Rapier3DCollisionEvent[] {
423
+ return [...this.collisionEventHistory];
424
+ }
425
+
426
+ getPendingTeleports(): readonly [
427
+ number,
428
+ { readonly x: number; readonly y: number; readonly z: number },
429
+ ][] {
430
+ return [...this.pendingTeleports].map(([entity, target]) => [entity, { ...target }]);
431
+ }
432
+
433
+ getKinematicControllerStates(): readonly Rapier3DKinematicControllerState[] {
434
+ return [...this.kccOffsets]
435
+ .sort(([first], [second]) => first - second)
436
+ .map(([entity, offset]) => ({ entity, offset }));
437
+ }
438
+
439
+ dispose(): void {
440
+ if (this.disposed) return;
441
+ this.syncState = undefined;
442
+ this.moveContext = undefined;
443
+ if (typeof this.raw.free === 'function') this.raw.free();
444
+ if (typeof this.eventQueue.free === 'function') this.eventQueue.free();
445
+ this.entityMap.clear();
446
+ this.pendingTeleports.clear();
447
+ this.collisionPairs.clear();
448
+ this.pendingCollisionEvents.length = 0;
449
+ this.collisionEventHistory.length = 0;
450
+ this.kccCache.clear();
451
+ this.kccOffsets.clear();
452
+ this.disposed = true;
453
+ }
454
+
455
+ getBodyCount(): number {
456
+ return this.entityMap.size;
457
+ }
458
+
459
+ hasBody(entity: number): boolean {
460
+ return this.entityMap.has(entity);
461
+ }
462
+
463
+ /**
464
+ * Wire the ECS World + Transform / CharacterController components needed by
465
+ * `moveAndSlide` to read tuning and write back pose + grounded. Called once by
466
+ * `registerPhysicsSystems` (plan-strategy D-1/D-7).
467
+ */
468
+ setMoveContext(world: World, transform: Component, characterController: Component): void {
469
+ this.assertActive('setMoveContext');
470
+ this.moveContext = { world, transform, characterController };
471
+ }
472
+
473
+ /** Release the persistent ECS readers owned by one system registration. */
474
+ clearEcsContext(world: World): void {
475
+ if (this.syncState?.world === world) this.syncState = undefined;
476
+ if (this.moveContext?.world === world) this.moveContext = undefined;
477
+ }
478
+
479
+ moveAndSlide(entity: number, desiredDelta: Vec3): Vec3 {
480
+ this.assertActive('moveAndSlide');
481
+ return this.computeMove(entity, desiredDelta);
482
+ }
483
+
484
+ private assertActive(operation: string): void {
485
+ if (this.disposed) {
486
+ throw new Error(`RapierPhysicsWorld3D.${operation} cannot run on a disposed instance`);
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Shared moveAndSlide core (plan-strategy D-1/D-2/D-4/D-6/D-7).
492
+ *
493
+ * The three Fail-Fast entry checks (body / collider / kinematic) throw
494
+ * structured PhysicsError before the World is read, so error-path tests can
495
+ * call this without registered systems.
496
+ */
497
+ private computeMove(entity: number, desiredDelta: Vec3): Vec3 {
498
+ // ── Fail-Fast entry checks (charter P3) ──
499
+ const record = this.entityMap.get(entity);
500
+ if (!record) {
501
+ throw new PhysicsError({
502
+ code: 'body-not-found',
503
+ expected: 'a registered Rapier body for this entity',
504
+ hint: PHYSICS_ERROR_HINTS['body-not-found'],
505
+ detail: { code: 'body-not-found', entity },
506
+ });
507
+ }
508
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier bodies API needs any-cast
509
+ const body = (this.raw as any).bodies.get(record.bodyHandle) as RapierRigidBody | null;
510
+ if (!body) {
511
+ throw new PhysicsError({
512
+ code: 'body-not-found',
513
+ expected: 'a registered Rapier body for this entity',
514
+ hint: PHYSICS_ERROR_HINTS['body-not-found'],
515
+ detail: { code: 'body-not-found', entity },
516
+ });
517
+ }
518
+ if (body.numColliders() === 0) {
519
+ // D-2: body exists but carries no collider — more precise than body-not-found.
520
+ throw new PhysicsError({
521
+ code: 'collider-not-found',
522
+ expected: 'a Collider attached to this entity body',
523
+ hint: PHYSICS_ERROR_HINTS['collider-not-found'],
524
+ detail: { code: 'collider-not-found', entity },
525
+ });
526
+ }
527
+ const RAPIER = this.rapierModule;
528
+ if (body.bodyType() !== RAPIER.RigidBodyType.KinematicPositionBased) {
529
+ throw new PhysicsError({
530
+ code: 'controller-requires-kinematic',
531
+ expected: "RigidBody.type === 'kinematic'",
532
+ hint: PHYSICS_ERROR_HINTS['controller-requires-kinematic'],
533
+ detail: {
534
+ code: 'controller-requires-kinematic',
535
+ entity,
536
+ bodyType: rapierBodyTypeToString(RAPIER, body.bodyType()),
537
+ },
538
+ });
539
+ }
540
+
541
+ const collider = body.collider(0); // D-4: zero-schema reverse lookup
542
+
543
+ // ── Read CharacterController tuning + lazily build/configure the KCC ──
544
+ const cc = this.readCharacterController(entity);
545
+ const ctrl = this.ensureKcc(entity, cc.offset);
546
+ applyKccTuning(ctrl, cc);
547
+
548
+ // ── Step 1: solve collisions (D-1 self-exclude predicate) ──
549
+ // Rapier's filter predicate returns true to INCLUDE a collider as a
550
+ // potential obstacle, false to skip it; this excludes the character's own
551
+ // collider so it never collides with itself. EXCLUDE_SENSORS makes the KCC
552
+ // treat sensor colliders as non-solid (their purpose is overlap detection,
553
+ // not blocking) -- without it any sensor overlapping the character (e.g. a
554
+ // pickup/attack trigger volume) walls the KCC and freezes it in place.
555
+ const delta = { x: desiredDelta[0] ?? 0, y: desiredDelta[1] ?? 0, z: desiredDelta[2] ?? 0 };
556
+ ctrl.computeColliderMovement(
557
+ collider,
558
+ delta,
559
+ RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
560
+ undefined,
561
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier Collider in filter predicate
562
+ (other: any) => other.handle !== collider.handle,
563
+ );
564
+
565
+ // ── Step 2/3: read corrected movement + grounded ──
566
+ const movement = ctrl.computedMovement() as { x: number; y: number; z: number };
567
+ const grounded = ctrl.computedGrounded() as boolean;
568
+
569
+ // ── Write back: push the kinematic body + ECS Transform + grounded ──
570
+ const t = body.translation();
571
+ const next = { x: t.x + movement.x, y: t.y + movement.y, z: t.z + movement.z };
572
+ // setNextKinematicTranslation feeds the physics step pipeline; setTranslation
573
+ // advances the body + its collider immediately so consecutive moveAndSlide
574
+ // calls (without an intervening world.step) see the updated pose for the next
575
+ // collision solve + grounded check. The query structures are refreshed so the
576
+ // next computeColliderMovement reads the new position.
577
+ body.setNextKinematicTranslation(next);
578
+ body.setTranslation(next, true);
579
+ // setTranslation marks the body modified but does not re-place its collider
580
+ // in the collider set; propagate so the next computeColliderMovement
581
+ // shape-casts the character from its updated pose.
582
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World.propagateModifiedBodyPositionsToColliders
583
+ (this.raw as any).propagateModifiedBodyPositionsToColliders();
584
+
585
+ const ctx = this.moveContext;
586
+ if (ctx) {
587
+ // D-6: writeback Result ignored — entry checks already guard liveness.
588
+ ctx.world.set(entity as EntityHandle, ctx.transform, {
589
+ pos: [next.x, next.y, next.z],
590
+ });
591
+ ctx.world.set(entity as EntityHandle, ctx.characterController, { grounded });
592
+ }
593
+
594
+ return vec3.create(movement.x, movement.y, movement.z);
595
+ }
596
+
597
+ /**
598
+ * Read CharacterController tuning fields for an entity from the ECS World,
599
+ * falling back to schema defaults when the World is not wired (defensive;
600
+ * the kinematic check upstream means a valid character always has the World).
601
+ */
602
+ private readCharacterController(entity: number): CharacterControllerTuning {
603
+ const ctx = this.moveContext;
604
+ if (ctx) {
605
+ const r = ctx.world.get(entity as EntityHandle, ctx.characterController);
606
+ if (r.ok) {
607
+ const v = r.value as Record<string, number>;
608
+ return {
609
+ offset: v.offset as number,
610
+ maxSlopeClimbDeg: v.maxSlopeClimbDeg as number,
611
+ minSlopeSlideDeg: v.minSlopeSlideDeg as number,
612
+ autoStepMaxHeight: v.autoStepMaxHeight as number,
613
+ autoStepMinWidth: v.autoStepMinWidth as number,
614
+ snapToGroundDist: v.snapToGroundDist as number,
615
+ };
616
+ }
617
+ }
618
+ // The ECS token owns the defaults; keep the defensive no-context path on
619
+ // that projection so 3D cannot drift from the shared CharacterController schema.
620
+ return componentDefinition(CharacterController)
621
+ .defaults as unknown as CharacterControllerTuning;
622
+ }
623
+
624
+ /**
625
+ * Lazily build a Rapier KinematicCharacterController for `entity` (cached).
626
+ */
627
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier KinematicCharacterController from dynamic module
628
+ private ensureKcc(entity: number, offset: number): any {
629
+ const cached = this.kccCache.get(entity);
630
+ if (cached) return cached;
631
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World.createCharacterController
632
+ const ctrl = (this.raw as any).createCharacterController(offset);
633
+ this.kccCache.set(entity, ctrl);
634
+ this.kccOffsets.set(entity, offset);
635
+ return ctrl;
636
+ }
637
+
638
+ /** Remove backend rows whose Collider disappeared from the World query. */
639
+ pruneMissingEntities(active: ReadonlySet<number>): void {
640
+ for (const entity of this.entityMap.keys()) {
641
+ if (!active.has(entity)) this.removeEntity(entity);
642
+ }
643
+ }
644
+
645
+ private bodyForEntity(entity: number): RapierRigidBody | undefined {
646
+ const record = this.entityMap.get(entity);
647
+ if (!record) return undefined;
648
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier bodies API needs any-cast
649
+ return ((this.raw as any).bodies.get(record.bodyHandle) as RapierRigidBody | null) ?? undefined;
650
+ }
651
+
652
+ private isCommittedFixedBody(entity: number): boolean {
653
+ return this.bodyForEntity(entity)?.bodyType() === this.rapierModule.RigidBodyType.Fixed;
654
+ }
655
+
656
+ private reconcileTransformlessCompatibility(entity: number, staticByEcs: boolean): void {
657
+ if (!staticByEcs || !this.isCommittedFixedBody(entity)) {
658
+ this.removeEntity(entity);
659
+ return;
660
+ }
661
+ // A fixed body cannot be controller-owned. Clear stale KCC state defensively
662
+ // while retaining the already-committed body and collider receipt.
663
+ this.removeKccController(entity);
664
+ }
665
+
666
+ private resetForFullReconcile(transformlessStatic: ReadonlySet<number>): void {
667
+ // Without a descriptor/hash mirror, an overflow cannot prove which existing
668
+ // Transform-backed body changed. Recreate those bodies from the final ECS
669
+ // combination. Dynamic velocity/contact state is intentionally reset during
670
+ // this recovery path so stale motion type or collider data cannot survive.
671
+ for (const entity of [...this.entityMap.keys()]) {
672
+ if (transformlessStatic.has(entity) && this.isCommittedFixedBody(entity)) {
673
+ this.removeKccController(entity);
674
+ continue;
675
+ }
676
+ this.removeEntity(entity);
677
+ }
678
+ }
679
+
680
+ private fullReconcilePhysicsState(state: PhysicsSyncState): void {
681
+ const descriptors: PhysicsSyncDescriptor[] = [];
682
+ const transformlessStatic = new Set<number>();
683
+ for (const row of state.query) {
684
+ if (!row.has(state.transformComponent)) {
685
+ if (physicsRowIsStatic(row)) transformlessStatic.add(row.entity);
686
+ continue;
687
+ }
688
+ const descriptor = readPhysicsSyncDescriptor(row, state.transformComponent);
689
+ if (descriptor !== undefined) descriptors.push(descriptor);
690
+ }
691
+
692
+ this.resetForFullReconcile(transformlessStatic);
693
+ for (const descriptor of descriptors) {
694
+ this.ensureBody(
695
+ descriptor.entity,
696
+ descriptor.transform,
697
+ descriptor.rigidBody,
698
+ descriptor.collider,
699
+ );
700
+ }
701
+ state.initialized = true;
702
+ }
703
+
704
+ private reconcilePhysicsDelta(
705
+ state: PhysicsSyncState,
706
+ entity: EntityHandle,
707
+ delta: PhysicsEntityDelta,
708
+ ): void {
709
+ const row = state.query.at(entity);
710
+ if (row === undefined) {
711
+ this.removeEntity(entity);
712
+ return;
713
+ }
714
+ if (!row.has(state.transformComponent)) {
715
+ // A lifecycle mutation cannot be applied without a pose. Keep only the
716
+ // narrow migration case where Transform alone disappeared from an already
717
+ // committed fixed body; never create or reshape a Transform-less row.
718
+ if (delta.colliderChanged || delta.rigidBodyChanged) {
719
+ this.removeEntity(entity);
720
+ return;
721
+ }
722
+ this.reconcileTransformlessCompatibility(entity, physicsRowIsStatic(row));
723
+ return;
724
+ }
725
+
726
+ const descriptor = readPhysicsSyncDescriptor(row, state.transformComponent);
727
+ if (descriptor === undefined) {
728
+ this.removeEntity(entity);
729
+ return;
730
+ }
731
+
732
+ if (this.hasBody(entity) && (delta.colliderChanged || delta.rigidBodyChanged)) {
733
+ // Collider/RigidBody lifecycle is reconciled by replacement from the final
734
+ // ECS combination. This deliberately resets velocity/contact state for an
735
+ // affected dynamic body; no descriptor mirror is introduced in M1.
736
+ this.removeEntity(entity);
737
+ }
738
+ if (!this.hasBody(entity)) {
739
+ this.ensureBody(entity, descriptor.transform, descriptor.rigidBody, descriptor.collider);
740
+ return;
741
+ }
742
+
743
+ if (delta.characterControllerChanged) {
744
+ const cachedOffset = this.kccOffsets.get(entity);
745
+ const hadCachedReceipt = this.kccCache.has(entity);
746
+ const finalOffset = descriptor.characterControllerOffset;
747
+ if (!descriptor.hasCharacterController) {
748
+ this.removeKccController(entity);
749
+ } else if (
750
+ hadCachedReceipt &&
751
+ (delta.characterControllerRemoved ||
752
+ finalOffset === undefined ||
753
+ !Object.is(cachedOffset, finalOffset))
754
+ ) {
755
+ // Consume the final ECS combination. A remove+add in one journal window
756
+ // rebuilds the KCC receipt with the final offset, while transient
757
+ // grounded writeback leaves an equal-offset receipt untouched.
758
+ this.removeKccController(entity);
759
+ if (finalOffset !== undefined) this.ensureKcc(entity, finalOffset);
760
+ }
761
+ }
762
+ if (!delta.transformChanged && !delta.characterControllerRemoved) return;
763
+
764
+ const bodyType = rigidBodyTypeFromF32(descriptor.rigidBody.type);
765
+ if (bodyType === 'static') {
766
+ this.syncAuthoredPose(entity, descriptor.transform, descriptor.collider, 'static');
767
+ } else if (bodyType === 'kinematic' && !descriptor.hasCharacterController) {
768
+ this.syncAuthoredPose(entity, descriptor.transform, descriptor.collider, 'kinematic');
769
+ }
770
+ }
771
+
772
+ /** @internal ECS system bridge; consumers should register PhysicsSyncBackend. */
773
+ _syncFromEcs(world: World, transformComponent: Component): void {
774
+ this.assertActive('syncFromEcs');
775
+ let state = this.syncState;
776
+ if (
777
+ state === undefined ||
778
+ state.world !== world ||
779
+ state.transformComponent !== transformComponent
780
+ ) {
781
+ const queryResult = world.query({
782
+ read: [Collider],
783
+ optional: [transformComponent, RigidBody, CharacterController, ChildOf],
784
+ });
785
+ if (!queryResult.ok) return;
786
+ state = {
787
+ world,
788
+ transformComponent,
789
+ query: queryResult.value as unknown as PhysicsSyncQuery,
790
+ projection: createWorldProjection(world, {
791
+ components: [
792
+ transformComponent,
793
+ Collider,
794
+ RigidBody,
795
+ CharacterController,
796
+ ChildOf,
797
+ Disabled,
798
+ ],
799
+ }),
800
+ initialized: false,
801
+ };
802
+ this.syncState = state;
803
+ }
804
+
805
+ if (!state.initialized) {
806
+ this.fullReconcilePhysicsState(state);
807
+ return;
808
+ }
809
+
810
+ const evidence = state.projection.poll();
811
+ if (evidence.status === 'rebuild') {
812
+ this.fullReconcilePhysicsState(state);
813
+ return;
814
+ }
815
+ if (evidence.changes.length === 0) return;
816
+
817
+ const deltas = new Map<EntityHandle, PhysicsEntityDelta>();
818
+ for (const change of evidence.changes) {
819
+ let delta = deltas.get(change.entity);
820
+ if (delta === undefined) {
821
+ delta = {
822
+ transformChanged: false,
823
+ colliderChanged: false,
824
+ rigidBodyChanged: false,
825
+ characterControllerChanged: false,
826
+ characterControllerRemoved: false,
827
+ };
828
+ deltas.set(change.entity, delta);
829
+ }
830
+ if (change.kind === 'entity-removed') continue;
831
+ if (change.component === undefined) {
832
+ this.fullReconcilePhysicsState(state);
833
+ return;
834
+ }
835
+ if (change.component === transformComponent) {
836
+ delta.transformChanged = true;
837
+ } else if (change.component === Collider) {
838
+ delta.colliderChanged = true;
839
+ } else if (change.component === RigidBody) {
840
+ delta.rigidBodyChanged = true;
841
+ } else if (change.component === CharacterController) {
842
+ delta.characterControllerChanged = true;
843
+ if (change.kind === 'component-removed') delta.characterControllerRemoved = true;
844
+ } else if (change.component === ChildOf) {
845
+ delta.transformChanged = true;
846
+ } else if (change.component !== Disabled) {
847
+ // The projection is intentionally closed over the membership inputs.
848
+ // Any future unattributable record fails closed to a complete read.
849
+ this.fullReconcilePhysicsState(state);
850
+ return;
851
+ }
852
+ }
853
+
854
+ for (const [entity, delta] of deltas) this.reconcilePhysicsDelta(state, entity, delta);
855
+ }
856
+
857
+ /**
858
+ * Remove an entity's cached KCC and unregister it from the Rapier world
859
+ * (plan-strategy D-3). Idempotent — safe for entities that never moved.
860
+ */
861
+ removeKccController(entity: number): void {
862
+ const ctrl = this.kccCache.get(entity);
863
+ this.kccOffsets.delete(entity);
864
+ if (!ctrl) return;
865
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World.removeCharacterController
866
+ (this.raw as any).removeCharacterController(ctrl);
867
+ this.kccCache.delete(entity);
868
+ }
869
+
870
+ // ─── ECS→Rapier bridge (D-2) ──────────────────────────────────────────
871
+
872
+ /**
873
+ * Ensure a Rapier body and collider exist for an ECS entity (idempotent).
874
+ *
875
+ * When `entityMap` already contains the entity this returns immediately.
876
+ * Otherwise creates a Rapier RigidBody (dynamic / fixed / kinematic) +
877
+ * Collider (cuboid / ball / capsule) from the ECS component data, sets
878
+ * `body.userData = entity`, and registers the pairing via `registerBody`.
879
+ *
880
+ * @param entity Raw ECS entity number (stored in Rapier body.userData).
881
+ * @param transform ECS Transform fields: { posX, posY, posZ, ... }.
882
+ * @param rigidBody ECS RigidBody fields: { type (enum num), mass, ... }.
883
+ * @param collider ECS Collider fields: { shape (enum num), radius, ... }.
884
+ *
885
+ * Plan-strategy D-2 + D-3: enum→Rapier desc mapping consumes
886
+ * rigidBodyTypeFromF32 / colliderShapeFromF32 helpers; closed switch with
887
+ * no default — TypeScript enforces exhaustiveness on the string-union arms.
888
+ */
889
+ ensureBody(
890
+ entity: number,
891
+ transform: PhysicsTransform3D,
892
+ rigidBody: {
893
+ type: number;
894
+ mass: number;
895
+ linearDamping: number;
896
+ angularDamping: number;
897
+ gravityScale: number;
898
+ ccdEnabled: number;
899
+ },
900
+ collider: PhysicsCollider3D,
901
+ ): void {
902
+ this.assertActive('ensureBody');
903
+ if (this.entityMap.has(entity)) return; // M1 idempotent guard (D-2)
904
+
905
+ const RAPIER = this.rapierModule;
906
+
907
+ // ── Create RigidBodyDesc ──
908
+ const rbType = rigidBodyTypeFromF32(rigidBody.type);
909
+ let body: RapierRigidBody;
910
+ switch (rbType) {
911
+ case 'dynamic': {
912
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier RigidBodyDesc
913
+ const desc = (RAPIER as any).RigidBodyDesc.dynamic()
914
+ .setTranslation(transform.position.x, transform.position.y, transform.position.z)
915
+ .setRotation(transform.rotation)
916
+ .setLinearDamping(rigidBody.linearDamping)
917
+ .setAngularDamping(rigidBody.angularDamping)
918
+ .setGravityScale(rigidBody.gravityScale);
919
+ if (rigidBody.mass > 0) {
920
+ desc.setAdditionalMass(rigidBody.mass);
921
+ }
922
+ if (rigidBody.ccdEnabled) {
923
+ desc.setCcdEnabled(true);
924
+ }
925
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World.createRigidBody
926
+ body = (this.raw as any).createRigidBody(desc);
927
+ break;
928
+ }
929
+ case 'static': {
930
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier RigidBodyDesc
931
+ const desc = (RAPIER as any).RigidBodyDesc.fixed()
932
+ .setTranslation(transform.position.x, transform.position.y, transform.position.z)
933
+ .setRotation(transform.rotation);
934
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World.createRigidBody
935
+ body = (this.raw as any).createRigidBody(desc);
936
+ break;
937
+ }
938
+ case 'kinematic': {
939
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier RigidBodyDesc
940
+ const desc = (RAPIER as any).RigidBodyDesc.kinematicPositionBased()
941
+ .setTranslation(transform.position.x, transform.position.y, transform.position.z)
942
+ .setRotation(transform.rotation);
943
+ // CCD sweeps the collider along its per-step kinematic translation so a
944
+ // fast mover (player, bullet) reliably contacts dynamics instead of
945
+ // tunneling through them on discrete steps.
946
+ if (rigidBody.ccdEnabled) {
947
+ desc.setCcdEnabled(true);
948
+ }
949
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World.createRigidBody
950
+ body = (this.raw as any).createRigidBody(desc);
951
+ break;
952
+ }
953
+ // No default — rigidBodyTypeFromF32 ensures only 3 arms; TS guards completeness.
954
+ }
955
+
956
+ body.userData = entity;
957
+ this.registerBody(entity, body.handle);
958
+
959
+ // ── Create ColliderDesc ──
960
+ const scaleX = Math.abs(transform.scale.x);
961
+ const scaleY = Math.abs(transform.scale.y);
962
+ const scaleZ = Math.abs(transform.scale.z);
963
+ // Enable collision events + all body-type combinations so sensors register
964
+ // overlaps against kinematic/fixed bodies too (the default omits non-dynamic
965
+ // pairs, which would silence kinematic-sensor-vs-kinematic-body pickup).
966
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier enums from dynamic module
967
+ const activeEvents = (RAPIER as any).ActiveEvents.COLLISION_EVENTS as number;
968
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier enums from dynamic module
969
+ const activeCollisionTypes = (RAPIER as any).ActiveCollisionTypes.ALL as number;
970
+ const cShape = colliderShapeFromF32(collider.shape);
971
+ switch (cShape) {
972
+ case 'cuboid': {
973
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier ColliderDesc
974
+ const desc = (RAPIER as any).ColliderDesc.cuboid(
975
+ collider.halfExtents[0] * scaleX,
976
+ collider.halfExtents[1] * scaleY,
977
+ collider.halfExtents[2] * scaleZ,
978
+ )
979
+ .setFriction(collider.friction)
980
+ .setRestitution(collider.restitution)
981
+ .setDensity(collider.density)
982
+ .setCollisionGroups(collider.collisionGroups)
983
+ .setSolverGroups(collider.solverGroups)
984
+ .setActiveEvents(activeEvents)
985
+ .setActiveCollisionTypes(activeCollisionTypes);
986
+ if (collider.isSensor) desc.setSensor(true);
987
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World.createCollider
988
+ (this.raw as any).createCollider(desc, body);
989
+ break;
990
+ }
991
+ case 'sphere': {
992
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier ColliderDesc
993
+ const desc = (RAPIER as any).ColliderDesc.ball(
994
+ collider.radius * Math.max(scaleX, scaleY, scaleZ),
995
+ )
996
+ .setFriction(collider.friction)
997
+ .setRestitution(collider.restitution)
998
+ .setDensity(collider.density)
999
+ .setCollisionGroups(collider.collisionGroups)
1000
+ .setSolverGroups(collider.solverGroups)
1001
+ .setActiveEvents(activeEvents)
1002
+ .setActiveCollisionTypes(activeCollisionTypes);
1003
+ if (collider.isSensor) desc.setSensor(true);
1004
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World.createCollider
1005
+ (this.raw as any).createCollider(desc, body);
1006
+ break;
1007
+ }
1008
+ case 'capsule': {
1009
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier ColliderDesc
1010
+ const desc = (RAPIER as any).ColliderDesc.capsule(
1011
+ collider.halfHeight * scaleY,
1012
+ collider.radius * Math.max(scaleX, scaleZ),
1013
+ )
1014
+ .setFriction(collider.friction)
1015
+ .setRestitution(collider.restitution)
1016
+ .setDensity(collider.density)
1017
+ .setCollisionGroups(collider.collisionGroups)
1018
+ .setSolverGroups(collider.solverGroups)
1019
+ .setActiveEvents(activeEvents)
1020
+ .setActiveCollisionTypes(activeCollisionTypes);
1021
+ if (collider.isSensor) desc.setSensor(true);
1022
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World.createCollider
1023
+ (this.raw as any).createCollider(desc, body);
1024
+ break;
1025
+ }
1026
+ // No default — colliderShapeFromF32 ensures only 3 arms; TS guards completeness.
1027
+ }
1028
+ }
1029
+
1030
+ /**
1031
+ * Synchronize a static or kinematic body's Rapier pose and collider shape from
1032
+ * the resolved Transform pose. Dynamic bodies own their pose after creation.
1033
+ */
1034
+ syncAuthoredPose(
1035
+ entity: number,
1036
+ transform: PhysicsTransform3D,
1037
+ collider: PhysicsCollider3D,
1038
+ bodyType: 'static' | 'kinematic',
1039
+ ): void {
1040
+ this.assertActive('syncAuthoredPose');
1041
+ const record = this.entityMap.get(entity);
1042
+ if (!record) return;
1043
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier bodies API needs any-cast
1044
+ const body = (this.raw as any).bodies.get(record.bodyHandle) as RapierRigidBody | null;
1045
+ if (!body) return;
1046
+
1047
+ if (bodyType === 'static') {
1048
+ body.setTranslation(transform.position, true);
1049
+ body.setRotation(transform.rotation, true);
1050
+ } else {
1051
+ body.setNextKinematicTranslation(transform.position);
1052
+ body.setNextKinematicRotation(transform.rotation);
1053
+ }
1054
+
1055
+ const rapierCollider = body.collider(0);
1056
+ if (!rapierCollider) return;
1057
+ const scaleX = Math.abs(transform.scale.x);
1058
+ const scaleY = Math.abs(transform.scale.y);
1059
+ const scaleZ = Math.abs(transform.scale.z);
1060
+ switch (colliderShapeFromF32(collider.shape)) {
1061
+ case 'cuboid':
1062
+ rapierCollider.setHalfExtents({
1063
+ x: collider.halfExtents[0] * scaleX,
1064
+ y: collider.halfExtents[1] * scaleY,
1065
+ z: collider.halfExtents[2] * scaleZ,
1066
+ });
1067
+ break;
1068
+ case 'sphere':
1069
+ rapierCollider.setRadius(collider.radius * Math.max(scaleX, scaleY, scaleZ));
1070
+ break;
1071
+ case 'capsule':
1072
+ rapierCollider.setHalfHeight(collider.halfHeight * scaleY);
1073
+ rapierCollider.setRadius(collider.radius * Math.max(scaleX, scaleZ));
1074
+ break;
1075
+ }
1076
+ }
1077
+
1078
+ // ─── ECS integration helpers ───────────────────────────────────────────
1079
+
1080
+ /**
1081
+ * Register an ECS entity with its Rapier body handle.
1082
+ */
1083
+ registerBody(entity: number, bodyHandle: number): void {
1084
+ this.entityMap.set(entity, { bodyHandle });
1085
+ }
1086
+
1087
+ /**
1088
+ * Apply all pending teleports to their respective bodies.
1089
+ */
1090
+ applyPendingTeleports(): void {
1091
+ for (const [entity, target] of this.pendingTeleports) {
1092
+ const record = this.entityMap.get(entity);
1093
+ if (!record) continue;
1094
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier bodies API needs any-cast
1095
+ const body = (this.raw as any).bodies.get(record.bodyHandle) as RapierRigidBody | null;
1096
+ if (!body) continue;
1097
+
1098
+ body.setTranslation({ x: target.x, y: target.y, z: target.z }, true);
1099
+ body.setLinvel({ x: 0, y: 0, z: 0 }, false);
1100
+ body.setAngvel({ x: 0, y: 0, z: 0 }, false);
1101
+ }
1102
+ this.pendingTeleports.clear();
1103
+ }
1104
+
1105
+ /**
1106
+ * Set a kinematic body's next position from ECS transform.
1107
+ */
1108
+ setKinematicPosition(entity: number, pos: { x: number; y: number; z: number }): void {
1109
+ const record = this.entityMap.get(entity);
1110
+ if (!record) return;
1111
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier bodies API needs any-cast
1112
+ const body = (this.raw as any).bodies.get(record.bodyHandle) as RapierRigidBody | null;
1113
+ if (!body) return;
1114
+ body.setNextKinematicTranslation({ x: pos.x, y: pos.y, z: pos.z });
1115
+ }
1116
+
1117
+ /**
1118
+ * Write Rapier dynamic body poses back.
1119
+ */
1120
+ writebackDynamicBodies(): Array<{
1121
+ entity: number;
1122
+ pos: { x: number; y: number; z: number };
1123
+ rotation: { x: number; y: number; z: number; w: number };
1124
+ }> {
1125
+ const results: Array<{
1126
+ entity: number;
1127
+ pos: { x: number; y: number; z: number };
1128
+ rotation: { x: number; y: number; z: number; w: number };
1129
+ }> = [];
1130
+ for (const [entity, record] of this.entityMap) {
1131
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier bodies API needs any-cast
1132
+ const body = (this.raw as any).bodies.get(record.bodyHandle) as RapierRigidBody | null;
1133
+ if (!body) continue;
1134
+ if (body.bodyType() !== this.rapierModule.RigidBodyType.Dynamic) continue;
1135
+ const translation = body.translation();
1136
+ const rotation = body.rotation();
1137
+ results.push({
1138
+ entity,
1139
+ pos: { x: translation.x, y: translation.y, z: translation.z },
1140
+ rotation: { x: rotation.x, y: rotation.y, z: rotation.z, w: rotation.w },
1141
+ });
1142
+ }
1143
+ return results;
1144
+ }
1145
+
1146
+ /**
1147
+ * Remove a Rapier body and its colliders when the ECS entity is despawned.
1148
+ */
1149
+ removeEntity(entity: number): void {
1150
+ const record = this.entityMap.get(entity);
1151
+ if (!record) return;
1152
+ const ownPairs = [...(this.collisionPairs.get(entity) ?? [])];
1153
+ for (const other of ownPairs) {
1154
+ if (this.removePair(entity, other)) {
1155
+ this.pushCollisionEvent({ type: 'stopped', entityA: entity, entityB: other });
1156
+ }
1157
+ }
1158
+ this.removeKccController(entity); // D-3: clear cached KCC before body removal
1159
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World.removeRigidBody
1160
+ (this.raw as any).removeRigidBody({ handle: record.bodyHandle } as RapierRigidBody);
1161
+ this.entityMap.delete(entity);
1162
+ // Clear the despawned entity from every overlap set so a collected Core does
1163
+ // not linger in the player's CollidingEntities (Rapier emits no `stopped`
1164
+ // event when a collider is removed mid-overlap).
1165
+ const own = this.collisionPairs.get(entity);
1166
+ if (own) {
1167
+ for (const other of own) this.collisionPairs.get(other)?.delete(entity);
1168
+ this.collisionPairs.delete(entity);
1169
+ }
1170
+ }
1171
+ }
1172
+
1173
+ /**
1174
+ * Create a new RapierPhysicsWorld3D instance.
1175
+ */
1176
+ export function createRapier3DPhysicsWorld(rapier: Rapier3DModule): RapierPhysicsWorld3D {
1177
+ return new RapierPhysicsWorld3D(rapier);
1178
+ }
1179
+
1180
+ function hasReadableWorldPose(world: Float32Array | undefined): world is Float32Array {
1181
+ return world !== undefined && world.length >= 16;
1182
+ }
1183
+
1184
+ /** dt upper bound (plan-strategy D-4): skip step if dt exceeds this. */
1185
+ const PHYSICS_DT_MAX = 0.1;
1186
+ const poseScratchPosition = vec3.create();
1187
+ const poseScratchRotation = quat.create();
1188
+ const poseScratchScale = vec3.create();
1189
+ const poseScratchWorld = new Float32Array(16);
1190
+
1191
+ function physicsRowIsStatic(row: PhysicsSyncQueryRow): boolean {
1192
+ if (!row.has(RigidBody)) return true;
1193
+ const rigidBody = row.get(RigidBody) as { readonly type: number } | undefined;
1194
+ return rigidBody !== undefined && rigidBodyTypeFromF32(rigidBody.type) === 'static';
1195
+ }
1196
+
1197
+ function readPhysicsSyncDescriptor(
1198
+ row: PhysicsSyncQueryRow,
1199
+ transformComponent: Component,
1200
+ ): PhysicsSyncDescriptor | undefined {
1201
+ const transformData = row.get(transformComponent) as
1202
+ | {
1203
+ readonly pos: Float32Array;
1204
+ readonly quat: Float32Array;
1205
+ readonly scale: Float32Array;
1206
+ readonly world?: Float32Array;
1207
+ }
1208
+ | undefined;
1209
+ const colliderData = row.get(Collider) as
1210
+ | {
1211
+ readonly shape: number;
1212
+ readonly halfExtents: Float32Array;
1213
+ readonly radius: number;
1214
+ readonly halfHeight: number;
1215
+ readonly friction: number;
1216
+ readonly restitution: number;
1217
+ readonly density: number;
1218
+ readonly isSensor: number | boolean;
1219
+ readonly collisionGroups: number;
1220
+ readonly solverGroups: number;
1221
+ }
1222
+ | undefined;
1223
+ if (transformData === undefined || colliderData === undefined) return undefined;
1224
+
1225
+ // Root-local TRS is already a world pose. A ChildOf row, however, must use
1226
+ // Scene's derived world matrix whenever that field is readable. Matrix
1227
+ // contents cannot be a validity sentinel: a legitimate parent/local
1228
+ // composition can resolve to identity. Direct/test rows that omit `world`
1229
+ // retain the authored-local fallback.
1230
+ const useWorldPose = row.has(ChildOf) && hasReadableWorldPose(transformData.world);
1231
+ if (useWorldPose) {
1232
+ poseScratchWorld.set(transformData.world.subarray(0, 16));
1233
+ mat4.decompose(poseScratchPosition, poseScratchRotation, poseScratchScale, poseScratchWorld);
1234
+ } else {
1235
+ poseScratchPosition[0] = transformData.pos[0] ?? 0;
1236
+ poseScratchPosition[1] = transformData.pos[1] ?? 0;
1237
+ poseScratchPosition[2] = transformData.pos[2] ?? 0;
1238
+ poseScratchRotation[0] = transformData.quat[0] ?? 0;
1239
+ poseScratchRotation[1] = transformData.quat[1] ?? 0;
1240
+ poseScratchRotation[2] = transformData.quat[2] ?? 0;
1241
+ poseScratchRotation[3] = transformData.quat[3] ?? 1;
1242
+ poseScratchScale[0] = transformData.scale[0] ?? 1;
1243
+ poseScratchScale[1] = transformData.scale[1] ?? 1;
1244
+ poseScratchScale[2] = transformData.scale[2] ?? 1;
1245
+ }
1246
+
1247
+ const rigidBodyData = row.has(RigidBody)
1248
+ ? (row.get(RigidBody) as
1249
+ | {
1250
+ readonly type: number;
1251
+ readonly mass: number;
1252
+ readonly linearDamping: number;
1253
+ readonly angularDamping: number;
1254
+ readonly gravityScale: number;
1255
+ readonly ccdEnabled: number | boolean;
1256
+ }
1257
+ | undefined)
1258
+ : undefined;
1259
+ const characterControllerData = row.has(CharacterController)
1260
+ ? (row.get(CharacterController) as { readonly offset: number } | undefined)
1261
+ : undefined;
1262
+
1263
+ return {
1264
+ entity: row.entity,
1265
+ transform: {
1266
+ position: {
1267
+ x: poseScratchPosition[0] ?? 0,
1268
+ y: poseScratchPosition[1] ?? 0,
1269
+ z: poseScratchPosition[2] ?? 0,
1270
+ },
1271
+ rotation: {
1272
+ x: poseScratchRotation[0] ?? 0,
1273
+ y: poseScratchRotation[1] ?? 0,
1274
+ z: poseScratchRotation[2] ?? 0,
1275
+ w: poseScratchRotation[3] ?? 1,
1276
+ },
1277
+ scale: {
1278
+ x: poseScratchScale[0] ?? 1,
1279
+ y: poseScratchScale[1] ?? 1,
1280
+ z: poseScratchScale[2] ?? 1,
1281
+ },
1282
+ },
1283
+ rigidBody:
1284
+ rigidBodyData === undefined
1285
+ ? {
1286
+ type: RIGID_BODY_TYPE_STATIC,
1287
+ mass: 0,
1288
+ linearDamping: 0,
1289
+ angularDamping: 0,
1290
+ gravityScale: 1,
1291
+ ccdEnabled: 0,
1292
+ }
1293
+ : {
1294
+ type: rigidBodyData.type,
1295
+ mass: rigidBodyData.mass,
1296
+ linearDamping: rigidBodyData.linearDamping,
1297
+ angularDamping: rigidBodyData.angularDamping,
1298
+ gravityScale: rigidBodyData.gravityScale,
1299
+ ccdEnabled: Number(rigidBodyData.ccdEnabled),
1300
+ },
1301
+ collider: {
1302
+ shape: colliderData.shape,
1303
+ halfExtents: [
1304
+ colliderData.halfExtents[0] ?? 0,
1305
+ colliderData.halfExtents[1] ?? 0,
1306
+ colliderData.halfExtents[2] ?? 0,
1307
+ ],
1308
+ radius: colliderData.radius,
1309
+ halfHeight: colliderData.halfHeight,
1310
+ friction: colliderData.friction,
1311
+ restitution: colliderData.restitution,
1312
+ density: colliderData.density,
1313
+ isSensor: Number(colliderData.isSensor),
1314
+ collisionGroups: colliderData.collisionGroups,
1315
+ solverGroups: colliderData.solverGroups,
1316
+ },
1317
+ hasCharacterController: row.has(CharacterController),
1318
+ characterControllerOffset: characterControllerData?.offset,
1319
+ };
1320
+ }
1321
+
1322
+ // ── System name constants ──
1323
+ const PHYSICS_SYNC_BACKEND = 'physicsSyncBackend' as const;
1324
+ const PHYSICS_STEP_SIMULATION = 'physicsStepSimulation' as const;
1325
+ const PHYSICS_WRITEBACK = 'physicsWriteback' as const;
1326
+ const PHYSICS_COLLISION_SYNC = 'physicsCollisionSync' as const;
1327
+
1328
+ /**
1329
+ * Resolve the runtime `Transform` component token from the World-local ECS
1330
+ * registry (M2 — full resource-ification, D-3). physics already depends on
1331
+ * `@forgeax/engine-ecs`, so the catalog introduces no new dependency
1332
+ * and replaces the closure-captured `transformComponent` second parameter.
1333
+ * Returns `undefined` when Transform is not yet defined (the runtime package
1334
+ * defines it on import); callers early-out.
1335
+ */
1336
+ function resolveTransform(world: World): Component | undefined {
1337
+ return world.components.resolve('Transform');
1338
+ }
1339
+
1340
+ /**
1341
+ * `physicsSyncBackend` system token (M2 — full resource-ification, D-4).
1342
+ *
1343
+ * After propagateTransforms, bootstrap/recovery performs one complete Collider
1344
+ * reconcile. Warm ticks poll the existing ECS projection and identity-read only
1345
+ * final changed rows. Bare Colliders remain implicit static bodies; Transform-less
1346
+ * fixed bodies are retained only as a migration defense and are never created.
1347
+ * Reads `world` from its first parameter; resolves Transform via the global
1348
+ * registry. Labelled `'physics'`.
1349
+ */
1350
+ export const PhysicsSyncBackend: SystemHandle<readonly []> = defineSystem({
1351
+ name: PHYSICS_SYNC_BACKEND,
1352
+ queries: [],
1353
+ after: ['propagateTransformsFixed'],
1354
+ fn: (world) => {
1355
+ const transformComponent = resolveTransform(world);
1356
+ if (transformComponent === undefined) return;
1357
+ let pw: RapierPhysicsWorld3D;
1358
+ try {
1359
+ pw = world.getResource<RapierPhysicsWorld3D>('PhysicsWorld');
1360
+ } catch {
1361
+ return; // C-2: PhysicsWorld resource not yet ready — safe early out
1362
+ }
1363
+
1364
+ pw.applyPendingTeleports();
1365
+ pw._syncFromEcs(world, transformComponent);
1366
+ },
1367
+ });
1368
+
1369
+ /**
1370
+ * `physicsStepSimulation` system token (M2 — full resource-ification, D-4).
1371
+ *
1372
+ * After physicsSyncBackend — read FixedTime.delta and call pw.step() with dt-gating.
1373
+ */
1374
+ export const PhysicsStepSimulation: SystemHandle<readonly []> = defineSystem({
1375
+ name: PHYSICS_STEP_SIMULATION,
1376
+ queries: [],
1377
+ after: [PHYSICS_SYNC_BACKEND],
1378
+ fn: (world) => {
1379
+ let pw: RapierPhysicsWorld3D;
1380
+ try {
1381
+ pw = world.getResource<RapierPhysicsWorld3D>('PhysicsWorld');
1382
+ } catch {
1383
+ return; // C-2: safe early out
1384
+ }
1385
+
1386
+ const dt = world.getResource(FixedTime).delta;
1387
+ if (dt <= 0 || dt > PHYSICS_DT_MAX) return; // D-4: skip abnormal delta
1388
+
1389
+ pw.step(dt);
1390
+ },
1391
+ });
1392
+
1393
+ /**
1394
+ * `physicsWriteback` system token (M2 — full resource-ification, D-4).
1395
+ *
1396
+ * After physicsStepSimulation — call pw.writebackDynamicBodies() and write
1397
+ * positions back to ECS Transform (resolved via the global registry, D-3).
1398
+ */
1399
+ export const PhysicsWriteback: SystemHandle<readonly []> = defineSystem({
1400
+ name: PHYSICS_WRITEBACK,
1401
+ queries: [],
1402
+ after: [PHYSICS_STEP_SIMULATION],
1403
+ fn: (world) => {
1404
+ const transformComponent = resolveTransform(world);
1405
+ if (transformComponent === undefined) return;
1406
+ let pw: RapierPhysicsWorld3D;
1407
+ try {
1408
+ pw = world.getResource<RapierPhysicsWorld3D>('PhysicsWorld');
1409
+ } catch {
1410
+ return; // C-2: safe early out
1411
+ }
1412
+
1413
+ const results = pw.writebackDynamicBodies();
1414
+ for (const r of results) {
1415
+ const entity = r.entity as EntityHandle;
1416
+ world.set(entity, transformComponent, {
1417
+ pos: [r.pos.x, r.pos.y, r.pos.z],
1418
+ quat: [r.rotation.x, r.rotation.y, r.rotation.z, r.rotation.w],
1419
+ });
1420
+ }
1421
+ },
1422
+ });
1423
+
1424
+ /**
1425
+ * `physicsCollisionSync` system token — writes the drained overlap set into each
1426
+ * entity's `CollidingEntities` component (the contact/sensor set-query path).
1427
+ *
1428
+ * Runs after writeback so the component reflects this step's contacts. Without
1429
+ * it the `CollidingEntities` component documented in the physics README never
1430
+ * updates (the event queue was drained-on-overflow only), so sensor pickup +
1431
+ * proximity queries silently saw an empty set.
1432
+ */
1433
+ export const PhysicsCollisionSync: SystemHandle<readonly []> = defineSystem({
1434
+ name: PHYSICS_COLLISION_SYNC,
1435
+ queries: [],
1436
+ after: [PHYSICS_WRITEBACK],
1437
+ fn: (world) => {
1438
+ let pw: RapierPhysicsWorld3D;
1439
+ try {
1440
+ pw = world.getResource<RapierPhysicsWorld3D>('PhysicsWorld');
1441
+ } catch {
1442
+ return; // C-2: safe early out
1443
+ }
1444
+ pw.writebackCollidingEntities(world, CollidingEntities as unknown as Component);
1445
+ },
1446
+ });
1447
+
1448
+ /**
1449
+ * Register the physics tick systems into an ECS World.
1450
+ *
1451
+ * The systems ({@link PhysicsSyncBackend} / {@link PhysicsStepSimulation} /
1452
+ * {@link PhysicsWriteback} / {@link PhysicsCollisionSync}) are module-level
1453
+ * `defineSystem` tokens; this helper wires the moveAndSlide context + despawn
1454
+ * cleanup hook, then adds the tokens to the schedule.
1455
+ *
1456
+ * Transform is resolved from the World-local ECS component catalog,
1457
+ * D-3) — the previous `transformComponent` second parameter was redundant once
1458
+ * the system fns and moveContext resolve Transform themselves, so it is gone.
1459
+ *
1460
+ * @param world ECS World instance.
1461
+ */
1462
+ export function registerPhysicsSystems(world: World): () => void {
1463
+ const releaseComponents = registerPhysicsComponents(world);
1464
+ // ── moveAndSlide context + despawn cleanup wiring (D-1/D-3) ──
1465
+ // Wire the World + Transform/CharacterController components into the backend
1466
+ // so moveAndSlide can read tuning and write pose/grounded back, and register
1467
+ // the backend for the global Collider.onRemove dispatch (despawn cleanup).
1468
+ const transformComponent = resolveTransform(world);
1469
+ try {
1470
+ const pw = world.getResource<RapierPhysicsWorld3D>('PhysicsWorld');
1471
+ if (transformComponent !== undefined) {
1472
+ pw.setMoveContext(world, transformComponent, CharacterController);
1473
+ }
1474
+ } catch {
1475
+ // PhysicsWorld resource not yet inserted — moveAndSlide falls back to
1476
+ // CharacterController schema defaults until a later registration wires it.
1477
+ }
1478
+
1479
+ world
1480
+ .addSystems(FixedUpdate, PhysicsSet, [
1481
+ PhysicsSyncBackend,
1482
+ PhysicsStepSimulation,
1483
+ PhysicsWriteback,
1484
+ PhysicsCollisionSync,
1485
+ ])
1486
+ .unwrap();
1487
+ return () => {
1488
+ world.removeSystem(FixedUpdate, PHYSICS_COLLISION_SYNC);
1489
+ world.removeSystem(FixedUpdate, PHYSICS_WRITEBACK);
1490
+ world.removeSystem(FixedUpdate, PHYSICS_STEP_SIMULATION);
1491
+ world.removeSystem(FixedUpdate, PHYSICS_SYNC_BACKEND);
1492
+ try {
1493
+ world.getResource<RapierPhysicsWorld3D>('PhysicsWorld').clearEcsContext(world);
1494
+ } catch {
1495
+ // PhysicsWorld may already have been removed as part of outer teardown.
1496
+ }
1497
+ releaseComponents();
1498
+ };
1499
+ }