@ignifx/physics-2d 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -0,0 +1,3881 @@
1
+ import { Component, IgnifxError, Phase, Signal, Vec2, Vec3, array, asset, bool, createDefaults, createServiceKey, defineExtension, defineSchema, describeSchema, enumOf, f32, lerp, map, optional, record, str, u32, vec2 } from "@ignifx/core";
2
+ import { ActiveCollisionTypes, ActiveEvents, Ball, Capsule, CoefficientCombineRule, ColliderDesc, ConvexPolygon, Cuboid, EventQueue, Polyline, QueryFilterFlags, Ray, RigidBodyDesc, World, init } from "@dimforge/rapier2d-compat";
3
+ //#region src/runtime/runtime-key.ts
4
+ /**
5
+ * The key the extension registers its runtime holder under, so that `Rigidbody2D`, the 2D
6
+ * colliders, and `CharacterController2D` can reach the runtime through `this.app.services` without
7
+ * a module-level singleton (`CONSTITUTION.md` §3.6: no globals, two apps in one process).
8
+ *
9
+ * The **holder** is registered rather than the runtime itself, because everything an extension
10
+ * contributes is declared in `register` (`docs/architecture/04-extensions.md` §1) and the runtime
11
+ * does not exist until `onStart`.
12
+ *
13
+ * @internal
14
+ */
15
+ const Physics2DHostKey = createServiceKey("@ignifx/physics-2d:host");
16
+ //#endregion
17
+ //#region src/components/rigidbody.ts
18
+ /**
19
+ * The `Rigidbody2D` component (`docs/architecture/11-2d-toolkit.md` §8): what makes an entity's 2D
20
+ * colliders a simulated, kinematic, or explicitly static Rapier body.
21
+ *
22
+ * ## Decisions §8 leaves open
23
+ *
24
+ * - **`mass` is exact, and `0` means "weigh the colliders".** Rapier derives a body's mass and
25
+ * angular inertia from each collider's *density* and area (`geometry/collider.d.ts`,
26
+ * `setDensity`), and `setAdditionalMass` adds mass without inertia. So a non-zero `mass` is
27
+ * applied by scaling every collider's density until the body weighs exactly that, which keeps the
28
+ * inertia distribution the shapes imply; `mass: 0` leaves Rapier's own density of 1 kg/m² alone.
29
+ * - **`freezeRotation` is one boolean, not three.** A 2D body has one rotational degree of freedom
30
+ * (`dynamics/rigid_body.d.ts`, `lockRotations`).
31
+ * - **Damping is per body here, unlike 3D.** Havok has no per-body damping; Rapier does
32
+ * (`setLinearDamping`, `setAngularDamping`), so `linearDamping` and `angularDamping` are real
33
+ * fields rather than upstream requests.
34
+ * - **`collisionEvents` is a three-valued enum**, exactly as in `@ignifx/physics`: a schema field
35
+ * has one kind (ADR-0004), and `"auto"` recomputes from `ctx.entityImplements` whenever a
36
+ * component joins or leaves the entity.
37
+ */
38
+ /**
39
+ * How a 2D body moves.
40
+ *
41
+ * @public
42
+ */
43
+ const BODY_TYPES_2D = [
44
+ "dynamic",
45
+ "kinematic",
46
+ "static"
47
+ ];
48
+ /**
49
+ * Whether a body's display pose is interpolated between fixed steps.
50
+ *
51
+ * @public
52
+ */
53
+ const INTERPOLATION_MODES_2D = ["none", "interpolate"];
54
+ /**
55
+ * Whether collision callbacks are delivered for this body.
56
+ *
57
+ * @public
58
+ */
59
+ const COLLISION_EVENT_MODES_2D = [
60
+ "auto",
61
+ "on",
62
+ "off"
63
+ ];
64
+ /**
65
+ * Makes an entity's 2D colliders a Rapier body.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * const crate = world.createEntity("Crate");
70
+ * crate.transform.position2D = new Vec2(0, 5);
71
+ * crate.addComponent(BoxCollider2D, { size: { x: 1, y: 1 } });
72
+ * crate.addComponent(Rigidbody2D, { mass: 2 });
73
+ * ```
74
+ *
75
+ * @public
76
+ */
77
+ var Rigidbody2D = class Rigidbody2D extends Component {
78
+ /** The namespaced registration id. */
79
+ static typeId = "ignifx/Rigidbody2D";
80
+ /** One body per entity. */
81
+ static allowMultiple = false;
82
+ /** The serialized field declarations (ADR-0004). */
83
+ static schema = rigidbody2DSchema();
84
+ /** Applies the schema defaults, exactly as `Component.define` would. */
85
+ constructor() {
86
+ super();
87
+ Object.assign(this, createDefaults(Rigidbody2D.schema));
88
+ }
89
+ /** Marks the entity's body for a rebuild at the start of the next fixed step. */
90
+ onAttach() {
91
+ this.rebuild();
92
+ }
93
+ /** Marks the entity's body for a rebuild, which turns it back into an implicit static body. */
94
+ onDetach() {
95
+ this.rebuild();
96
+ }
97
+ /**
98
+ * Rebuilds the body at the start of the next fixed step. Call it after changing `bodyType`,
99
+ * `mass`, `freezeRotation`, damping, or the entity's scale.
100
+ */
101
+ rebuild() {
102
+ this.#runtime()?.markDirty(this.entity);
103
+ }
104
+ /**
105
+ * The Rapier handles this component owns.
106
+ *
107
+ * @returns The body, or `null` before the first fixed step built it.
108
+ */
109
+ get rapier() {
110
+ return { body: this.#runtime()?.bodyHandleOf(this.entity) ?? null };
111
+ }
112
+ /**
113
+ * The body's linear velocity in metres per second.
114
+ *
115
+ * @returns A freshly allocated vector; use {@link Rigidbody2D.linearVelocityToRef} in hot code.
116
+ */
117
+ get linearVelocity() {
118
+ const out = new Vec2();
119
+ this.linearVelocityToRef(out);
120
+ return out;
121
+ }
122
+ /**
123
+ * Replaces the body's linear velocity.
124
+ *
125
+ * @param value - Metres per second, world space.
126
+ */
127
+ set linearVelocity(value) {
128
+ this.#runtime()?.setLinearVelocity(this.entity, value);
129
+ }
130
+ /**
131
+ * Reads the linear velocity without allocating.
132
+ *
133
+ * @param out - The vector to write.
134
+ * @returns `out`.
135
+ */
136
+ linearVelocityToRef(out) {
137
+ this.#runtime()?.readLinear(this.entity, out);
138
+ return out;
139
+ }
140
+ /**
141
+ * The body's angular velocity.
142
+ *
143
+ * @returns Degrees per second, counter-clockwise — the same unit as `Transform.rotation2D`.
144
+ */
145
+ get angularVelocity() {
146
+ return this.#runtime()?.readAngular(this.entity) ?? 0;
147
+ }
148
+ /**
149
+ * Replaces the body's angular velocity.
150
+ *
151
+ * @param value - Degrees per second, counter-clockwise.
152
+ */
153
+ set angularVelocity(value) {
154
+ this.#runtime()?.setAngularVelocity(this.entity, value);
155
+ }
156
+ /**
157
+ * The mass Rapier computed for the body, in kilograms.
158
+ *
159
+ * @returns The mass, or `0` before the body exists.
160
+ */
161
+ get computedMass() {
162
+ return this.#runtime()?.massOf(this.entity) ?? 0;
163
+ }
164
+ /**
165
+ * Applies a force for one fixed step. Call it from `fixedUpdate`.
166
+ *
167
+ * @param force - Newtons, world space.
168
+ * @param point - Where to apply it; defaults to the centre of mass.
169
+ *
170
+ * @example
171
+ * ```ts
172
+ * fixedUpdate(): void {
173
+ * this.body.addForce({ x: 0, y: 20 });
174
+ * }
175
+ * ```
176
+ */
177
+ addForce(force, point) {
178
+ this.#runtime()?.addForce(this.entity, force, point ?? null);
179
+ }
180
+ /**
181
+ * Applies an instantaneous impulse.
182
+ *
183
+ * @param impulse - Newton-seconds, world space.
184
+ * @param point - Where to apply it; defaults to the centre of mass.
185
+ */
186
+ addImpulse(impulse, point) {
187
+ this.#runtime()?.addImpulse(this.entity, impulse, point ?? null);
188
+ }
189
+ /**
190
+ * Applies a torque for one fixed step.
191
+ *
192
+ * @param torque - Newton-metres, positive counter-clockwise.
193
+ */
194
+ addTorque(torque) {
195
+ this.#runtime()?.addTorque(this.entity, torque);
196
+ }
197
+ /**
198
+ * Moves the body without integrating a velocity, and resets the interpolation history so the
199
+ * display pose does not slide across the gap.
200
+ *
201
+ * @param position - The new world position, in metres.
202
+ * @param rotation - The new rotation in degrees counter-clockwise; defaults to the current one.
203
+ */
204
+ teleport(position, rotation) {
205
+ this.#runtime()?.teleport(this.entity, position, rotation ?? this.transform.rotation2D);
206
+ }
207
+ /**
208
+ * The runtime, or `null` when the extension is not registered on this app.
209
+ *
210
+ * @returns The runtime.
211
+ */
212
+ #runtime() {
213
+ return this.app.services.tryGet(Physics2DHostKey)?.runtime ?? null;
214
+ }
215
+ };
216
+ /**
217
+ * Builds the `Rigidbody2D` schema.
218
+ *
219
+ * @returns The schema.
220
+ */
221
+ function rigidbody2DSchema() {
222
+ return defineSchema({
223
+ bodyType: enumOf(BODY_TYPES_2D, "dynamic"),
224
+ mass: f32(1, { min: 0 }),
225
+ gravityScale: f32(1),
226
+ linearDamping: f32(0, { min: 0 }),
227
+ angularDamping: f32(.05, { min: 0 }),
228
+ freezeRotation: bool(false),
229
+ interpolation: enumOf(INTERPOLATION_MODES_2D, "interpolate"),
230
+ collisionEvents: enumOf(COLLISION_EVENT_MODES_2D, "auto")
231
+ });
232
+ }
233
+ //#endregion
234
+ //#region src/components/character-controller.ts
235
+ /**
236
+ * The `CharacterController2D` component (`docs/architecture/11-2d-toolkit.md` §8): Rapier's
237
+ * `KinematicCharacterController` (`control/character_controller.d.ts`) driven from `fixedUpdate`.
238
+ *
239
+ * ## How it is driven
240
+ *
241
+ * Call {@link CharacterController2D.move} from `fixedUpdate`. The requested displacements of one
242
+ * step are summed and handed to Rapier's `computeColliderMovement` by the step system, so two
243
+ * scripts moving the same character compose instead of fighting. Gravity is **not** applied by this
244
+ * component: a bare controller is purely kinematic, exactly as the 3D one is, and the toolkit
245
+ * controller scripts integrate gravity themselves.
246
+ *
247
+ * After the step the controller owns the entity's world X and Y. Z and `rotation2D` stay
248
+ * user-controlled.
249
+ *
250
+ * ## The one addition to §8: `shape`
251
+ *
252
+ * §8 describes the controller as a capsule. Measured against `@dimforge/rapier2d-compat@0.20.0`
253
+ * (spike S6.2), Rapier's autostep clears a 0.3 m step with a **box** character and refuses anything
254
+ * above about 0.15 m with a capsule of radius 0.2 — so `stepOffset` is only usable with a box.
255
+ * {@link CharacterController2D.shape} therefore chooses between the two, defaulting to `"capsule"`
256
+ * as §8 says, and the limitation is documented in the skill and in ADR-0006's validation section.
257
+ */
258
+ /**
259
+ * The collision shape a 2D character controller uses.
260
+ *
261
+ * @public
262
+ */
263
+ const CHARACTER_SHAPES_2D = ["capsule", "box"];
264
+ /**
265
+ * A kinematic character that walks, slides, climbs slopes, and steps up.
266
+ *
267
+ * @example
268
+ * ```ts
269
+ * class Walk extends Script implements ScriptCallbacks {
270
+ * static typeId = "mygame/Walk";
271
+ * fixedUpdate(dt: number): void {
272
+ * const controller = this.entity.requireComponent(CharacterController2D);
273
+ * controller.move({ x: 4 * dt, y: -9.81 * dt });
274
+ * }
275
+ * }
276
+ * ```
277
+ *
278
+ * @public
279
+ */
280
+ var CharacterController2D = class CharacterController2D extends Component {
281
+ /** The namespaced registration id. */
282
+ static typeId = "ignifx/CharacterController2D";
283
+ /** One controller per entity. */
284
+ static allowMultiple = false;
285
+ /** The serialized field declarations (ADR-0004). */
286
+ static schema = characterSchema();
287
+ /** The displacement requested so far this fixed step. */
288
+ #pendingMove = new Vec2();
289
+ /** The movement Rapier resolved for the last step, which is what `velocity` reports. */
290
+ #resolvedMove = new Vec2();
291
+ /** The averaged normal of the supporting surface, written by the step system. */
292
+ #groundNormal = new Vec2(0, 1);
293
+ /** Whether the last step ended on walkable ground. */
294
+ #grounded = false;
295
+ /** The fixed step the last move was resolved over, for `velocity`. */
296
+ #lastDelta = 0;
297
+ /** Emitted for every obstacle the character hit; created on first access. */
298
+ #onCollided = null;
299
+ /** Applies the schema defaults. */
300
+ constructor() {
301
+ super();
302
+ Object.assign(this, createDefaults(CharacterController2D.schema));
303
+ }
304
+ /** Creates the Rapier controller at the start of the next fixed step. */
305
+ onAttach() {
306
+ this.rebuild();
307
+ }
308
+ /** Releases the Rapier controller and its collider. */
309
+ onDetach() {
310
+ this.#runtime()?.releaseController(this);
311
+ }
312
+ /** Rebuilds the capsule or box at the start of the next fixed step. */
313
+ rebuild() {
314
+ this.#runtime()?.markControllerDirty(this);
315
+ }
316
+ /**
317
+ * Requests a displacement for this fixed step. Displacements accumulate until the step runs.
318
+ *
319
+ * @param displacement - The world-space displacement to attempt, in metres.
320
+ */
321
+ move(displacement) {
322
+ this.#pendingMove.set(this.#pendingMove.x + displacement.x, this.#pendingMove.y + displacement.y);
323
+ }
324
+ /**
325
+ * How fast the character actually moved over the last fixed step, after sliding and blocking.
326
+ *
327
+ * @returns A freshly allocated vector in metres per second.
328
+ */
329
+ get velocity() {
330
+ if (this.#lastDelta <= 0) return new Vec2();
331
+ return new Vec2(this.#resolvedMove.x / this.#lastDelta, this.#resolvedMove.y / this.#lastDelta);
332
+ }
333
+ /**
334
+ * Whether the character ended the last step on walkable ground.
335
+ *
336
+ * @returns Rapier's `computedGrounded` from the last move.
337
+ */
338
+ get isGrounded() {
339
+ return this.#grounded;
340
+ }
341
+ /**
342
+ * The most upward-facing normal of the obstacles the last move touched.
343
+ *
344
+ * @returns A live view; copy it if you keep it.
345
+ */
346
+ get groundNormal() {
347
+ return this.#groundNormal;
348
+ }
349
+ /**
350
+ * Emitted once per obstacle the character hit during a step.
351
+ *
352
+ * @returns The signal, created on first access.
353
+ */
354
+ get onCollided() {
355
+ this.#onCollided ??= new Signal();
356
+ return this.#onCollided;
357
+ }
358
+ /**
359
+ * Teleports the character, clearing any pending motion and the interpolation history.
360
+ *
361
+ * @param position - The new world position of the entity, in metres.
362
+ */
363
+ teleport(position) {
364
+ this.#pendingMove.set(0, 0);
365
+ this.#runtime()?.teleportController(this, position);
366
+ }
367
+ /**
368
+ * The displacement requested so far this step, consumed by the step system.
369
+ *
370
+ * @returns A live view of the accumulator.
371
+ *
372
+ * @internal
373
+ */
374
+ takePendingMove() {
375
+ return this.#pendingMove;
376
+ }
377
+ /**
378
+ * Records the outcome of a step.
379
+ *
380
+ * @param movement - What Rapier resolved.
381
+ * @param grounded - Whether the character ended on walkable ground.
382
+ * @param normal - The most upward-facing contact normal, or `null` when there was none.
383
+ * @param deltaSeconds - The fixed step the move covered.
384
+ *
385
+ * @internal
386
+ */
387
+ applyStepResult(movement, grounded, normal, deltaSeconds) {
388
+ this.#resolvedMove.set(movement.x, movement.y);
389
+ this.#grounded = grounded;
390
+ this.#lastDelta = deltaSeconds;
391
+ if (normal !== null) this.#groundNormal.set(normal.x, normal.y);
392
+ this.#pendingMove.set(0, 0);
393
+ }
394
+ /**
395
+ * Emits `onCollided` if anything is listening.
396
+ *
397
+ * @param collision - The obstacle to report.
398
+ *
399
+ * @internal
400
+ */
401
+ reportHit(collision) {
402
+ this.#onCollided?.emit(collision);
403
+ }
404
+ /**
405
+ * The runtime, or `null` when the extension is not registered on this app.
406
+ *
407
+ * @returns The runtime.
408
+ */
409
+ #runtime() {
410
+ return this.app.services.tryGet(Physics2DHostKey)?.runtime ?? null;
411
+ }
412
+ };
413
+ /**
414
+ * Builds the `CharacterController2D` schema.
415
+ *
416
+ * @returns The schema.
417
+ */
418
+ function characterSchema() {
419
+ return defineSchema({
420
+ shape: enumOf(CHARACTER_SHAPES_2D, "capsule"),
421
+ radius: f32(.25, { min: 0 }),
422
+ height: f32(1, { min: 0 }),
423
+ offset: vec2(),
424
+ slopeLimit: f32(45, {
425
+ min: 0,
426
+ max: 90
427
+ }),
428
+ stepOffset: f32(0, { min: 0 }),
429
+ snapToGround: f32(0, { min: 0 }),
430
+ skinWidth: f32(.01, { min: 0 }),
431
+ onOneWayPlatforms: bool(true),
432
+ pushBodies: bool(false),
433
+ interpolation: enumOf(INTERPOLATION_MODES_2D, "interpolate")
434
+ });
435
+ }
436
+ //#endregion
437
+ //#region src/errors.ts
438
+ /**
439
+ * The diagnostic codes `@ignifx/physics-2d` owns. 2D physics shares the `11` subsystem range with
440
+ * the 2D toolkit (`docs/architecture/15-devtools-and-diagnostics.md` §1,
441
+ * `docs/architecture/11-2d-toolkit.md` §8), so the range is split: `@ignifx/2d` owns
442
+ * `IGX-1101`–`IGX-1149` and this package owns `IGX-1150` upwards.
443
+ *
444
+ * `IGX-1101` is the one exception. `11-2d-toolkit.md` §8 names it by number — "registering both
445
+ * throws `IGX-1101`" — and only this package can detect the condition, so this package registers
446
+ * it. An app's error-code registry refuses a duplicate registration
447
+ * (`packages/core/src/errors/error-code-registry.ts`), so `@ignifx/2d` must not declare it.
448
+ */
449
+ /**
450
+ * Every diagnostic code `@ignifx/physics-2d` can throw or report.
451
+ *
452
+ * @example
453
+ * ```ts
454
+ * throw physics2DError(Physics2DErrorCode.queryBeforeStep, "raycast() ran before the first step.", {
455
+ * context: { query: "raycast" },
456
+ * });
457
+ * ```
458
+ *
459
+ * @public
460
+ */
461
+ const Physics2DErrorCode = {
462
+ /** Both `physics()` and `physics2d()` are registered on one world (`11-2d-toolkit.md` §8). */
463
+ bothPhysicsExtensions: "IGX-1101",
464
+ /** The Rapier WebAssembly module could not be instantiated. */
465
+ rapierUnavailable: "IGX-1150",
466
+ /** An entity with 2D colliders but no `Rigidbody2D` moved after its static body was placed. */
467
+ movedStaticBody: "IGX-1151",
468
+ /** A collider's layer index is outside the sixteen Rapier's interaction groups can express. */
469
+ layerOutOfRange: "IGX-1152",
470
+ /** A query ran before the first completed fixed step, so Rapier has no broadphase yet. */
471
+ queryBeforeStep: "IGX-1153",
472
+ /** A `.physicsmaterial.json` file is not an `ignifx.physicsmaterial` document this build reads. */
473
+ invalidMaterialFile: "IGX-1154",
474
+ /** The `physics2d.collisionMatrix` setting names a layer the project does not declare. */
475
+ unknownLayer: "IGX-1155",
476
+ /** A collider's geometry is degenerate: too few points, or a hull Rapier refused to build. */
477
+ colliderGeometryInvalid: "IGX-1156",
478
+ /** A 2D body was built for an entity that has a parent, whose pose is not world space. */
479
+ bodyOnChildEntity: "IGX-1157"
480
+ };
481
+ /**
482
+ * The one-line message template of every code, as `ExtensionContext.registerErrorCodes` wants it.
483
+ *
484
+ * @public
485
+ */
486
+ const PHYSICS_2D_ERROR_MESSAGES = Object.freeze({
487
+ "IGX-1101": "A world uses either physics() or physics2d(), and {extension} found the other already there.",
488
+ "IGX-1150": "The Rapier 2D WebAssembly module could not be instantiated.",
489
+ "IGX-1151": "{entity} has 2D colliders but no Rigidbody2D, and it moved after its body was placed.",
490
+ "IGX-1152": "{entity} is on layer {layer}, which is outside the sixteen layers Rapier 2D can filter by.",
491
+ "IGX-1153": "{query} needs at least one completed fixed step before Rapier can answer it.",
492
+ "IGX-1154": "{file} is not an ignifx.physicsmaterial document this build can read.",
493
+ "IGX-1155": "physics2d.collisionMatrix names the layer {layer}, which the project does not declare.",
494
+ "IGX-1156": "{entity} has a 2D collider whose geometry Rapier cannot build a shape from.",
495
+ "IGX-1157": "{entity} carries a 2D physics body but is not a root entity; Rapier writes its world pose."
496
+ });
497
+ /**
498
+ * Builds an `IgnifxError` carrying one of this package's codes.
499
+ *
500
+ * @param code - The code from the `Physics2DErrorCode` table.
501
+ * @param message - The actionable development sentence.
502
+ * @param options - Context identifiers, a remedy hint, and the wrapped cause.
503
+ * @returns The error to throw or to reject with.
504
+ *
505
+ * @example
506
+ * ```ts
507
+ * throw physics2DError(Physics2DErrorCode.unknownLayer, "physics2d.collisionMatrix names Enemy.", {
508
+ * context: { layer: "Enemy" },
509
+ * });
510
+ * ```
511
+ *
512
+ * @public
513
+ */
514
+ function physics2DError(code, message, options) {
515
+ return new IgnifxError(code, message, {
516
+ ...options?.context === void 0 ? {} : { context: options.context },
517
+ ...options?.hint === void 0 ? {} : { hint: options.hint },
518
+ ...options?.cause === void 0 ? {} : { cause: options.cause }
519
+ });
520
+ }
521
+ //#endregion
522
+ //#region src/material.ts
523
+ /**
524
+ * The `ignifx.physicsmaterial` asset, read by the 2D extension.
525
+ *
526
+ * ## Why the format is the 3D one
527
+ *
528
+ * `11-2d-toolkit.md` §8 lists a `PhysicsMaterial2D`, and `09-physics.md` §2.4 already defines
529
+ * `{ "format": "ignifx.physicsmaterial", "friction": …, "staticFriction": …, "restitution": … }`.
530
+ * Inventing a second document for the same two numbers would give artists two files to keep in
531
+ * step, so this package reads **the same format and the same `.physicsmaterial.json` extension**,
532
+ * ignoring `staticFriction` (Rapier 2D has one friction coefficient, `geometry/collider.d.ts`).
533
+ * `@ignifx/physics` and `@ignifx/physics-2d` never run in one world — `IGX-1101` — so the two
534
+ * loaders never collide.
535
+ *
536
+ * The 2D-only knobs, how two surfaces' friction and restitution are **combined**, are fields on the
537
+ * collider rather than on the asset, so the document stays byte-identical to the 3D one.
538
+ */
539
+ /**
540
+ * The asset type name `.physicsmaterial.json` addresses resolve to.
541
+ *
542
+ * @public
543
+ */
544
+ const PHYSICS_MATERIAL_2D_ASSET_TYPE = "physicsmaterial";
545
+ /**
546
+ * The address suffix that selects the loader.
547
+ *
548
+ * @public
549
+ */
550
+ const PHYSICS_MATERIAL_2D_FILE_EXTENSION = ".physicsmaterial.json";
551
+ /**
552
+ * The `format` string every physics-material document declares.
553
+ *
554
+ * @public
555
+ */
556
+ const PHYSICS_MATERIAL_2D_FILE_FORMAT = "ignifx.physicsmaterial";
557
+ /**
558
+ * The file format version; `1` before ignifx 1.0.
559
+ *
560
+ * @public
561
+ */
562
+ const PHYSICS_MATERIAL_2D_FORMAT_VERSION = 1;
563
+ /**
564
+ * A loaded 2D surface.
565
+ *
566
+ * @example
567
+ * ```ts
568
+ * const ice = await app.assets.load<PhysicsMaterial2D>("materials/ice.physicsmaterial.json");
569
+ * floor.addComponent(BoxCollider2D, { size: { x: 10, y: 1 }, material: ice });
570
+ * ```
571
+ *
572
+ * @public
573
+ */
574
+ var PhysicsMaterial2D = class PhysicsMaterial2D {
575
+ /** The asset type token, so `asset(PhysicsMaterial2D)` fields resolve. */
576
+ static assetType = PHYSICS_MATERIAL_2D_ASSET_TYPE;
577
+ /** A human-readable name, used in diagnostics. */
578
+ name;
579
+ /** The friction coefficient. */
580
+ friction;
581
+ /** How much of the approach speed is returned, `0` to `1`. */
582
+ restitution;
583
+ /**
584
+ * Wraps parsed values.
585
+ *
586
+ * @param name - A human-readable name.
587
+ * @param values - Friction and restitution.
588
+ */
589
+ constructor(name, values) {
590
+ this.name = name;
591
+ this.friction = values.friction;
592
+ this.restitution = values.restitution;
593
+ }
594
+ /**
595
+ * Builds a material in code, filling in the fields the caller omitted.
596
+ *
597
+ * @param name - A human-readable name.
598
+ * @param values - Any subset of the two coefficients.
599
+ * @returns The material.
600
+ *
601
+ * @example
602
+ * ```ts
603
+ * const bouncy = PhysicsMaterial2D.fromValues("bouncy", { restitution: 0.9 });
604
+ * ```
605
+ */
606
+ static fromValues(name, values) {
607
+ return new PhysicsMaterial2D(name, {
608
+ friction: values.friction ?? .6,
609
+ restitution: values.restitution ?? 0
610
+ });
611
+ }
612
+ };
613
+ /**
614
+ * Builds the loader for `.physicsmaterial.json` files.
615
+ *
616
+ * @returns The loader, ready for `ctx.registerAssetLoader`.
617
+ *
618
+ * @public
619
+ */
620
+ function createPhysicsMaterial2DLoader() {
621
+ return {
622
+ type: PHYSICS_MATERIAL_2D_ASSET_TYPE,
623
+ extensions: [PHYSICS_MATERIAL_2D_FILE_EXTENSION],
624
+ load: async (ctx) => {
625
+ const document = await ctx.fetchJson();
626
+ return parsePhysicsMaterial2D(ctx.address, document);
627
+ }
628
+ };
629
+ }
630
+ /**
631
+ * Parses one `ignifx.physicsmaterial` document into a 2D surface.
632
+ *
633
+ * @param address - The address it came from, for the diagnostic.
634
+ * @param document - The parsed JSON.
635
+ * @returns The material.
636
+ * @throws IgnifxError with code `IGX-1154` when the document is not one this build can read.
637
+ *
638
+ * @public
639
+ */
640
+ function parsePhysicsMaterial2D(address, document) {
641
+ const object = asObject(document);
642
+ if (object === null || object["format"] !== "ignifx.physicsmaterial") throw physics2DError(Physics2DErrorCode.invalidMaterialFile, `${address} is not an ${PHYSICS_MATERIAL_2D_FILE_FORMAT}.`, {
643
+ context: { file: address },
644
+ hint: `Add "format": "${PHYSICS_MATERIAL_2D_FILE_FORMAT}" to the document.`
645
+ });
646
+ const version = numberAt(object, "formatVersion", 1);
647
+ if (version > 1) throw physics2DError(Physics2DErrorCode.invalidMaterialFile, `${address} is version ${String(version)}.`, {
648
+ context: {
649
+ file: address,
650
+ version
651
+ },
652
+ hint: "Upgrade @ignifx/physics-2d, or re-save the material from this version of the editor."
653
+ });
654
+ return new PhysicsMaterial2D(typeof object["name"] === "string" ? object["name"] : address, {
655
+ friction: numberAt(object, "friction", .6),
656
+ restitution: numberAt(object, "restitution", 0)
657
+ });
658
+ }
659
+ /**
660
+ * Views a JSON value as an object.
661
+ *
662
+ * @param value - The parsed JSON.
663
+ * @returns The object, or `null` when the value is not one.
664
+ */
665
+ function asObject(value) {
666
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
667
+ return value;
668
+ }
669
+ /**
670
+ * Reads a numeric property, tolerating its absence.
671
+ *
672
+ * @param object - The document.
673
+ * @param key - The property name.
674
+ * @param fallback - What to use when the property is missing or not a number.
675
+ * @returns The value.
676
+ */
677
+ function numberAt(object, key, fallback) {
678
+ const value = object[key];
679
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
680
+ }
681
+ //#endregion
682
+ //#region src/components/collider.ts
683
+ /**
684
+ * The 2D collider contract (`docs/architecture/11-2d-toolkit.md` §8). Every collider shares an
685
+ * offset, the trigger flag, a surface, a layer override, and the one-way platform flag, and answers
686
+ * one question the body builder asks: which Rapier shapes it stands for.
687
+ *
688
+ * ## Corrections to §8
689
+ *
690
+ * - **The material is two fields, not a union.** A schema field has exactly one kind (ADR-0004), so
691
+ * {@link Collider2D.material} (an asset reference) and {@link Collider2D.inlineMaterial} (a
692
+ * record) are separate; the asset wins, the inline record is the fallback, and
693
+ * `physics2d.defaultMaterial` is the last resort. This is the same split `@ignifx/physics` makes.
694
+ * - **Combine rules are collider fields.** Rapier combines two surfaces' friction and restitution
695
+ * with a per-collider rule (`dynamics/coefficient_combine_rule.d.ts`); the 3D
696
+ * `ignifx.physicsmaterial` document has no such field, so the knob lives here rather than
697
+ * forking the asset format.
698
+ * - **A collider may stand for several Rapier colliders.** `TilemapCollider2D` produces one polygon
699
+ * per merged tile run, so {@link Collider2D.collectShapes} fills a list rather than returning one
700
+ * shape.
701
+ *
702
+ * ## Sizes and scale
703
+ *
704
+ * Sizes are authored in local metres and multiplied by the entity's lossy scale when the shapes are
705
+ * built. Rapier cannot rescale a built shape, so a later scale change needs
706
+ * {@link Collider2D.rebuild}.
707
+ */
708
+ /**
709
+ * How two surfaces' coefficients are combined when they touch, mirroring Rapier's
710
+ * `CoefficientCombineRule`.
711
+ *
712
+ * @public
713
+ */
714
+ const COMBINE_RULES = [
715
+ "average",
716
+ "min",
717
+ "multiply",
718
+ "max"
719
+ ];
720
+ /**
721
+ * The fields every 2D collider declares.
722
+ *
723
+ * @returns The shared field declarations, ready to spread into a collider's own schema.
724
+ *
725
+ * @public
726
+ */
727
+ function collider2DFields() {
728
+ return {
729
+ offset: vec2(),
730
+ isTrigger: bool(false),
731
+ material: asset(PhysicsMaterial2D),
732
+ inlineMaterial: optional(record({
733
+ friction: f32(.6, { min: 0 }),
734
+ restitution: f32(0, {
735
+ min: 0,
736
+ max: 1
737
+ })
738
+ })),
739
+ frictionCombine: enumOf(COMBINE_RULES, "average"),
740
+ restitutionCombine: enumOf(COMBINE_RULES, "average"),
741
+ oneWay: bool(false),
742
+ layerOverride: str("")
743
+ };
744
+ }
745
+ /**
746
+ * The base class of every 2D collider.
747
+ *
748
+ * @public
749
+ */
750
+ var Collider2D = class extends Component {
751
+ /** Several colliders on one entity make one compound body. */
752
+ static allowMultiple = true;
753
+ /** Applies the shared defaults. A concrete collider calls `super()` and then applies its own. */
754
+ constructor() {
755
+ super();
756
+ Object.assign(this, createDefaults(collider2DFields()));
757
+ }
758
+ /** Marks the entity's body for a rebuild at the start of the next fixed step. */
759
+ onAttach() {
760
+ this.rebuild();
761
+ }
762
+ /** Marks the entity's body for a rebuild, which removes this collider from it. */
763
+ onDetach() {
764
+ this.rebuild();
765
+ }
766
+ /**
767
+ * Rebuilds the entity's body and shapes at the start of the next fixed step. Call it after
768
+ * changing a size, an offset, `isTrigger`, or the entity's scale.
769
+ *
770
+ * @example
771
+ * ```ts
772
+ * box.size = { x: 2, y: 2 };
773
+ * box.rebuild();
774
+ * ```
775
+ */
776
+ rebuild() {
777
+ this.app.services.tryGet(Physics2DHostKey)?.runtime?.markDirty(this.entity);
778
+ }
779
+ /**
780
+ * Resolves the surface this collider presents to Rapier.
781
+ *
782
+ * @param fallback - The world's `physics2d.defaultMaterial`.
783
+ * @returns The asset's values, the inline values, or the fallback.
784
+ */
785
+ resolveMaterial(fallback) {
786
+ return this.material?.value ?? this.inlineMaterial ?? fallback;
787
+ }
788
+ };
789
+ //#endregion
790
+ //#region src/components/colliders.ts
791
+ /**
792
+ * The concrete 2D colliders (`docs/architecture/11-2d-toolkit.md` §8). Each maps to one Rapier
793
+ * shape (`geometry/shape.d.ts`), sized in local metres and multiplied by the entity's lossy scale.
794
+ *
795
+ * ## The two point-list colliders
796
+ *
797
+ * - `PolygonCollider2D` builds a **convex** shape (`ColliderDesc.convexHull`). Rapier 2D also ships
798
+ * `convexDecomposition`, which needs a triangle mesh with an index buffer and runs V-HACD — far
799
+ * too heavy for a component field. Concave outlines are expressed as several
800
+ * `PolygonCollider2D`s on one entity, which is what a compound body is for, or as an
801
+ * `EdgeCollider2D` when the outline is open.
802
+ * - `EdgeCollider2D` builds a `polyline`, the open chain a platformer's ground contour wants. A
803
+ * polyline is infinitely thin: a fast body can tunnel through it, and a character controller that
804
+ * starts *inside* one is not pushed out. Static level geometry is what it is for.
805
+ */
806
+ /** A quarter turn in radians, which is how a horizontal capsule is expressed. */
807
+ const QUARTER_TURN = Math.PI / 2;
808
+ /**
809
+ * The axis a 2D capsule stands along.
810
+ *
811
+ * @public
812
+ */
813
+ const CAPSULE_DIRECTIONS_2D = ["x", "y"];
814
+ /**
815
+ * An axis-aligned box collider, sized in local metres.
816
+ *
817
+ * @example
818
+ * ```ts
819
+ * const floor = world.createEntity("Floor");
820
+ * floor.addComponent(BoxCollider2D, { size: { x: 20, y: 1 } });
821
+ * ```
822
+ *
823
+ * @public
824
+ */
825
+ var BoxCollider2D = class BoxCollider2D extends Collider2D {
826
+ /** The namespaced registration id. */
827
+ static typeId = "ignifx/BoxCollider2D";
828
+ /** The serialized field declarations (ADR-0004). */
829
+ static schema = boxSchema();
830
+ /** Applies this collider's defaults on top of the shared ones. */
831
+ constructor() {
832
+ super();
833
+ Object.assign(this, createDefaults(BoxCollider2D.schema));
834
+ }
835
+ /**
836
+ * Appends this collider's Rapier shape.
837
+ *
838
+ * @param scale - The entity's lossy 2D scale.
839
+ * @param out - The list to append to.
840
+ *
841
+ * @internal
842
+ */
843
+ collectShapes(scale, out) {
844
+ out.push({
845
+ shape: {
846
+ kind: "box",
847
+ halfWidth: this.size.x * abs(scale.x) / 2,
848
+ halfHeight: this.size.y * abs(scale.y) / 2
849
+ },
850
+ offset: scaled(this.offset, scale),
851
+ rotation: 0,
852
+ oneWay: this.oneWay
853
+ });
854
+ }
855
+ };
856
+ /**
857
+ * A circle collider. Non-uniform scale is not representable as a circle, so the larger scale axis
858
+ * wins — the same rule Unity applies.
859
+ *
860
+ * @public
861
+ */
862
+ var CircleCollider2D = class CircleCollider2D extends Collider2D {
863
+ /** The namespaced registration id. */
864
+ static typeId = "ignifx/CircleCollider2D";
865
+ /** The serialized field declarations. */
866
+ static schema = circleSchema();
867
+ /** Applies this collider's defaults on top of the shared ones. */
868
+ constructor() {
869
+ super();
870
+ Object.assign(this, createDefaults(CircleCollider2D.schema));
871
+ }
872
+ /**
873
+ * Appends this collider's Rapier shape.
874
+ *
875
+ * @param scale - The entity's lossy 2D scale.
876
+ * @param out - The list to append to.
877
+ *
878
+ * @internal
879
+ */
880
+ collectShapes(scale, out) {
881
+ out.push({
882
+ shape: {
883
+ kind: "circle",
884
+ radius: this.radius * Math.max(abs(scale.x), abs(scale.y))
885
+ },
886
+ offset: scaled(this.offset, scale),
887
+ rotation: 0,
888
+ oneWay: this.oneWay
889
+ });
890
+ }
891
+ };
892
+ /**
893
+ * A capsule collider: a rectangle with semicircular caps, standing along X or Y.
894
+ *
895
+ * @remarks
896
+ * `height` is the **total** tip-to-tip height, so a capsule shorter than `2 * radius` degenerates to
897
+ * a circle of that radius rather than inverting. Rapier's capsule always stands along Y, so an
898
+ * `x` capsule is expressed by swapping the axes of the half-extents — which means an `x` capsule
899
+ * and a rotated `y` capsule are the same shape.
900
+ *
901
+ * @public
902
+ */
903
+ var CapsuleCollider2D = class CapsuleCollider2D extends Collider2D {
904
+ /** The namespaced registration id. */
905
+ static typeId = "ignifx/CapsuleCollider2D";
906
+ /** The serialized field declarations. */
907
+ static schema = capsuleSchema();
908
+ /** Applies this collider's defaults on top of the shared ones. */
909
+ constructor() {
910
+ super();
911
+ Object.assign(this, createDefaults(CapsuleCollider2D.schema));
912
+ }
913
+ /**
914
+ * Appends this collider's Rapier shape.
915
+ *
916
+ * @param scale - The entity's lossy 2D scale.
917
+ * @param out - The list to append to.
918
+ *
919
+ * @internal
920
+ */
921
+ collectShapes(scale, out) {
922
+ const alongY = this.direction === "y";
923
+ const radialScale = alongY ? abs(scale.x) : abs(scale.y);
924
+ const axisScale = alongY ? abs(scale.y) : abs(scale.x);
925
+ const radius = this.radius * radialScale;
926
+ const half = Math.max(0, this.height * axisScale / 2 - radius);
927
+ out.push({
928
+ shape: {
929
+ kind: "capsule",
930
+ halfHeight: half,
931
+ radius
932
+ },
933
+ offset: scaled(this.offset, scale),
934
+ rotation: alongY ? 0 : QUARTER_TURN,
935
+ oneWay: this.oneWay
936
+ });
937
+ }
938
+ };
939
+ /**
940
+ * A convex polygon collider, wound in either direction, in local metres.
941
+ *
942
+ * @remarks
943
+ * Rapier builds the **convex hull** of the points, so a concave outline is silently rounded out.
944
+ * Model a concave shape as several `PolygonCollider2D`s on one entity, or as an
945
+ * {@link EdgeCollider2D} when it is an open contour.
946
+ *
947
+ * @public
948
+ */
949
+ var PolygonCollider2D = class PolygonCollider2D extends Collider2D {
950
+ /** The namespaced registration id. */
951
+ static typeId = "ignifx/PolygonCollider2D";
952
+ /** The serialized field declarations. */
953
+ static schema = pointsSchema();
954
+ /** Applies this collider's defaults on top of the shared ones. */
955
+ constructor() {
956
+ super();
957
+ Object.assign(this, createDefaults(PolygonCollider2D.schema));
958
+ }
959
+ /**
960
+ * Appends this collider's Rapier shape.
961
+ *
962
+ * @param scale - The entity's lossy 2D scale.
963
+ * @param out - The list to append to.
964
+ * @throws IgnifxError with code `IGX-1156` when fewer than three points are given.
965
+ *
966
+ * @internal
967
+ */
968
+ collectShapes(scale, out) {
969
+ if (this.points.length < 3) throw degenerate(this.entity.name, "PolygonCollider2D", "at least three points");
970
+ out.push({
971
+ shape: {
972
+ kind: "polygon",
973
+ points: packPoints(this.points, scale)
974
+ },
975
+ offset: scaled(this.offset, scale),
976
+ rotation: 0,
977
+ oneWay: this.oneWay
978
+ });
979
+ }
980
+ };
981
+ /**
982
+ * An open chain of line segments — a platformer's ground contour.
983
+ *
984
+ * @public
985
+ */
986
+ var EdgeCollider2D = class EdgeCollider2D extends Collider2D {
987
+ /** The namespaced registration id. */
988
+ static typeId = "ignifx/EdgeCollider2D";
989
+ /** The serialized field declarations. */
990
+ static schema = pointsSchema();
991
+ /** Applies this collider's defaults on top of the shared ones. */
992
+ constructor() {
993
+ super();
994
+ Object.assign(this, createDefaults(EdgeCollider2D.schema));
995
+ }
996
+ /**
997
+ * Appends this collider's Rapier shape.
998
+ *
999
+ * @param scale - The entity's lossy 2D scale.
1000
+ * @param out - The list to append to.
1001
+ * @throws IgnifxError with code `IGX-1156` when fewer than two points are given.
1002
+ *
1003
+ * @internal
1004
+ */
1005
+ collectShapes(scale, out) {
1006
+ if (this.points.length < 2) throw degenerate(this.entity.name, "EdgeCollider2D", "at least two points");
1007
+ out.push({
1008
+ shape: {
1009
+ kind: "polyline",
1010
+ points: packPoints(this.points, scale)
1011
+ },
1012
+ offset: scaled(this.offset, scale),
1013
+ rotation: 0,
1014
+ oneWay: this.oneWay
1015
+ });
1016
+ }
1017
+ };
1018
+ /**
1019
+ * Builds the `BoxCollider2D` schema.
1020
+ *
1021
+ * @returns The schema.
1022
+ */
1023
+ function boxSchema() {
1024
+ return defineSchema({
1025
+ ...collider2DFields(),
1026
+ size: vec2({
1027
+ x: 1,
1028
+ y: 1
1029
+ })
1030
+ });
1031
+ }
1032
+ /**
1033
+ * Builds the `CircleCollider2D` schema.
1034
+ *
1035
+ * @returns The schema.
1036
+ */
1037
+ function circleSchema() {
1038
+ return defineSchema({
1039
+ ...collider2DFields(),
1040
+ radius: f32(.5, { min: 0 })
1041
+ });
1042
+ }
1043
+ /**
1044
+ * Builds the `CapsuleCollider2D` schema.
1045
+ *
1046
+ * @returns The schema.
1047
+ */
1048
+ function capsuleSchema() {
1049
+ return defineSchema({
1050
+ ...collider2DFields(),
1051
+ radius: f32(.25, { min: 0 }),
1052
+ height: f32(1, { min: 0 }),
1053
+ direction: enumOf(CAPSULE_DIRECTIONS_2D, "y")
1054
+ });
1055
+ }
1056
+ /**
1057
+ * Builds the schema the two point-list colliders share.
1058
+ *
1059
+ * @returns The schema.
1060
+ */
1061
+ function pointsSchema() {
1062
+ return defineSchema({
1063
+ ...collider2DFields(),
1064
+ points: array(vec2())
1065
+ });
1066
+ }
1067
+ /**
1068
+ * The absolute value of a scale component; a negative scale mirrors geometry, so the shape uses the
1069
+ * magnitude.
1070
+ *
1071
+ * @param value - The scale component.
1072
+ * @returns Its magnitude.
1073
+ */
1074
+ function abs(value) {
1075
+ return Math.abs(value);
1076
+ }
1077
+ /**
1078
+ * Scales a local offset into shape space.
1079
+ *
1080
+ * @param offset - The authored offset.
1081
+ * @param scale - The lossy 2D scale.
1082
+ * @returns A fresh vector; shape construction is not a per-frame path.
1083
+ */
1084
+ function scaled(offset, scale) {
1085
+ return {
1086
+ x: offset.x * scale.x,
1087
+ y: offset.y * scale.y
1088
+ };
1089
+ }
1090
+ /**
1091
+ * Packs a point list into the interleaved `Float32Array` Rapier's shape constructors take.
1092
+ *
1093
+ * @param points - The authored points.
1094
+ * @param scale - The lossy 2D scale.
1095
+ * @returns A fresh buffer.
1096
+ */
1097
+ function packPoints(points, scale) {
1098
+ const packed = new Float32Array(points.length * 2);
1099
+ for (let index = 0; index < points.length; index += 1) {
1100
+ const point = points[index];
1101
+ if (point === void 0) continue;
1102
+ packed[index * 2] = point.x * scale.x;
1103
+ packed[index * 2 + 1] = point.y * scale.y;
1104
+ }
1105
+ return packed;
1106
+ }
1107
+ /**
1108
+ * Builds the "that geometry is degenerate" failure.
1109
+ *
1110
+ * @param entity - The entity name.
1111
+ * @param component - Which collider complained.
1112
+ * @param requirement - What it needed.
1113
+ * @returns The error to throw.
1114
+ */
1115
+ function degenerate(entity, component, requirement) {
1116
+ return physics2DError(Physics2DErrorCode.colliderGeometryInvalid, `${component} on ${entity} needs ${requirement}.`, {
1117
+ context: {
1118
+ entity,
1119
+ component
1120
+ },
1121
+ hint: `Give the collider ${requirement}.`
1122
+ });
1123
+ }
1124
+ //#endregion
1125
+ //#region src/components/tilemap-collider.ts
1126
+ /**
1127
+ * `TilemapCollider2D` (`docs/architecture/11-2d-toolkit.md` §2.5, §8): the merged collision surface
1128
+ * of a `Tilemap`, turned into Rapier geometry.
1129
+ *
1130
+ * ## How it reaches the data
1131
+ *
1132
+ * `@ignifx/2d` is an **optional** peer of `@ignifx/physics-2d` (a physics-only 2D game needs no
1133
+ * sprite toolkit), so this component never imports the toolkit as a value — only the
1134
+ * `TilemapCollisionData` type. A game, or `@ignifx/2d`'s own tilemap loader, assigns
1135
+ * {@link TilemapCollider2D.collisionData}; the runtime compares the data's `version` at the start of
1136
+ * every fixed step and rebuilds the shapes when it changes, which is the same moment
1137
+ * `Tilemap.onCollisionChanged` fires and needs no signal subscription across the package boundary.
1138
+ *
1139
+ * ## Why closed polylines rather than polygons
1140
+ *
1141
+ * A merged run of solid tiles is not convex in general — an L of tiles merges into an L — and
1142
+ * Rapier's polygon shape is a **convex hull** (`geometry/shape.d.ts`, `ConvexPolygon`), which would
1143
+ * silently fill the notch. A closed `polyline` reproduces the outline exactly and is the shape
1144
+ * static level geometry wants; the trade-off is that a polyline is infinitely thin, so a body that
1145
+ * starts inside the tilemap is not pushed out.
1146
+ */
1147
+ /**
1148
+ * The collision surface of a tilemap, as one static body's worth of Rapier shapes.
1149
+ *
1150
+ * @example
1151
+ * ```ts
1152
+ * const map = world.createEntity("Map");
1153
+ * const collider = map.addComponent(TilemapCollider2D);
1154
+ * collider.collisionData = tilemap.collisionData;
1155
+ * ```
1156
+ *
1157
+ * @public
1158
+ */
1159
+ var TilemapCollider2D = class TilemapCollider2D extends Collider2D {
1160
+ /** The namespaced registration id. */
1161
+ static typeId = "ignifx/TilemapCollider2D";
1162
+ /** The serialized field declarations; the geometry itself comes from the tilemap asset. */
1163
+ static schema = tilemapSchema();
1164
+ /** The merged chunk geometry, or `null` until a tilemap supplies it. */
1165
+ #data = null;
1166
+ /** The `version` of the data the current shapes were built from. */
1167
+ #builtVersion = -1;
1168
+ /** Applies the shared defaults. */
1169
+ constructor() {
1170
+ super();
1171
+ Object.assign(this, createDefaults(TilemapCollider2D.schema));
1172
+ }
1173
+ /**
1174
+ * The merged chunk geometry this collider builds shapes from.
1175
+ *
1176
+ * @returns The data, or `null` when none has been supplied.
1177
+ */
1178
+ get collisionData() {
1179
+ return this.#data;
1180
+ }
1181
+ /**
1182
+ * Replaces the tilemap geometry and schedules a rebuild.
1183
+ *
1184
+ * @param value - The merged chunk geometry, or `null` to drop the shapes.
1185
+ */
1186
+ set collisionData(value) {
1187
+ this.#data = value;
1188
+ this.#builtVersion = -1;
1189
+ this.rebuild();
1190
+ }
1191
+ /**
1192
+ * Whether the supplied data has changed since the shapes were built. The runtime polls this once
1193
+ * per fixed step, which is how a tile edit reaches physics without a cross-package subscription.
1194
+ *
1195
+ * @returns `true` when a rebuild is due.
1196
+ *
1197
+ * @internal
1198
+ */
1199
+ needsRebuild() {
1200
+ return (this.#data?.version ?? -1) !== this.#builtVersion;
1201
+ }
1202
+ /**
1203
+ * Appends one shape per merged outline and one per one-way edge.
1204
+ *
1205
+ * @param scale - The entity's lossy 2D scale.
1206
+ * @param out - The list to append to.
1207
+ *
1208
+ * @internal
1209
+ */
1210
+ collectShapes(scale, out) {
1211
+ const data = this.#data;
1212
+ this.#builtVersion = data?.version ?? -1;
1213
+ if (data === null) return;
1214
+ const offset = {
1215
+ x: this.offset.x * scale.x,
1216
+ y: this.offset.y * scale.y
1217
+ };
1218
+ for (const chunk of data.chunks) {
1219
+ for (const polygon of chunk.polygons) {
1220
+ if (polygon.length < 3) continue;
1221
+ out.push({
1222
+ shape: {
1223
+ kind: "polyline",
1224
+ points: closedLoop(polygon, scale)
1225
+ },
1226
+ offset,
1227
+ rotation: 0,
1228
+ oneWay: false
1229
+ });
1230
+ }
1231
+ for (const edge of chunk.oneWayEdges) out.push({
1232
+ shape: {
1233
+ kind: "polyline",
1234
+ points: segment(edge[0], edge[1], scale)
1235
+ },
1236
+ offset,
1237
+ rotation: 0,
1238
+ oneWay: true
1239
+ });
1240
+ }
1241
+ }
1242
+ };
1243
+ /**
1244
+ * Builds the `TilemapCollider2D` schema.
1245
+ *
1246
+ * @returns The schema.
1247
+ */
1248
+ function tilemapSchema() {
1249
+ return defineSchema({ ...collider2DFields() });
1250
+ }
1251
+ /**
1252
+ * Packs an outline as a closed chain of vertices.
1253
+ *
1254
+ * @param points - The outline, wound counter-clockwise.
1255
+ * @param scale - The entity's lossy 2D scale.
1256
+ * @returns A fresh buffer whose last vertex repeats the first.
1257
+ */
1258
+ function closedLoop(points, scale) {
1259
+ const packed = new Float32Array((points.length + 1) * 2);
1260
+ for (let index = 0; index < points.length; index += 1) {
1261
+ const point = points[index];
1262
+ if (point === void 0) continue;
1263
+ packed[index * 2] = point.x * scale.x;
1264
+ packed[index * 2 + 1] = point.y * scale.y;
1265
+ }
1266
+ packed[points.length * 2] = packed[0] ?? 0;
1267
+ packed[points.length * 2 + 1] = packed[1] ?? 0;
1268
+ return packed;
1269
+ }
1270
+ /**
1271
+ * Packs one one-way edge as a two-vertex chain.
1272
+ *
1273
+ * @param from - The edge's start.
1274
+ * @param to - The edge's end.
1275
+ * @param scale - The entity's lossy 2D scale.
1276
+ * @returns A fresh buffer.
1277
+ */
1278
+ function segment(from, to, scale) {
1279
+ return new Float32Array([
1280
+ from.x * scale.x,
1281
+ from.y * scale.y,
1282
+ to.x * scale.x,
1283
+ to.y * scale.y
1284
+ ]);
1285
+ }
1286
+ //#endregion
1287
+ //#region src/lite/rapier/module.ts
1288
+ /**
1289
+ * The one place `@dimforge/rapier2d-compat`'s WebAssembly is instantiated
1290
+ * (`docs/standards/coding-standards.md` §4, `CONSTITUTION.md` §3.4). Rapier is not Babylon Lite,
1291
+ * but it is a native/WASM backend under the same adapter rule, and the rule's allowed directory is
1292
+ * `src/lite/**` — hence `src/lite/rapier/`.
1293
+ *
1294
+ * ## Why `-compat`
1295
+ *
1296
+ * The `-compat` build inlines the WebAssembly module as base64 inside `rapier.mjs`, so it needs no
1297
+ * asset serving and works unchanged in Node and in the browser (spike S6.2, ADR-0006 Validation).
1298
+ * `init()` is what decodes and instantiates it; every other Rapier call before that throws.
1299
+ *
1300
+ * The import is static: `world.ts` and `service.ts` also import Rapier's classes statically, so a
1301
+ * dynamic import here alone would not move the 2 MB module out of an app's entry chunk (measured in
1302
+ * Phase 6). Splitting it needs the adapter to read every class off the loaded module instead, which
1303
+ * is the Phase 12 bundle work.
1304
+ *
1305
+ * @internal
1306
+ */
1307
+ /**
1308
+ * Instantiates Rapier's WebAssembly module.
1309
+ *
1310
+ * @remarks
1311
+ * Rapier's own `init()` is idempotent and caches the instantiated module in the package's module
1312
+ * scope, so the second and later apps in one process pay nothing.
1313
+ *
1314
+ * @returns A promise that settles once Rapier is usable.
1315
+ *
1316
+ * @internal
1317
+ */
1318
+ async function loadRapier() {
1319
+ await init();
1320
+ }
1321
+ //#endregion
1322
+ //#region src/lite/rapier/world.ts
1323
+ /**
1324
+ * How a body moves, in the three flavours ignifx exposes.
1325
+ *
1326
+ * @internal
1327
+ */
1328
+ const BodyMotion2D = {
1329
+ /** Simulated by the solver. */
1330
+ dynamic: "dynamic",
1331
+ /** Moved by the game, pushes dynamic bodies, ignores forces. */
1332
+ kinematic: "kinematic",
1333
+ /** Never moves. */
1334
+ static: "static"
1335
+ };
1336
+ /**
1337
+ * How two surfaces' friction or restitution coefficients are combined, mirroring Rapier's
1338
+ * `CoefficientCombineRule` (`dynamics/coefficient_combine_rule.d.ts`).
1339
+ *
1340
+ * @internal
1341
+ */
1342
+ const CombineRule2D = {
1343
+ /** The arithmetic mean; Rapier's default. */
1344
+ average: "average",
1345
+ /** The smaller of the two. */
1346
+ min: "min",
1347
+ /** The product. */
1348
+ multiply: "multiply",
1349
+ /** The larger of the two. */
1350
+ max: "max"
1351
+ };
1352
+ /** Radians per full turn, for the degree conversions the components use. */
1353
+ const TAU = Math.PI * 2;
1354
+ /** How wide one half of an `InteractionGroups` word is. */
1355
+ const GROUP_BITS = 16;
1356
+ /** Every bit of one half. */
1357
+ const GROUP_MASK = 65535;
1358
+ /**
1359
+ * Packs a membership mask and a collide mask into Rapier's single `InteractionGroups` word.
1360
+ *
1361
+ * @param membership - Which layers the collider belongs to, in the low 16 bits.
1362
+ * @param filter - Which layers it may collide with, in the low 16 bits.
1363
+ * @returns The packed word.
1364
+ *
1365
+ * @internal
1366
+ */
1367
+ function interactionGroups(membership, filter) {
1368
+ return (membership & GROUP_MASK) << GROUP_BITS | filter & GROUP_MASK;
1369
+ }
1370
+ /**
1371
+ * Creates the physics world.
1372
+ *
1373
+ * @param gravity - The acceleration in metres per second squared.
1374
+ * @returns The world.
1375
+ *
1376
+ * @internal
1377
+ */
1378
+ function createWorld(gravity) {
1379
+ return new World({
1380
+ x: gravity.x,
1381
+ y: gravity.y
1382
+ });
1383
+ }
1384
+ /**
1385
+ * Releases the world and every WebAssembly object it owns.
1386
+ *
1387
+ * @param world - The world.
1388
+ *
1389
+ * @internal
1390
+ */
1391
+ function destroyWorld(world) {
1392
+ world.free();
1393
+ }
1394
+ /**
1395
+ * Replaces world gravity.
1396
+ *
1397
+ * @param world - The world.
1398
+ * @param gravity - The new acceleration.
1399
+ *
1400
+ * @internal
1401
+ */
1402
+ function setGravity(world, gravity) {
1403
+ world.gravity.x = gravity.x;
1404
+ world.gravity.y = gravity.y;
1405
+ }
1406
+ /**
1407
+ * Sets the length of the next step.
1408
+ *
1409
+ * @param world - The world.
1410
+ * @param seconds - The fixed step in seconds.
1411
+ *
1412
+ * @internal
1413
+ */
1414
+ function setTimestep(world, seconds) {
1415
+ world.timestep = seconds;
1416
+ }
1417
+ /**
1418
+ * Sets how many iterations the constraint solver runs.
1419
+ *
1420
+ * @param world - The world.
1421
+ * @param iterations - The iteration count; Rapier's default is 4.
1422
+ *
1423
+ * @internal
1424
+ */
1425
+ function setSolverIterations(world, iterations) {
1426
+ world.numSolverIterations = iterations;
1427
+ }
1428
+ /**
1429
+ * Advances the simulation by one timestep, collecting events.
1430
+ *
1431
+ * @param world - The world.
1432
+ * @param queue - The event collector.
1433
+ *
1434
+ * @internal
1435
+ */
1436
+ function stepWorld(world, queue) {
1437
+ world.step(queue);
1438
+ }
1439
+ /**
1440
+ * Rebuilds the broadphase without advancing time, by running a zero-length step.
1441
+ *
1442
+ * @remarks
1443
+ * Rapier only refreshes its acceleration structure inside `World.step`, so a collider created since
1444
+ * the last step is invisible to queries and to the character controller (S6.2). The queue is passed
1445
+ * so an enter event raised by the priming step is still delivered.
1446
+ *
1447
+ * @param world - The world.
1448
+ * @param queue - The event collector.
1449
+ *
1450
+ * @internal
1451
+ */
1452
+ function primeWorld(world, queue) {
1453
+ const timestep = world.timestep;
1454
+ world.timestep = 0;
1455
+ world.step(queue);
1456
+ world.timestep = timestep;
1457
+ }
1458
+ /**
1459
+ * Creates the event collector.
1460
+ *
1461
+ * @remarks
1462
+ * `autoDrain: false` — the collector must survive both the priming step and the real step of one
1463
+ * fixed update, and the runtime drains it once at the end of that update.
1464
+ *
1465
+ * @returns The queue.
1466
+ *
1467
+ * @internal
1468
+ */
1469
+ function createEventQueue() {
1470
+ return new EventQueue(false);
1471
+ }
1472
+ /**
1473
+ * Releases the event collector.
1474
+ *
1475
+ * @param queue - The queue.
1476
+ *
1477
+ * @internal
1478
+ */
1479
+ function destroyEventQueue(queue) {
1480
+ queue.free();
1481
+ }
1482
+ /**
1483
+ * Hands every buffered collision and intersection event to a sink and clears the buffer.
1484
+ *
1485
+ * @param queue - The queue.
1486
+ * @param sink - Called once per event with the two collider handles and the enter/exit flag.
1487
+ *
1488
+ * @internal
1489
+ */
1490
+ function drainCollisions(queue, sink) {
1491
+ queue.drainCollisionEvents((first, second, started) => {
1492
+ sink({
1493
+ first,
1494
+ second,
1495
+ started
1496
+ });
1497
+ });
1498
+ }
1499
+ /**
1500
+ * Creates a rigid body.
1501
+ *
1502
+ * @param world - The world.
1503
+ * @param options - Motion, pose, damping, gravity scale, and mass.
1504
+ * @returns The body.
1505
+ *
1506
+ * @internal
1507
+ */
1508
+ function createBody(world, options) {
1509
+ const description = descriptionFor(options.motion).setTranslation(options.position.x, options.position.y).setRotation(options.rotation).setGravityScale(options.gravityScale).setLinearDamping(options.linearDamping).setAngularDamping(options.angularDamping).setSleeping(options.startAsleep);
1510
+ if (options.freezeRotation) description.lockRotations();
1511
+ if (options.additionalMass > 0) description.setAdditionalMass(options.additionalMass);
1512
+ return world.createRigidBody(description);
1513
+ }
1514
+ /**
1515
+ * Removes a body and every collider attached to it.
1516
+ *
1517
+ * @param world - The world.
1518
+ * @param body - The body.
1519
+ *
1520
+ * @internal
1521
+ */
1522
+ function destroyBody(world, body) {
1523
+ world.removeRigidBody(body);
1524
+ }
1525
+ /**
1526
+ * Reads a body's world pose without allocating.
1527
+ *
1528
+ * @param body - The body.
1529
+ * @param out - The vector the translation is written into.
1530
+ * @returns The rotation in radians.
1531
+ *
1532
+ * @internal
1533
+ */
1534
+ function readBodyPose(body, out) {
1535
+ body.translation(out);
1536
+ return body.rotation();
1537
+ }
1538
+ /**
1539
+ * Moves a body immediately, as a teleport.
1540
+ *
1541
+ * @param body - The body.
1542
+ * @param position - The new world position.
1543
+ * @param rotation - The new rotation in radians.
1544
+ *
1545
+ * @internal
1546
+ */
1547
+ function setBodyPose(body, position, rotation) {
1548
+ body.setTranslation({
1549
+ x: position.x,
1550
+ y: position.y
1551
+ }, true);
1552
+ body.setRotation(rotation, true);
1553
+ }
1554
+ /**
1555
+ * Asks Rapier to interpolate a kinematic body to a pose over the next step, which is what makes it
1556
+ * push dynamic bodies instead of tunnelling through them.
1557
+ *
1558
+ * @param body - The body.
1559
+ * @param position - The pose to reach.
1560
+ * @param rotation - The rotation to reach, in radians.
1561
+ *
1562
+ * @internal
1563
+ */
1564
+ function setNextKinematicPose(body, position, rotation) {
1565
+ body.setNextKinematicTranslation({
1566
+ x: position.x,
1567
+ y: position.y
1568
+ });
1569
+ body.setNextKinematicRotation(rotation);
1570
+ }
1571
+ /**
1572
+ * Reads a body's linear velocity without allocating.
1573
+ *
1574
+ * @param body - The body.
1575
+ * @param out - The vector to write.
1576
+ *
1577
+ * @internal
1578
+ */
1579
+ function readLinearVelocity(body, out) {
1580
+ body.linvel(out);
1581
+ }
1582
+ /**
1583
+ * Replaces a body's linear velocity.
1584
+ *
1585
+ * @param body - The body.
1586
+ * @param velocity - Metres per second.
1587
+ *
1588
+ * @internal
1589
+ */
1590
+ function writeLinearVelocity(body, velocity) {
1591
+ body.setLinvel({
1592
+ x: velocity.x,
1593
+ y: velocity.y
1594
+ }, true);
1595
+ }
1596
+ /**
1597
+ * Reads a body's angular velocity.
1598
+ *
1599
+ * @param body - The body.
1600
+ * @returns Radians per second, counter-clockwise.
1601
+ *
1602
+ * @internal
1603
+ */
1604
+ function readAngularVelocity(body) {
1605
+ return body.angvel();
1606
+ }
1607
+ /**
1608
+ * Replaces a body's angular velocity.
1609
+ *
1610
+ * @param body - The body.
1611
+ * @param velocity - Radians per second.
1612
+ *
1613
+ * @internal
1614
+ */
1615
+ function writeAngularVelocity(body, velocity) {
1616
+ body.setAngvel(velocity, true);
1617
+ }
1618
+ /**
1619
+ * Adds a force that lasts for one step.
1620
+ *
1621
+ * @param body - The body.
1622
+ * @param force - Newtons.
1623
+ * @param point - Where to apply it, or `null` for the centre of mass.
1624
+ *
1625
+ * @internal
1626
+ */
1627
+ function addForce(body, force, point) {
1628
+ if (point === null) {
1629
+ body.addForce({
1630
+ x: force.x,
1631
+ y: force.y
1632
+ }, true);
1633
+ return;
1634
+ }
1635
+ body.addForceAtPoint({
1636
+ x: force.x,
1637
+ y: force.y
1638
+ }, {
1639
+ x: point.x,
1640
+ y: point.y
1641
+ }, true);
1642
+ }
1643
+ /**
1644
+ * Applies an instantaneous impulse.
1645
+ *
1646
+ * @param body - The body.
1647
+ * @param impulse - Newton-seconds.
1648
+ * @param point - Where to apply it, or `null` for the centre of mass.
1649
+ *
1650
+ * @internal
1651
+ */
1652
+ function applyImpulse(body, impulse, point) {
1653
+ if (point === null) {
1654
+ body.applyImpulse({
1655
+ x: impulse.x,
1656
+ y: impulse.y
1657
+ }, true);
1658
+ return;
1659
+ }
1660
+ body.applyImpulseAtPoint({
1661
+ x: impulse.x,
1662
+ y: impulse.y
1663
+ }, {
1664
+ x: point.x,
1665
+ y: point.y
1666
+ }, true);
1667
+ }
1668
+ /**
1669
+ * Adds a torque that lasts for one step.
1670
+ *
1671
+ * @param body - The body.
1672
+ * @param torque - Newton-metres, positive counter-clockwise.
1673
+ *
1674
+ * @internal
1675
+ */
1676
+ function addTorque(body, torque) {
1677
+ body.addTorque(torque, true);
1678
+ }
1679
+ /**
1680
+ * The mass Rapier computed for a body from its colliders and any additional mass.
1681
+ *
1682
+ * @param body - The body.
1683
+ * @returns Kilograms.
1684
+ *
1685
+ * @internal
1686
+ */
1687
+ function bodyMass(body) {
1688
+ return body.mass();
1689
+ }
1690
+ /**
1691
+ * Sets how many kilograms a square metre of a collider weighs. Rapier derives the body's mass and
1692
+ * angular inertia from this and the collider's area.
1693
+ *
1694
+ * @param collider - The collider.
1695
+ * @param density - Kilograms per square metre.
1696
+ *
1697
+ * @internal
1698
+ */
1699
+ function setColliderDensity(collider, density) {
1700
+ collider.setDensity(density);
1701
+ }
1702
+ /**
1703
+ * Recomputes a body's mass and inertia from its colliders, after their densities changed.
1704
+ *
1705
+ * @param body - The body.
1706
+ *
1707
+ * @internal
1708
+ */
1709
+ function recomputeMass(body) {
1710
+ body.recomputeMassPropertiesFromColliders();
1711
+ }
1712
+ /**
1713
+ * Creates a collider and attaches it to a body.
1714
+ *
1715
+ * @param world - The world.
1716
+ * @param body - The body it belongs to.
1717
+ * @param shape - The geometry, in body-local metres.
1718
+ * @param options - Offset, sensor flag, filter word, surface, and event flags.
1719
+ * @returns The collider, or `null` when Rapier refuses the geometry — a convex hull of collinear or
1720
+ * duplicated points, say, which throws inside its WebAssembly rather than returning nothing.
1721
+ *
1722
+ * @internal
1723
+ */
1724
+ function createCollider(world, body, shape, options) {
1725
+ const description = describeShape(shape);
1726
+ if (description === null) return null;
1727
+ description.setTranslation(options.offset.x, options.offset.y).setRotation(options.rotation).setSensor(options.sensor).setCollisionGroups(options.groups).setFriction(options.friction).setRestitution(options.restitution).setFrictionCombineRule(combineRule(options.frictionCombine)).setRestitutionCombineRule(combineRule(options.restitutionCombine));
1728
+ if (options.events) description.setActiveEvents(ActiveEvents.COLLISION_EVENTS).setActiveCollisionTypes(ActiveCollisionTypes.ALL);
1729
+ try {
1730
+ return world.createCollider(description, body);
1731
+ } catch {
1732
+ return null;
1733
+ }
1734
+ }
1735
+ /**
1736
+ * Turns a collider's collision and intersection events on or off without rebuilding it.
1737
+ *
1738
+ * @param collider - The collider.
1739
+ * @param enabled - Whether it reports events.
1740
+ *
1741
+ * @internal
1742
+ */
1743
+ function setColliderEvents(collider, enabled) {
1744
+ collider.setActiveEvents(enabled ? ActiveEvents.COLLISION_EVENTS : ActiveEvents.NONE);
1745
+ collider.setActiveCollisionTypes(enabled ? ActiveCollisionTypes.ALL : ActiveCollisionTypes.DEFAULT);
1746
+ }
1747
+ /**
1748
+ * Reads a collider's world position without allocating.
1749
+ *
1750
+ * @param collider - The collider.
1751
+ * @param out - The vector to write.
1752
+ *
1753
+ * @internal
1754
+ */
1755
+ function readColliderTranslation(collider, out) {
1756
+ collider.translation(out);
1757
+ }
1758
+ /**
1759
+ * The stable integer Rapier identifies a collider by, which is what its event queue reports.
1760
+ *
1761
+ * @param collider - The collider.
1762
+ * @returns The handle.
1763
+ *
1764
+ * @internal
1765
+ */
1766
+ function colliderHandle(collider) {
1767
+ return collider.handle;
1768
+ }
1769
+ /**
1770
+ * Whether a collider is a sensor, which is how a trigger event is told from a contact.
1771
+ *
1772
+ * @param collider - The collider.
1773
+ * @returns `true` for a sensor.
1774
+ *
1775
+ * @internal
1776
+ */
1777
+ function isColliderSensor(collider) {
1778
+ return collider.isSensor();
1779
+ }
1780
+ /**
1781
+ * Reads the first contact of a collider pair.
1782
+ *
1783
+ * @param world - The world.
1784
+ * @param first - One collider.
1785
+ * @param second - The other.
1786
+ * @param out - The record to write.
1787
+ *
1788
+ * @internal
1789
+ */
1790
+ function readContact(world, first, second, out) {
1791
+ out.touching = false;
1792
+ out.impulse = 0;
1793
+ world.contactPair(first, second, (manifold, flipped) => {
1794
+ const normal = manifold.normal();
1795
+ const sign = flipped ? -1 : 1;
1796
+ out.normal.set(normal.x * sign, normal.y * sign);
1797
+ const point = manifold.numSolverContacts() > 0 ? manifold.solverContactPoint(0) : null;
1798
+ if (point !== null) out.point.set(point.x, point.y);
1799
+ out.impulse = manifold.numContacts() > 0 ? manifold.contactImpulse(0) : 0;
1800
+ out.touching = true;
1801
+ });
1802
+ }
1803
+ /**
1804
+ * Casts a ray and writes the nearest hit.
1805
+ *
1806
+ * @param world - The world.
1807
+ * @param origin - The world-space origin.
1808
+ * @param direction - A unit direction.
1809
+ * @param maxDistance - How far to travel.
1810
+ * @param groups - The packed filter word.
1811
+ * @param hitSensors - Whether sensors count.
1812
+ * @param outPoint - Receives the contact point.
1813
+ * @param outNormal - Receives the contact normal.
1814
+ * @param out - Receives the hit flag, collider, and distance.
1815
+ *
1816
+ * @internal
1817
+ */
1818
+ function castRay(world, origin, direction, maxDistance, groups, hitSensors, outPoint, outNormal, out) {
1819
+ out.hit = false;
1820
+ out.collider = null;
1821
+ out.distance = 0;
1822
+ const ray = new Ray({
1823
+ x: origin.x,
1824
+ y: origin.y
1825
+ }, {
1826
+ x: direction.x,
1827
+ y: direction.y
1828
+ });
1829
+ const hit = world.castRayAndGetNormal(ray, maxDistance, true, sensorFlags(hitSensors), groups);
1830
+ if (hit === null) return;
1831
+ out.hit = true;
1832
+ out.collider = hit.collider;
1833
+ out.distance = hit.timeOfImpact;
1834
+ outPoint.set(origin.x + direction.x * hit.timeOfImpact, origin.y + direction.y * hit.timeOfImpact);
1835
+ outNormal.set(hit.normal.x, hit.normal.y);
1836
+ }
1837
+ /**
1838
+ * Casts a ray and hands every hit to a sink, nearest first is **not** guaranteed — the caller sorts.
1839
+ *
1840
+ * @param world - The world.
1841
+ * @param origin - The world-space origin.
1842
+ * @param direction - A unit direction.
1843
+ * @param maxDistance - How far to travel.
1844
+ * @param groups - The packed filter word.
1845
+ * @param hitSensors - Whether sensors count.
1846
+ * @param sink - Called once per hit with the collider, distance, and normal.
1847
+ *
1848
+ * @internal
1849
+ */
1850
+ function castRayAll(world, origin, direction, maxDistance, groups, hitSensors, sink) {
1851
+ const ray = new Ray({
1852
+ x: origin.x,
1853
+ y: origin.y
1854
+ }, {
1855
+ x: direction.x,
1856
+ y: direction.y
1857
+ });
1858
+ world.intersectionsWithRay(ray, maxDistance, true, (intersection) => {
1859
+ sink(intersection.collider, intersection.timeOfImpact, intersection.normal.x, intersection.normal.y);
1860
+ return true;
1861
+ }, sensorFlags(hitSensors), groups);
1862
+ }
1863
+ /**
1864
+ * Lists every collider a positioned shape overlaps.
1865
+ *
1866
+ * @param world - The world.
1867
+ * @param shape - The query geometry.
1868
+ * @param position - Its world position.
1869
+ * @param rotation - Its world rotation in radians.
1870
+ * @param groups - The packed filter word.
1871
+ * @param hitSensors - Whether sensors count.
1872
+ * @param sink - Called once per overlapping collider.
1873
+ *
1874
+ * @internal
1875
+ */
1876
+ function overlapShape(world, shape, position, rotation, groups, hitSensors, sink) {
1877
+ const built = buildShape(shape);
1878
+ if (built === null) return;
1879
+ world.intersectionsWithShape({
1880
+ x: position.x,
1881
+ y: position.y
1882
+ }, rotation, built, (collider) => {
1883
+ sink(collider);
1884
+ return true;
1885
+ }, sensorFlags(hitSensors), groups);
1886
+ }
1887
+ /**
1888
+ * Sweeps a shape and writes the first contact.
1889
+ *
1890
+ * @param world - The world.
1891
+ * @param shape - The query geometry.
1892
+ * @param position - Where the sweep starts.
1893
+ * @param rotation - The shape's rotation in radians.
1894
+ * @param direction - A unit sweep direction.
1895
+ * @param maxDistance - How far to sweep.
1896
+ * @param groups - The packed filter word.
1897
+ * @param hitSensors - Whether sensors count.
1898
+ * @param outPoint - Receives the contact point.
1899
+ * @param outNormal - Receives the contact normal.
1900
+ * @param out - Receives the hit flag, collider, and distance.
1901
+ *
1902
+ * @internal
1903
+ */
1904
+ function castShape(world, shape, position, rotation, direction, maxDistance, groups, hitSensors, outPoint, outNormal, out) {
1905
+ out.hit = false;
1906
+ out.collider = null;
1907
+ out.distance = 0;
1908
+ const built = buildShape(shape);
1909
+ if (built === null) return;
1910
+ const hit = world.castShape({
1911
+ x: position.x,
1912
+ y: position.y
1913
+ }, rotation, {
1914
+ x: direction.x,
1915
+ y: direction.y
1916
+ }, built, 0, maxDistance, true, sensorFlags(hitSensors), groups);
1917
+ if (hit === null) return;
1918
+ out.hit = true;
1919
+ out.collider = hit.collider;
1920
+ out.distance = hit.time_of_impact;
1921
+ const cos = Math.cos(rotation);
1922
+ const sin = Math.sin(rotation);
1923
+ const originX = position.x + direction.x * hit.time_of_impact;
1924
+ const originY = position.y + direction.y * hit.time_of_impact;
1925
+ outPoint.set(originX + hit.witness1.x * cos - hit.witness1.y * sin, originY + hit.witness1.x * sin + hit.witness1.y * cos);
1926
+ outNormal.set(hit.normal1.x * cos - hit.normal1.y * sin, hit.normal1.x * sin + hit.normal1.y * cos);
1927
+ }
1928
+ /**
1929
+ * Turns degrees counter-clockwise into the radians Rapier stores.
1930
+ *
1931
+ * @param degrees - The angle in degrees.
1932
+ * @returns The angle in radians.
1933
+ *
1934
+ * @internal
1935
+ */
1936
+ function toRadians(degrees) {
1937
+ return degrees * TAU / 360;
1938
+ }
1939
+ /**
1940
+ * Turns Rapier's radians into the degrees `Transform.rotation2D` uses.
1941
+ *
1942
+ * @param radians - The angle in radians.
1943
+ * @returns The angle in degrees.
1944
+ *
1945
+ * @internal
1946
+ */
1947
+ function toDegrees(radians) {
1948
+ return radians * 360 / TAU;
1949
+ }
1950
+ /**
1951
+ * Builds the Rapier `Shape` a description stands for, for the query paths.
1952
+ *
1953
+ * @param shape - The description.
1954
+ * @returns The shape, or `null` when the geometry is degenerate.
1955
+ */
1956
+ function buildShape(shape) {
1957
+ if (shape.kind === "box") return new Cuboid(shape.halfWidth, shape.halfHeight);
1958
+ if (shape.kind === "circle") return new Ball(shape.radius);
1959
+ if (shape.kind === "capsule") return new Capsule(shape.halfHeight, shape.radius);
1960
+ if (shape.kind === "polyline") return new Polyline(shape.points);
1961
+ return new ConvexPolygon(shape.points, false);
1962
+ }
1963
+ /**
1964
+ * Builds the collider description a shape stands for.
1965
+ *
1966
+ * @param shape - The description.
1967
+ * @returns The description, or `null` when Rapier refuses the geometry.
1968
+ */
1969
+ function describeShape(shape) {
1970
+ if (shape.kind === "box") return ColliderDesc.cuboid(shape.halfWidth, shape.halfHeight);
1971
+ if (shape.kind === "circle") return ColliderDesc.ball(shape.radius);
1972
+ if (shape.kind === "capsule") return ColliderDesc.capsule(shape.halfHeight, shape.radius);
1973
+ if (shape.kind === "polyline") return ColliderDesc.polyline(shape.points);
1974
+ return ColliderDesc.convexHull(shape.points);
1975
+ }
1976
+ /**
1977
+ * The body description a motion type starts from.
1978
+ *
1979
+ * @param motion - The motion type.
1980
+ * @returns A fresh description.
1981
+ */
1982
+ function descriptionFor(motion) {
1983
+ if (motion === BodyMotion2D.dynamic) return RigidBodyDesc.dynamic();
1984
+ return motion === BodyMotion2D.kinematic ? RigidBodyDesc.kinematicPositionBased() : RigidBodyDesc.fixed();
1985
+ }
1986
+ /**
1987
+ * Rapier's combine rule for one of ours.
1988
+ *
1989
+ * @param rule - The ignifx name.
1990
+ * @returns Rapier's value.
1991
+ */
1992
+ function combineRule(rule) {
1993
+ if (rule === CombineRule2D.min) return CoefficientCombineRule.Min;
1994
+ if (rule === CombineRule2D.multiply) return CoefficientCombineRule.Multiply;
1995
+ return rule === CombineRule2D.max ? CoefficientCombineRule.Max : CoefficientCombineRule.Average;
1996
+ }
1997
+ /**
1998
+ * The query filter flags that express "sensors count" or "sensors do not".
1999
+ *
2000
+ * @param hitSensors - Whether sensors count.
2001
+ * @returns The flags.
2002
+ */
2003
+ function sensorFlags(hitSensors) {
2004
+ return hitSensors ? void 0 : QueryFilterFlags.EXCLUDE_SENSORS;
2005
+ }
2006
+ //#endregion
2007
+ //#region src/runtime/host.ts
2008
+ /**
2009
+ * The one mutable cell between `register` and `onStart`.
2010
+ *
2011
+ * `Extension.register` runs **before** `createApp` builds the world
2012
+ * (`packages/core/src/app/app.ts`, `initialize`), and the Rapier world needs the world's layer
2013
+ * table and Rapier's WebAssembly module, which is loaded asynchronously. So the extension declares
2014
+ * its systems and its service in `register` — which is where `docs/architecture/04-extensions.md`
2015
+ * §1 says everything is declared — and they all read the runtime through this holder, which
2016
+ * `onStart` fills in.
2017
+ *
2018
+ * @internal
2019
+ */
2020
+ var Physics2DHost = class {
2021
+ /** The runtime, or `null` until `onStart` has built it. */
2022
+ runtime = null;
2023
+ };
2024
+ //#endregion
2025
+ //#region src/lite/rapier/character.ts
2026
+ /**
2027
+ * Creates a character controller.
2028
+ *
2029
+ * @param world - The world.
2030
+ * @param skinWidth - The gap Rapier keeps between the character and its surroundings, in metres.
2031
+ * @returns The controller.
2032
+ *
2033
+ * @internal
2034
+ */
2035
+ function createController(world, skinWidth) {
2036
+ const controller = world.createCharacterController(skinWidth);
2037
+ controller.setUp({
2038
+ x: 0,
2039
+ y: 1
2040
+ });
2041
+ return controller;
2042
+ }
2043
+ /**
2044
+ * Releases a controller.
2045
+ *
2046
+ * @param world - The world.
2047
+ * @param controller - The controller.
2048
+ *
2049
+ * @internal
2050
+ */
2051
+ function destroyController(world, controller) {
2052
+ world.removeCharacterController(controller);
2053
+ }
2054
+ /**
2055
+ * Applies the component's settings to a controller.
2056
+ *
2057
+ * @param controller - The controller.
2058
+ * @param tuning - The slope, step, snap, and pushing settings.
2059
+ *
2060
+ * @internal
2061
+ */
2062
+ function tuneController(controller, tuning) {
2063
+ controller.setMaxSlopeClimbAngle(tuning.slopeLimit);
2064
+ controller.setMinSlopeSlideAngle(tuning.slideLimit);
2065
+ controller.setApplyImpulsesToDynamicBodies(tuning.pushBodies);
2066
+ if (tuning.stepOffset > 0) controller.enableAutostep(tuning.stepOffset, tuning.stepMinWidth, true);
2067
+ else controller.disableAutostep();
2068
+ if (tuning.snapToGround > 0) controller.enableSnapToGround(tuning.snapToGround);
2069
+ else controller.disableSnapToGround();
2070
+ }
2071
+ /**
2072
+ * Runs one collide-and-slide move.
2073
+ *
2074
+ * @param controller - The controller.
2075
+ * @param collider - The character's own collider.
2076
+ * @param desired - The displacement to attempt, in metres.
2077
+ * @param groups - The packed filter word obstacles must pass.
2078
+ * @param filter - An extra per-collider predicate — how one-way platforms are done — or `null`.
2079
+ * @param out - Receives the movement Rapier resolved.
2080
+ * @returns `true` when the character ended the move on walkable ground.
2081
+ *
2082
+ * @internal
2083
+ */
2084
+ function computeControllerMovement(controller, collider, desired, groups, filter, out) {
2085
+ if (filter === null) controller.computeColliderMovement(collider, {
2086
+ x: desired.x,
2087
+ y: desired.y
2088
+ }, void 0, groups);
2089
+ else controller.computeColliderMovement(collider, {
2090
+ x: desired.x,
2091
+ y: desired.y
2092
+ }, void 0, groups, filter);
2093
+ controller.computedMovement(out);
2094
+ return controller.computedGrounded();
2095
+ }
2096
+ /**
2097
+ * How many obstacles the last move hit.
2098
+ *
2099
+ * @param controller - The controller.
2100
+ * @returns The count.
2101
+ *
2102
+ * @internal
2103
+ */
2104
+ function controllerHitCount(controller) {
2105
+ return controller.numComputedCollisions();
2106
+ }
2107
+ /**
2108
+ * Reads one obstacle of the last move.
2109
+ *
2110
+ * @param controller - The controller.
2111
+ * @param index - Which obstacle.
2112
+ * @param out - The record to write.
2113
+ * @returns `true` when the obstacle exists.
2114
+ *
2115
+ * @internal
2116
+ */
2117
+ function readControllerHit(controller, index, out) {
2118
+ const hit = controller.computedCollision(index);
2119
+ if (hit === null) return false;
2120
+ out.collider = hit.collider;
2121
+ out.point.set(hit.witness1.x, hit.witness1.y);
2122
+ out.normal.set(hit.normal1.x, hit.normal1.y);
2123
+ return true;
2124
+ }
2125
+ //#endregion
2126
+ //#region src/runtime/runtime.ts
2127
+ /**
2128
+ * The counters `09-physics.md` §9 and `11-2d-toolkit.md` §8 name for 2D.
2129
+ *
2130
+ * @public
2131
+ */
2132
+ const PHYSICS_2D_DIAGNOSTICS_COUNTERS = Object.freeze([
2133
+ "bodies",
2134
+ "colliders",
2135
+ "stepsThisFrame",
2136
+ "collisionEvents",
2137
+ "triggerEvents",
2138
+ "stepMs"
2139
+ ]);
2140
+ /**
2141
+ * The diagnostics group name.
2142
+ *
2143
+ * @public
2144
+ */
2145
+ const PHYSICS_2D_DIAGNOSTICS_GROUP = "physics2d";
2146
+ /** How far an implicit static body may move before the diagnostic considers it moved. */
2147
+ const STATIC_MOVE_EPSILON = 1e-6;
2148
+ /** How much clear floor Rapier requires past an autostep, in metres. */
2149
+ const AUTOSTEP_MIN_WIDTH = .05;
2150
+ /** Every layer bit Rapier can express. */
2151
+ const ALL_LAYER_BITS = 65535;
2152
+ /**
2153
+ * The per-app 2D physics runtime.
2154
+ *
2155
+ * @internal
2156
+ */
2157
+ var Physics2DRuntime = class {
2158
+ #ctx;
2159
+ #settings;
2160
+ #world;
2161
+ #counters;
2162
+ #queue;
2163
+ #records = [];
2164
+ #byEntity = /* @__PURE__ */ new Map();
2165
+ #byCollider = /* @__PURE__ */ new Map();
2166
+ #controllers = /* @__PURE__ */ new Map();
2167
+ #oneWay = /* @__PURE__ */ new Map();
2168
+ #dirty = /* @__PURE__ */ new Set();
2169
+ #collideMasks = (/* @__PURE__ */ new Int32Array(16)).fill(ALL_LAYER_BITS);
2170
+ #pairs = /* @__PURE__ */ new Map();
2171
+ #endedPairs = [];
2172
+ #startedPairs = [];
2173
+ #contact = {
2174
+ touching: false,
2175
+ point: new Vec2(),
2176
+ normal: new Vec2(),
2177
+ impulse: 0
2178
+ };
2179
+ #contactPoint = {
2180
+ point: new Vec2(),
2181
+ normal: new Vec2(),
2182
+ impulse: 0
2183
+ };
2184
+ #contacts = [];
2185
+ #controllerHit = {
2186
+ collider: null,
2187
+ point: new Vec2(),
2188
+ normal: new Vec2()
2189
+ };
2190
+ #trigger = null;
2191
+ #collision = null;
2192
+ #characterCollision = null;
2193
+ #scratchPose = new Vec3();
2194
+ #scratchVector = new Vec2();
2195
+ #relativeVelocity = new Vec2();
2196
+ #movement = new Vec2();
2197
+ #groundNormal = new Vec2();
2198
+ #filterScratch = new Vec2();
2199
+ /** The vertical component of the displacement the filtered move is attempting. */
2200
+ #filterDesiredY = 0;
2201
+ /** The bottom of the filtered character, in metres. */
2202
+ #filterFeet = 0;
2203
+ #colliderSetChanged = true;
2204
+ #stepped = false;
2205
+ #disposed = false;
2206
+ #stepsThisFrame = 0;
2207
+ #stepMs = 0;
2208
+ #collisionEventCount = 0;
2209
+ #triggerEventCount = 0;
2210
+ #colliderCount = 0;
2211
+ /**
2212
+ * Builds the runtime and applies the world settings.
2213
+ *
2214
+ * @param options - The context, settings, world, and counters.
2215
+ * @throws IgnifxError with code `IGX-1155` when the collision matrix names an unknown layer.
2216
+ */
2217
+ constructor(options) {
2218
+ this.#ctx = options.ctx;
2219
+ this.#settings = options.settings;
2220
+ this.#world = options.world;
2221
+ this.#counters = options.counters;
2222
+ this.#queue = createEventQueue();
2223
+ this.#contacts.push(this.#contactPoint);
2224
+ setGravity(this.#world, this.#settings.gravity);
2225
+ if (this.#settings.velocityIterations > 0) setSolverIterations(this.#world, this.#settings.velocityIterations);
2226
+ this.#buildCollideMasks();
2227
+ }
2228
+ /**
2229
+ * The Rapier world, for the query and escape-hatch paths.
2230
+ *
2231
+ * @returns The world handle.
2232
+ */
2233
+ get world() {
2234
+ return this.#world;
2235
+ }
2236
+ /**
2237
+ * Whether at least one fixed step has run, which is what Rapier's broadphase needs before a query
2238
+ * can answer (`09-physics.md` §5, spike S6.2).
2239
+ *
2240
+ * @returns `true` once a step has completed.
2241
+ */
2242
+ get hasStepped() {
2243
+ return this.#stepped;
2244
+ }
2245
+ /**
2246
+ * Every body, in creation order.
2247
+ *
2248
+ * @returns The records.
2249
+ */
2250
+ get records() {
2251
+ return this.#records;
2252
+ }
2253
+ /**
2254
+ * The resolved settings.
2255
+ *
2256
+ * @returns The `physics2d` section.
2257
+ */
2258
+ get settings() {
2259
+ return this.#settings;
2260
+ }
2261
+ /**
2262
+ * Replaces world gravity.
2263
+ *
2264
+ * @param gravity - The new acceleration vector.
2265
+ */
2266
+ setGravity(gravity) {
2267
+ setGravity(this.#world, gravity);
2268
+ }
2269
+ /**
2270
+ * Marks an entity's body for a rebuild at the start of the next fixed step.
2271
+ *
2272
+ * @param entity - The entity whose colliders, `Rigidbody2D`, or controller changed.
2273
+ */
2274
+ markDirty(entity) {
2275
+ if (!this.#disposed && !entity.isDestroyed) this.#dirty.add(entity);
2276
+ }
2277
+ /**
2278
+ * Marks a character controller's entity for a rebuild.
2279
+ *
2280
+ * @param component - The controller component.
2281
+ */
2282
+ markControllerDirty(component) {
2283
+ this.markDirty(component.entity);
2284
+ }
2285
+ /**
2286
+ * Drops a character controller's body, which is what detaching the component does.
2287
+ *
2288
+ * @param component - The controller component.
2289
+ */
2290
+ releaseController(component) {
2291
+ this.markDirty(component.entity);
2292
+ }
2293
+ /**
2294
+ * The Rapier body of an entity, for the escape hatch.
2295
+ *
2296
+ * @param entity - The entity.
2297
+ * @returns The body handle, or `null`.
2298
+ */
2299
+ bodyHandleOf(entity) {
2300
+ return this.#byEntity.get(entity)?.body ?? null;
2301
+ }
2302
+ /**
2303
+ * The record an entity owns.
2304
+ *
2305
+ * @param entity - The entity.
2306
+ * @returns The record, or `null`.
2307
+ */
2308
+ recordOf(entity) {
2309
+ return this.#byEntity.get(entity) ?? null;
2310
+ }
2311
+ /**
2312
+ * The entity and collider a Rapier collider belongs to, which is how a query result or an event
2313
+ * becomes an ignifx object.
2314
+ *
2315
+ * @param collider - The Rapier collider, or `null`.
2316
+ * @returns The binding, or `null`.
2317
+ */
2318
+ bindingOf(collider) {
2319
+ return collider === null ? null : this.#byCollider.get(colliderHandle(collider)) ?? null;
2320
+ }
2321
+ /**
2322
+ * The mass Rapier computed for an entity's body.
2323
+ *
2324
+ * @param entity - The entity.
2325
+ * @returns Kilograms, or `0` when there is no body.
2326
+ */
2327
+ massOf(entity) {
2328
+ const record = this.#byEntity.get(entity);
2329
+ return record === void 0 ? 0 : bodyMass(record.body);
2330
+ }
2331
+ /**
2332
+ * Reads a body's linear velocity.
2333
+ *
2334
+ * @param entity - The entity.
2335
+ * @param out - The vector to write; left untouched when there is no body.
2336
+ */
2337
+ readLinear(entity, out) {
2338
+ const record = this.#byEntity.get(entity);
2339
+ if (record !== void 0) readLinearVelocity(record.body, out);
2340
+ }
2341
+ /**
2342
+ * Reads a body's angular velocity.
2343
+ *
2344
+ * @param entity - The entity.
2345
+ * @returns Degrees per second, counter-clockwise.
2346
+ */
2347
+ readAngular(entity) {
2348
+ const record = this.#byEntity.get(entity);
2349
+ return record === void 0 ? 0 : toDegrees(readAngularVelocity(record.body));
2350
+ }
2351
+ /**
2352
+ * Sets a body's linear velocity.
2353
+ *
2354
+ * @param entity - The entity.
2355
+ * @param velocity - Metres per second.
2356
+ */
2357
+ setLinearVelocity(entity, velocity) {
2358
+ const record = this.#byEntity.get(entity);
2359
+ if (record !== void 0) writeLinearVelocity(record.body, velocity);
2360
+ }
2361
+ /**
2362
+ * Sets a body's angular velocity.
2363
+ *
2364
+ * @param entity - The entity.
2365
+ * @param velocity - Degrees per second.
2366
+ */
2367
+ setAngularVelocity(entity, velocity) {
2368
+ const record = this.#byEntity.get(entity);
2369
+ if (record !== void 0) writeAngularVelocity(record.body, toRadians(velocity));
2370
+ }
2371
+ /**
2372
+ * Applies a force for one fixed step.
2373
+ *
2374
+ * @param entity - The entity.
2375
+ * @param force - Newtons.
2376
+ * @param point - The application point, or `null` for the centre of mass.
2377
+ */
2378
+ addForce(entity, force, point) {
2379
+ const record = this.#byEntity.get(entity);
2380
+ if (record !== void 0) addForce(record.body, force, point);
2381
+ }
2382
+ /**
2383
+ * Applies an impulse.
2384
+ *
2385
+ * @param entity - The entity.
2386
+ * @param impulse - Newton-seconds.
2387
+ * @param point - The application point, or `null` for the centre of mass.
2388
+ */
2389
+ addImpulse(entity, impulse, point) {
2390
+ const record = this.#byEntity.get(entity);
2391
+ if (record !== void 0) applyImpulse(record.body, impulse, point);
2392
+ }
2393
+ /**
2394
+ * Applies a torque for one fixed step.
2395
+ *
2396
+ * @param entity - The entity.
2397
+ * @param torque - Newton-metres, positive counter-clockwise.
2398
+ */
2399
+ addTorque(entity, torque) {
2400
+ const record = this.#byEntity.get(entity);
2401
+ if (record !== void 0) addTorque(record.body, torque);
2402
+ }
2403
+ /**
2404
+ * Teleports a body and resets its interpolation history.
2405
+ *
2406
+ * @param entity - The entity.
2407
+ * @param position - The new world position.
2408
+ * @param rotationDegrees - The new rotation in degrees counter-clockwise.
2409
+ */
2410
+ teleport(entity, position, rotationDegrees) {
2411
+ const record = this.#byEntity.get(entity);
2412
+ this.#writeTransform(entity, position, rotationDegrees);
2413
+ if (record === void 0) return;
2414
+ const radians = toRadians(rotationDegrees);
2415
+ setBodyPose(record.body, position, radians);
2416
+ record.currentPosition.set(position.x, position.y);
2417
+ record.previousPosition.set(position.x, position.y);
2418
+ record.currentRotation = radians;
2419
+ record.previousRotation = radians;
2420
+ this.#colliderSetChanged = true;
2421
+ }
2422
+ /**
2423
+ * Teleports a character controller.
2424
+ *
2425
+ * @param component - The controller component.
2426
+ * @param position - The new world position.
2427
+ */
2428
+ teleportController(component, position) {
2429
+ this.teleport(component.entity, position, component.transform.rotation2D);
2430
+ }
2431
+ /**
2432
+ * Restores the authoritative pose of every interpolated body, undoing the display pose the
2433
+ * `PreRender` system wrote (`09-physics.md` §1).
2434
+ */
2435
+ restorePoses() {
2436
+ for (let index = 0; index < this.#records.length; index += 1) {
2437
+ const record = this.#records[index];
2438
+ if (record !== void 0 && record.interpolate) this.#writeTransform(record.entity, record.currentPosition, toDegrees(record.currentRotation));
2439
+ }
2440
+ }
2441
+ /**
2442
+ * Runs one fixed step: reconcile, push kinematic poses, move controllers, step Rapier, snapshot,
2443
+ * dispatch events.
2444
+ *
2445
+ * @param fixedDeltaSeconds - The fixed step in seconds.
2446
+ */
2447
+ step(fixedDeltaSeconds) {
2448
+ this.#syncLayers();
2449
+ this.#pollTilemaps();
2450
+ this.#reconcile();
2451
+ this.#pushKinematicPoses();
2452
+ setTimestep(this.#world, fixedDeltaSeconds);
2453
+ const started = now();
2454
+ if (this.#colliderSetChanged || !this.#stepped) {
2455
+ primeWorld(this.#world, this.#queue);
2456
+ this.#colliderSetChanged = false;
2457
+ }
2458
+ this.#refreshOneWayPlatforms();
2459
+ this.#moveControllers(fixedDeltaSeconds);
2460
+ this.#capturePreStepVelocities();
2461
+ stepWorld(this.#world, this.#queue);
2462
+ this.#stepMs += now() - started;
2463
+ this.#stepsThisFrame += 1;
2464
+ this.#stepped = true;
2465
+ this.#snapshot();
2466
+ this.#dispatchEvents();
2467
+ }
2468
+ /**
2469
+ * Writes the interpolated display pose of every interpolated body.
2470
+ *
2471
+ * @param alpha - `time.fixedStepAlpha`, in `[0, 1)`.
2472
+ */
2473
+ interpolate(alpha) {
2474
+ this.#publishCounters();
2475
+ for (let index = 0; index < this.#records.length; index += 1) {
2476
+ const record = this.#records[index];
2477
+ if (record === void 0 || !record.interpolate) continue;
2478
+ this.#scratchVector.set(lerp(record.previousPosition.x, record.currentPosition.x, alpha), lerp(record.previousPosition.y, record.currentPosition.y, alpha));
2479
+ const rotation = record.previousRotation + shortestArc(record.previousRotation, record.currentRotation) * alpha;
2480
+ this.#writeTransform(record.entity, this.#scratchVector, toDegrees(rotation));
2481
+ }
2482
+ }
2483
+ /** Tears every body down and releases the Rapier world. */
2484
+ dispose() {
2485
+ if (this.#disposed) return;
2486
+ this.#disposed = true;
2487
+ for (let index = this.#records.length - 1; index >= 0; index -= 1) {
2488
+ const record = this.#records[index];
2489
+ if (record !== void 0) this.#teardown(record);
2490
+ }
2491
+ this.#records.length = 0;
2492
+ this.#byEntity.clear();
2493
+ this.#byCollider.clear();
2494
+ this.#controllers.clear();
2495
+ this.#pairs.clear();
2496
+ destroyEventQueue(this.#queue);
2497
+ destroyWorld(this.#world);
2498
+ }
2499
+ /**
2500
+ * Publishes the frame's counters and zeroes the accumulators. `PreRender` is where a frame's fixed
2501
+ * steps are all done.
2502
+ */
2503
+ #publishCounters() {
2504
+ this.#counters.set(this.#counters.index("bodies"), this.#records.length);
2505
+ this.#counters.set(this.#counters.index("colliders"), this.#colliderCount);
2506
+ this.#counters.set(this.#counters.index("stepsThisFrame"), this.#stepsThisFrame);
2507
+ this.#counters.set(this.#counters.index("collisionEvents"), this.#collisionEventCount);
2508
+ this.#counters.set(this.#counters.index("triggerEvents"), this.#triggerEventCount);
2509
+ this.#counters.set(this.#counters.index("stepMs"), this.#stepMs);
2510
+ this.#stepsThisFrame = 0;
2511
+ this.#collisionEventCount = 0;
2512
+ this.#triggerEventCount = 0;
2513
+ this.#stepMs = 0;
2514
+ }
2515
+ /**
2516
+ * Builds the per-layer collide masks from `physics2d.collisionMatrix` (`09-physics.md` §3).
2517
+ *
2518
+ * @throws IgnifxError with code `IGX-1155` when a name is not a declared layer, and reports
2519
+ * `IGX-1152` for a layer Rapier's sixteen interaction groups cannot express.
2520
+ */
2521
+ #buildCollideMasks() {
2522
+ for (const [name, others] of Object.entries(this.#settings.collisionMatrix)) {
2523
+ const index = this.#layerIndex(name);
2524
+ let mask = 0;
2525
+ for (const other of others) mask |= 1 << this.#layerIndex(other);
2526
+ this.#collideMasks[index] = mask;
2527
+ }
2528
+ }
2529
+ /**
2530
+ * Resolves a layer name to its index, refusing an undeclared one.
2531
+ *
2532
+ * @param name - The layer name.
2533
+ * @returns The index, clamped into Rapier's sixteen.
2534
+ * @throws IgnifxError with code `IGX-1155` when the project does not declare the layer.
2535
+ */
2536
+ #layerIndex(name) {
2537
+ const index = this.#ctx.app.world.layers.indexOf(name);
2538
+ if (index < 0) throw physics2DError(Physics2DErrorCode.unknownLayer, `${name} is not a layer this project declares.`, {
2539
+ context: { layer: name },
2540
+ hint: "Add the layer to the layers settings section, or fix the name in physics2d.collisionMatrix."
2541
+ });
2542
+ return this.#clampLayer(index, name);
2543
+ }
2544
+ /**
2545
+ * Reports a layer Rapier cannot express and falls back to layer 0.
2546
+ *
2547
+ * @param layer - The layer index.
2548
+ * @param subject - What the diagnostic names.
2549
+ * @returns The layer to use.
2550
+ */
2551
+ #clampLayer(layer, subject) {
2552
+ if (layer < 16) return layer;
2553
+ this.#ctx.log.warn(`${Physics2DErrorCode.layerOutOfRange}: {entity} is on layer {layer}, which is outside the sixteen layers Rapier 2D can filter by.`, subject, String(layer));
2554
+ return 0;
2555
+ }
2556
+ /** Marks every entity whose layer changed since its body was built. */
2557
+ #syncLayers() {
2558
+ for (let index = 0; index < this.#records.length; index += 1) {
2559
+ const record = this.#records[index];
2560
+ if (record !== void 0 && record.entity.layer !== record.layer) this.markDirty(record.entity);
2561
+ }
2562
+ }
2563
+ /** Marks every entity whose tilemap collision data changed since its shapes were built. */
2564
+ #pollTilemaps() {
2565
+ const colliders = this.#ctx.app.world.components(TilemapCollider2D);
2566
+ for (let index = 0; index < colliders.length; index += 1) {
2567
+ const collider = colliders[index];
2568
+ if (collider !== void 0 && collider.needsRebuild()) this.markDirty(collider.entity);
2569
+ }
2570
+ }
2571
+ /** Rebuilds every body marked dirty. */
2572
+ #reconcile() {
2573
+ if (this.#dirty.size === 0) return;
2574
+ const pending = [...this.#dirty];
2575
+ this.#dirty.clear();
2576
+ for (const entity of pending) this.#rebuildEntity(entity);
2577
+ this.#colliderSetChanged = true;
2578
+ }
2579
+ /**
2580
+ * Tears an entity's body down and builds a new one from its current components.
2581
+ *
2582
+ * @param entity - The entity to rebuild.
2583
+ */
2584
+ #rebuildEntity(entity) {
2585
+ const existing = this.#byEntity.get(entity);
2586
+ let carriedLinear = null;
2587
+ let carriedAngular = 0;
2588
+ if (existing !== void 0) {
2589
+ if (existing.motion === BodyMotion2D.dynamic) {
2590
+ carriedLinear = new Vec2();
2591
+ readLinearVelocity(existing.body, carriedLinear);
2592
+ carriedAngular = readAngularVelocity(existing.body);
2593
+ }
2594
+ this.#removeRecord(entity);
2595
+ }
2596
+ if (entity.isDestroyed || !entity.activeInHierarchy) return;
2597
+ const controller = entity.getComponent(CharacterController2D);
2598
+ const colliders = collidersOf(entity);
2599
+ if (colliders.length === 0 && controller === null) return;
2600
+ if (entity.parent !== null) this.#ctx.log.warn(`${Physics2DErrorCode.bodyOnChildEntity}: {entity} carries a 2D physics body but is not a root entity; Rapier writes its world pose.`, entity.name);
2601
+ const record = this.#build(entity, entity.getComponent(Rigidbody2D), controller, colliders);
2602
+ if (record === null) return;
2603
+ this.#records.push(record);
2604
+ if (record.motion === BodyMotion2D.dynamic && carriedLinear !== null) {
2605
+ writeLinearVelocity(record.body, carriedLinear);
2606
+ writeAngularVelocity(record.body, carriedAngular);
2607
+ }
2608
+ }
2609
+ /**
2610
+ * Builds one body from an entity's colliders and controller.
2611
+ *
2612
+ * @param entity - The entity.
2613
+ * @param rigidbody - Its `Rigidbody2D`, or `null`.
2614
+ * @param controller - Its `CharacterController2D`, or `null`.
2615
+ * @param colliders - Its colliders, in component order.
2616
+ * @returns The new record, or `null` when nothing produced a shape — a `TilemapCollider2D` whose
2617
+ * tilemap has not published its geometry yet, say, which must not leave an empty body behind.
2618
+ */
2619
+ #build(entity, rigidbody, controller, colliders) {
2620
+ const transform = entity.transform;
2621
+ transform.positionToRef(this.#scratchPose);
2622
+ const rotation = toRadians(transform.rotation2D);
2623
+ const layer = this.#clampLayer(entity.layer, entity.name);
2624
+ const motion = motionOf(rigidbody, controller);
2625
+ const body = createBody(this.#world, {
2626
+ motion,
2627
+ position: {
2628
+ x: this.#scratchPose.x,
2629
+ y: this.#scratchPose.y
2630
+ },
2631
+ rotation,
2632
+ gravityScale: rigidbody?.gravityScale ?? 1,
2633
+ linearDamping: rigidbody?.linearDamping ?? 0,
2634
+ angularDamping: rigidbody?.angularDamping ?? 0,
2635
+ freezeRotation: rigidbody?.freezeRotation ?? controller !== null,
2636
+ additionalMass: 0,
2637
+ startAsleep: false
2638
+ });
2639
+ const record = {
2640
+ entity,
2641
+ rigidbody,
2642
+ controller,
2643
+ body,
2644
+ colliders: [],
2645
+ owners: [],
2646
+ controllerCollider: null,
2647
+ motion,
2648
+ interpolate: this.#shouldInterpolate(rigidbody, controller, motion),
2649
+ layer,
2650
+ collisionEvents: false,
2651
+ previousPosition: new Vec2(this.#scratchPose.x, this.#scratchPose.y),
2652
+ previousRotation: rotation,
2653
+ currentPosition: new Vec2(this.#scratchPose.x, this.#scratchPose.y),
2654
+ currentRotation: rotation,
2655
+ preStepVelocity: new Vec2(),
2656
+ staticVersion: transform.worldMatrixVersion,
2657
+ reportedMove: false,
2658
+ disconnects: []
2659
+ };
2660
+ this.#byEntity.set(entity, record);
2661
+ this.#attachColliders(record, colliders, layer);
2662
+ if (controller !== null) this.#attachController(record, controller, layer);
2663
+ if (record.colliders.length === 0) {
2664
+ this.#byEntity.delete(entity);
2665
+ destroyBody(this.#world, body);
2666
+ return null;
2667
+ }
2668
+ this.#applyMass(record);
2669
+ this.#applyCollisionEvents(record);
2670
+ this.#watchEntity(record);
2671
+ return record;
2672
+ }
2673
+ /**
2674
+ * Builds and registers every Rapier collider an entity's authored colliders stand for.
2675
+ *
2676
+ * @param record - The record being built.
2677
+ * @param colliders - The authored colliders.
2678
+ * @param layer - The entity's clamped layer.
2679
+ */
2680
+ #attachColliders(record, colliders, layer) {
2681
+ const defaults = this.#settings.defaultMaterial;
2682
+ const shapes = [];
2683
+ for (const collider of colliders) {
2684
+ shapes.length = 0;
2685
+ collider.collectShapes(record.entity.transform.lossyScale, shapes);
2686
+ const effective = collider.layerOverride === "" ? layer : this.#layerIndex(collider.layerOverride);
2687
+ const material = collider.resolveMaterial(defaults);
2688
+ for (const collected of shapes) this.#addCollider(record, collider, collected, effective, material);
2689
+ }
2690
+ }
2691
+ /**
2692
+ * Creates one Rapier collider and binds it back to its author.
2693
+ *
2694
+ * @param record - The record it belongs to.
2695
+ * @param owner - The authored collider, or `null` for a controller shape.
2696
+ * @param collected - The geometry and its placement.
2697
+ * @param layer - The layer to filter as.
2698
+ * @param material - The surface.
2699
+ * @returns The created collider, or `null` when Rapier refused the geometry.
2700
+ */
2701
+ #addCollider(record, owner, collected, layer, material) {
2702
+ const collider = createCollider(this.#world, record.body, collected.shape, {
2703
+ offset: collected.offset,
2704
+ rotation: collected.rotation,
2705
+ sensor: owner?.isTrigger === true,
2706
+ groups: interactionGroups(1 << layer, this.#collideMasks[layer] ?? ALL_LAYER_BITS),
2707
+ friction: material.friction,
2708
+ restitution: material.restitution,
2709
+ frictionCombine: owner?.frictionCombine ?? "average",
2710
+ restitutionCombine: owner?.restitutionCombine ?? "average",
2711
+ events: owner?.isTrigger === true
2712
+ });
2713
+ if (collider === null) {
2714
+ this.#ctx.log.warn(`${Physics2DErrorCode.colliderGeometryInvalid}: {entity} has a 2D collider whose geometry Rapier cannot build a shape from.`, record.entity.name);
2715
+ return null;
2716
+ }
2717
+ const handle = colliderHandle(collider);
2718
+ record.colliders.push(collider);
2719
+ record.owners.push(owner);
2720
+ this.#byCollider.set(handle, {
2721
+ record,
2722
+ owner,
2723
+ collider
2724
+ });
2725
+ this.#colliderCount += 1;
2726
+ if (collected.oneWay) this.#oneWay.set(handle, {
2727
+ collider,
2728
+ halfHeight: topOf(collected.shape) + collected.offset.y,
2729
+ top: 0
2730
+ });
2731
+ return collider;
2732
+ }
2733
+ /**
2734
+ * Builds a character controller's own shape and Rapier controller.
2735
+ *
2736
+ * @param record - The record being built.
2737
+ * @param controller - The component.
2738
+ * @param layer - The entity's clamped layer.
2739
+ */
2740
+ #attachController(record, controller, layer) {
2741
+ const half = Math.max(0, controller.height / 2 - controller.radius);
2742
+ const shape = {
2743
+ shape: controller.shape === "box" ? {
2744
+ kind: "box",
2745
+ halfWidth: controller.radius,
2746
+ halfHeight: Math.max(controller.radius, controller.height / 2)
2747
+ } : {
2748
+ kind: "capsule",
2749
+ halfHeight: half,
2750
+ radius: controller.radius
2751
+ },
2752
+ offset: controller.offset,
2753
+ rotation: 0,
2754
+ oneWay: false
2755
+ };
2756
+ const collider = this.#addCollider(record, null, shape, layer, this.#settings.defaultMaterial);
2757
+ record.controllerCollider = collider;
2758
+ if (collider === null) return;
2759
+ const rapierController = createController(this.#world, Math.max(controller.skinWidth, .001));
2760
+ tuneController(rapierController, {
2761
+ slopeLimit: toRadians(controller.slopeLimit),
2762
+ slideLimit: toRadians(controller.slopeLimit),
2763
+ stepOffset: controller.stepOffset,
2764
+ stepMinWidth: AUTOSTEP_MIN_WIDTH,
2765
+ snapToGround: controller.snapToGround,
2766
+ pushBodies: controller.pushBodies
2767
+ });
2768
+ this.#controllers.set(colliderHandle(collider), rapierController);
2769
+ }
2770
+ /**
2771
+ * Scales the colliders' densities so the body weighs exactly `Rigidbody2D.mass`.
2772
+ *
2773
+ * @param record - The record being built.
2774
+ */
2775
+ #applyMass(record) {
2776
+ const wanted = record.rigidbody?.mass ?? 0;
2777
+ if (record.motion !== BodyMotion2D.dynamic || wanted <= 0) return;
2778
+ const current = bodyMass(record.body);
2779
+ if (current <= 0) return;
2780
+ const density = wanted / current;
2781
+ for (const collider of record.colliders) setColliderDensity(collider, density);
2782
+ recomputeMass(record.body);
2783
+ }
2784
+ /**
2785
+ * Keeps a record in step with its entity: component changes rebuild, destruction removes.
2786
+ *
2787
+ * @param record - The record to watch.
2788
+ */
2789
+ #watchEntity(record) {
2790
+ const entity = record.entity;
2791
+ record.disconnects.push(entity.onComponentAdded.connect((component) => {
2792
+ this.#onComponentChanged(record, component);
2793
+ }), entity.onComponentRemoved.connect((component) => {
2794
+ this.#onComponentChanged(record, component);
2795
+ }), entity.onDestroyed.connect(() => {
2796
+ this.#removeRecord(entity);
2797
+ }));
2798
+ }
2799
+ /**
2800
+ * Recomputes `collisionEvents` when a component joins or leaves the entity.
2801
+ *
2802
+ * @param record - The body record.
2803
+ * @param component - The component that changed.
2804
+ */
2805
+ #onComponentChanged(record, component) {
2806
+ if (component instanceof Collider2D || component instanceof Rigidbody2D || component instanceof CharacterController2D) {
2807
+ this.markDirty(record.entity);
2808
+ return;
2809
+ }
2810
+ this.#applyCollisionEvents(record);
2811
+ }
2812
+ /**
2813
+ * Pushes the effective `collisionEvents` value to every collider of a body.
2814
+ *
2815
+ * @param record - The body record.
2816
+ */
2817
+ #applyCollisionEvents(record) {
2818
+ const mode = record.rigidbody?.collisionEvents ?? "auto";
2819
+ const enabled = mode === "on" ? true : mode === "off" ? false : this.#entityWantsEvents(record.entity);
2820
+ if (enabled === record.collisionEvents) return;
2821
+ record.collisionEvents = enabled;
2822
+ for (let index = 0; index < record.colliders.length; index += 1) {
2823
+ const collider = record.colliders[index];
2824
+ const owner = record.owners[index];
2825
+ if (collider !== void 0) setColliderEvents(collider, enabled || owner?.isTrigger === true);
2826
+ }
2827
+ }
2828
+ /**
2829
+ * Whether any script on an entity implements a physics callback.
2830
+ *
2831
+ * @param entity - The entity.
2832
+ * @returns `true` when at least one does.
2833
+ */
2834
+ #entityWantsEvents(entity) {
2835
+ return this.#ctx.entityImplements(entity, "onCollisionEnter") || this.#ctx.entityImplements(entity, "onCollisionStay") || this.#ctx.entityImplements(entity, "onCollisionExit") || this.#ctx.entityImplements(entity, "onTriggerEnter") || this.#ctx.entityImplements(entity, "onTriggerExit");
2836
+ }
2837
+ /**
2838
+ * Drops an entity's body immediately.
2839
+ *
2840
+ * @param entity - The entity whose body goes away.
2841
+ */
2842
+ #removeRecord(entity) {
2843
+ const record = this.#byEntity.get(entity);
2844
+ if (record === void 0) return;
2845
+ this.#teardown(record);
2846
+ const index = this.#records.indexOf(record);
2847
+ if (index >= 0) this.#records.splice(index, 1);
2848
+ this.#byEntity.delete(entity);
2849
+ this.#dirty.delete(entity);
2850
+ this.#colliderSetChanged = true;
2851
+ }
2852
+ /**
2853
+ * Releases one record's Rapier resources, signal connections, and index entries.
2854
+ *
2855
+ * @param record - The record to tear down.
2856
+ */
2857
+ #teardown(record) {
2858
+ for (const disconnect of record.disconnects) disconnect();
2859
+ record.disconnects = [];
2860
+ for (const collider of record.colliders) {
2861
+ const handle = colliderHandle(collider);
2862
+ const controller = this.#controllers.get(handle);
2863
+ if (controller !== void 0) {
2864
+ destroyController(this.#world, controller);
2865
+ this.#controllers.delete(handle);
2866
+ }
2867
+ this.#byCollider.delete(handle);
2868
+ this.#oneWay.delete(handle);
2869
+ this.#dropPairs(handle);
2870
+ this.#colliderCount -= 1;
2871
+ }
2872
+ record.colliders = [];
2873
+ record.owners = [];
2874
+ record.controllerCollider = null;
2875
+ destroyBody(this.#world, record.body);
2876
+ }
2877
+ /**
2878
+ * Forgets every touching pair a departing collider took part in.
2879
+ *
2880
+ * @param handle - The collider handle.
2881
+ */
2882
+ #dropPairs(handle) {
2883
+ for (const [key, pair] of this.#pairs) if (pair.first === handle || pair.second === handle) this.#pairs.delete(key);
2884
+ }
2885
+ /** Pushes every kinematic body's authored transform into Rapier before the step. */
2886
+ #pushKinematicPoses() {
2887
+ for (let index = 0; index < this.#records.length; index += 1) {
2888
+ const record = this.#records[index];
2889
+ if (record === void 0 || record.motion !== BodyMotion2D.kinematic || record.controller !== null) continue;
2890
+ const transform = record.entity.transform;
2891
+ transform.positionToRef(this.#scratchPose);
2892
+ setNextKinematicPose(record.body, this.#scratchVector.set(this.#scratchPose.x, this.#scratchPose.y), toRadians(transform.rotation2D));
2893
+ }
2894
+ }
2895
+ /** Caches every one-way platform's world top, so the move filter needs no Rapier call. */
2896
+ #refreshOneWayPlatforms() {
2897
+ for (const platform of this.#oneWay.values()) {
2898
+ readColliderTranslation(platform.collider, this.#filterScratch);
2899
+ platform.top = this.#filterScratch.y + platform.halfHeight;
2900
+ }
2901
+ }
2902
+ /**
2903
+ * Remembers every event-reporting body's velocity before the solver runs, so a collision callback
2904
+ * can report the speed the two bodies met at rather than the one they left with.
2905
+ */
2906
+ #capturePreStepVelocities() {
2907
+ for (let index = 0; index < this.#records.length; index += 1) {
2908
+ const record = this.#records[index];
2909
+ if (record !== void 0 && record.collisionEvents) readLinearVelocity(record.body, record.preStepVelocity);
2910
+ }
2911
+ }
2912
+ /**
2913
+ * Hands every character controller its accumulated displacement and turns the resolved movement
2914
+ * into a kinematic target.
2915
+ *
2916
+ * @param fixedDeltaSeconds - The fixed step.
2917
+ */
2918
+ #moveControllers(fixedDeltaSeconds) {
2919
+ for (let index = 0; index < this.#records.length; index += 1) {
2920
+ const record = this.#records[index];
2921
+ const component = record?.controller ?? null;
2922
+ const collider = record?.controllerCollider ?? null;
2923
+ if (record === void 0 || component === null || collider === null) continue;
2924
+ const rapierController = this.#controllers.get(colliderHandle(collider));
2925
+ if (rapierController === void 0) continue;
2926
+ const desired = component.takePendingMove();
2927
+ this.#filterDesiredY = desired.y;
2928
+ this.#filterFeet = record.currentPosition.y + component.offset.y - halfHeightOf(component);
2929
+ const grounded = computeControllerMovement(rapierController, collider, desired, interactionGroups(1 << record.layer, this.#collideMasks[record.layer] ?? ALL_LAYER_BITS), component.onOneWayPlatforms ? this.#oneWayFilter : null, this.#movement);
2930
+ const normal = this.#collectControllerHits(rapierController, component);
2931
+ component.applyStepResult(this.#movement, grounded, normal, fixedDeltaSeconds);
2932
+ setNextKinematicPose(record.body, this.#scratchVector.set(record.currentPosition.x + this.#movement.x, record.currentPosition.y + this.#movement.y), record.currentRotation);
2933
+ }
2934
+ }
2935
+ /**
2936
+ * The one-way platform predicate, bound once so the per-step move allocates nothing.
2937
+ *
2938
+ * Rapier runs it from inside WebAssembly, so it reads only plain JavaScript state:
2939
+ * {@link Physics2DRuntime.#refreshOneWayPlatforms} caches every platform's world top *before* the
2940
+ * move, because calling back into Rapier (`collider.translation()`) from inside the callback is a
2941
+ * re-entrant WebAssembly call and the obstacle is then never actually excluded (measured, S6.2).
2942
+ *
2943
+ * @param collider - The obstacle Rapier is considering.
2944
+ * @returns `false` when the obstacle should be ignored this step.
2945
+ */
2946
+ #oneWayFilter = (collider) => {
2947
+ const platform = this.#oneWay.get(colliderHandle(collider));
2948
+ if (platform === void 0) return true;
2949
+ return this.#filterDesiredY <= 0 && this.#filterFeet >= platform.top - ONE_WAY_TOLERANCE;
2950
+ };
2951
+ /**
2952
+ * Reports every obstacle a controller hit and returns the most upward-facing normal.
2953
+ *
2954
+ * @param rapierController - Rapier's controller.
2955
+ * @param component - The component.
2956
+ * @returns The ground normal, or `null` when nothing was hit.
2957
+ */
2958
+ #collectControllerHits(rapierController, component) {
2959
+ const count = controllerHitCount(rapierController);
2960
+ let best = null;
2961
+ let bestY = -2;
2962
+ for (let index = 0; index < count; index += 1) {
2963
+ if (!readControllerHit(rapierController, index, this.#controllerHit)) continue;
2964
+ const normal = this.#controllerHit.normal;
2965
+ if (normal.y > bestY) {
2966
+ bestY = normal.y;
2967
+ this.#groundNormal.set(normal.x, normal.y);
2968
+ best = this.#groundNormal;
2969
+ }
2970
+ const binding = this.bindingOf(this.#controllerHit.collider);
2971
+ const event = this.#characterCollision ??= {
2972
+ other: null,
2973
+ otherCollider: null,
2974
+ point: this.#controllerHit.point,
2975
+ normal: this.#controllerHit.normal
2976
+ };
2977
+ event.other = binding?.record.entity ?? null;
2978
+ event.otherCollider = binding?.owner ?? null;
2979
+ component.reportHit(event);
2980
+ }
2981
+ return best;
2982
+ }
2983
+ /** Snapshots every body's authoritative pose and writes it onto the entity. */
2984
+ #snapshot() {
2985
+ for (let index = 0; index < this.#records.length; index += 1) {
2986
+ const record = this.#records[index];
2987
+ if (record === void 0) continue;
2988
+ record.previousPosition.copyFrom(record.currentPosition);
2989
+ record.previousRotation = record.currentRotation;
2990
+ record.currentRotation = readBodyPose(record.body, record.currentPosition);
2991
+ if (record.motion === BodyMotion2D.static && record.rigidbody === null && record.controller === null) {
2992
+ this.#checkStaticMove(record);
2993
+ continue;
2994
+ }
2995
+ this.#writeTransform(record.entity, record.currentPosition, toDegrees(record.currentRotation));
2996
+ }
2997
+ }
2998
+ /**
2999
+ * Reports `IGX-1151` once for an implicit static body whose transform moved.
3000
+ *
3001
+ * @param record - The body record.
3002
+ */
3003
+ #checkStaticMove(record) {
3004
+ const version = record.entity.transform.worldMatrixVersion;
3005
+ if (version === record.staticVersion || record.reportedMove) return;
3006
+ record.entity.transform.positionToRef(this.#scratchPose);
3007
+ if (Math.abs(this.#scratchPose.x - record.currentPosition.x) < STATIC_MOVE_EPSILON && Math.abs(this.#scratchPose.y - record.currentPosition.y) < STATIC_MOVE_EPSILON) {
3008
+ record.staticVersion = version;
3009
+ return;
3010
+ }
3011
+ record.staticVersion = version;
3012
+ record.reportedMove = true;
3013
+ this.#ctx.log.warn(`${Physics2DErrorCode.movedStaticBody}: {entity} has 2D colliders but no Rigidbody2D, and it moved after its body was placed.`, record.entity.name);
3014
+ }
3015
+ /**
3016
+ * Writes a 2D pose onto an entity without disturbing its Z.
3017
+ *
3018
+ * @param entity - The entity.
3019
+ * @param position - The world position in metres.
3020
+ * @param rotationDegrees - The rotation in degrees counter-clockwise.
3021
+ */
3022
+ #writeTransform(entity, position, rotationDegrees) {
3023
+ const transform = entity.transform;
3024
+ transform.positionToRef(this.#scratchPose);
3025
+ this.#scratchPose.set(position.x, position.y, this.#scratchPose.z);
3026
+ transform.position = this.#scratchPose;
3027
+ transform.rotation2D = rotationDegrees;
3028
+ }
3029
+ /** Drains Rapier's event queue and delivers enter, stay, and exit to both participants. */
3030
+ #dispatchEvents() {
3031
+ this.#startedPairs.length = 0;
3032
+ this.#endedPairs.length = 0;
3033
+ drainCollisions(this.#queue, (event) => {
3034
+ const key = pairKey(event.first, event.second);
3035
+ if (!event.started) {
3036
+ const existing = this.#pairs.get(key);
3037
+ if (existing !== void 0) {
3038
+ this.#pairs.delete(key);
3039
+ this.#endedPairs.push(existing);
3040
+ }
3041
+ return;
3042
+ }
3043
+ const first = this.#byCollider.get(event.first);
3044
+ const second = this.#byCollider.get(event.second);
3045
+ if (first === void 0 || second === void 0) return;
3046
+ const pair = {
3047
+ first: event.first,
3048
+ second: event.second,
3049
+ sensor: isColliderSensor(first.collider) || isColliderSensor(second.collider),
3050
+ entered: true
3051
+ };
3052
+ this.#pairs.set(key, pair);
3053
+ this.#startedPairs.push(pair);
3054
+ });
3055
+ for (const pair of this.#endedPairs) this.#deliverPair(pair, "exit");
3056
+ for (const pair of this.#startedPairs) this.#deliverPair(pair, "enter");
3057
+ for (const pair of this.#pairs.values()) {
3058
+ if (pair.entered) {
3059
+ pair.entered = false;
3060
+ continue;
3061
+ }
3062
+ this.#deliverPair(pair, "stay");
3063
+ }
3064
+ }
3065
+ /**
3066
+ * Delivers one pair's event to both participants.
3067
+ *
3068
+ * @param pair - The touching pair.
3069
+ * @param phase - Which callback to deliver.
3070
+ */
3071
+ #deliverPair(pair, phase) {
3072
+ const first = this.#byCollider.get(pair.first);
3073
+ const second = this.#byCollider.get(pair.second);
3074
+ if (first === void 0 || second === void 0) return;
3075
+ if (pair.sensor) {
3076
+ if (phase === "stay") return;
3077
+ this.#triggerEventCount += 1;
3078
+ const kind = phase === "enter" ? "onTriggerEnter" : "onTriggerExit";
3079
+ this.#deliverTrigger(first, second, kind);
3080
+ this.#deliverTrigger(second, first, kind);
3081
+ return;
3082
+ }
3083
+ this.#collisionEventCount += 1;
3084
+ const kind = phase === "enter" ? "onCollisionEnter" : phase === "exit" ? "onCollisionExit" : "onCollisionStay";
3085
+ this.#readContacts(pair, phase);
3086
+ this.#deliverCollision(first, second, kind);
3087
+ this.#deliverCollision(second, first, kind);
3088
+ }
3089
+ /**
3090
+ * Fills the pooled contact point for a collision event.
3091
+ *
3092
+ * @param pair - The touching pair.
3093
+ * @param phase - Which callback is being delivered; an exit has no contact left to read.
3094
+ */
3095
+ #readContacts(pair, phase) {
3096
+ this.#contactPoint.impulse = 0;
3097
+ if (phase === "exit") return;
3098
+ const first = this.#byCollider.get(pair.first);
3099
+ const second = this.#byCollider.get(pair.second);
3100
+ if (first === void 0 || second === void 0) return;
3101
+ readContact(this.#world, first.collider, second.collider, this.#contact);
3102
+ if (!this.#contact.touching) return;
3103
+ this.#contactPoint.point.copyFrom(this.#contact.point);
3104
+ this.#contactPoint.normal.copyFrom(this.#contact.normal);
3105
+ this.#contactPoint.impulse = this.#contact.impulse;
3106
+ }
3107
+ /**
3108
+ * Delivers one trigger callback to one entity.
3109
+ *
3110
+ * @param target - The binding whose scripts receive it.
3111
+ * @param other - The other participant.
3112
+ * @param kind - Which callback to deliver.
3113
+ */
3114
+ #deliverTrigger(target, other, kind) {
3115
+ if (target.record.entity.isDestroyed) return;
3116
+ const event = this.#trigger ??= {
3117
+ other: null,
3118
+ otherCollider: null,
3119
+ self: target.record.entity,
3120
+ selfCollider: null
3121
+ };
3122
+ event.self = target.record.entity;
3123
+ event.selfCollider = target.owner;
3124
+ event.other = other.record.entity;
3125
+ event.otherCollider = other.owner;
3126
+ this.#ctx.dispatchScriptCallback(target.record.entity, kind, event);
3127
+ }
3128
+ /**
3129
+ * Delivers one collision callback to one entity.
3130
+ *
3131
+ * @param target - The binding whose scripts receive it.
3132
+ * @param other - The other participant.
3133
+ * @param kind - Which callback to deliver.
3134
+ */
3135
+ #deliverCollision(target, other, kind) {
3136
+ if (target.record.entity.isDestroyed || !target.record.collisionEvents) return;
3137
+ this.#relativeVelocity.set(target.record.preStepVelocity.x - other.record.preStepVelocity.x, target.record.preStepVelocity.y - other.record.preStepVelocity.y);
3138
+ const collision = this.#collision ??= {
3139
+ other: null,
3140
+ otherCollider: null,
3141
+ self: target.record.entity,
3142
+ selfCollider: null,
3143
+ contacts: this.#contacts,
3144
+ relativeVelocity: this.#relativeVelocity
3145
+ };
3146
+ collision.self = target.record.entity;
3147
+ collision.selfCollider = target.owner;
3148
+ collision.other = other.record.entity;
3149
+ collision.otherCollider = other.owner;
3150
+ this.#ctx.dispatchScriptCallback(target.record.entity, kind, collision);
3151
+ }
3152
+ /**
3153
+ * Whether a body's display pose is interpolated.
3154
+ *
3155
+ * @param rigidbody - The `Rigidbody2D`, or `null`.
3156
+ * @param controller - The `CharacterController2D`, or `null`.
3157
+ * @param motion - The motion type.
3158
+ * @returns `true` when interpolation applies.
3159
+ */
3160
+ #shouldInterpolate(rigidbody, controller, motion) {
3161
+ if (!this.#settings.interpolation || motion === BodyMotion2D.static) return false;
3162
+ if (controller !== null) return controller.interpolation === "interpolate";
3163
+ return rigidbody !== null && rigidbody.interpolation === "interpolate";
3164
+ }
3165
+ };
3166
+ /** How far below a one-way platform's top the character's feet may be and still land on it. */
3167
+ const ONE_WAY_TOLERANCE = .02;
3168
+ /**
3169
+ * The colliders that make up an entity's body: every enabled `Collider2D` on the entity itself.
3170
+ *
3171
+ * @param entity - The entity.
3172
+ * @returns The colliders, in component order.
3173
+ */
3174
+ function collidersOf(entity) {
3175
+ const found = [];
3176
+ const components = entity.components;
3177
+ for (let index = 0; index < components.length; index += 1) {
3178
+ const component = components[index];
3179
+ if (component instanceof Collider2D && component.enabled && !component.isDestroyed) found.push(component);
3180
+ }
3181
+ return found;
3182
+ }
3183
+ /**
3184
+ * The motion type a body takes.
3185
+ *
3186
+ * @param rigidbody - The component, or `null`.
3187
+ * @param controller - The character controller, or `null`.
3188
+ * @returns The motion type; a controller is always kinematic.
3189
+ */
3190
+ function motionOf(rigidbody, controller) {
3191
+ if (controller !== null) return BodyMotion2D.kinematic;
3192
+ if (rigidbody === null || rigidbody.bodyType === "static") return BodyMotion2D.static;
3193
+ return rigidbody.bodyType === "kinematic" ? BodyMotion2D.kinematic : BodyMotion2D.dynamic;
3194
+ }
3195
+ /**
3196
+ * How far a shape reaches above its own origin, which is what a one-way platform's top is measured
3197
+ * from.
3198
+ *
3199
+ * @param shape - The geometry.
3200
+ * @returns The half-height in metres.
3201
+ */
3202
+ function topOf(shape) {
3203
+ if (shape.kind === "box") return shape.halfHeight;
3204
+ if (shape.kind === "circle") return shape.radius;
3205
+ if (shape.kind === "capsule") return shape.halfHeight + shape.radius;
3206
+ let top = 0;
3207
+ for (let index = 1; index < shape.points.length; index += 2) top = Math.max(top, shape.points[index] ?? 0);
3208
+ return top;
3209
+ }
3210
+ /**
3211
+ * Half a character controller's total height, including its caps.
3212
+ *
3213
+ * @param component - The controller.
3214
+ * @returns The half-height in metres.
3215
+ */
3216
+ function halfHeightOf(component) {
3217
+ return Math.max(component.radius, component.height / 2);
3218
+ }
3219
+ /**
3220
+ * The map key of one collider pair, order-independent.
3221
+ *
3222
+ * @param first - One handle.
3223
+ * @param second - The other.
3224
+ * @returns The key.
3225
+ */
3226
+ function pairKey(first, second) {
3227
+ return first < second ? `${String(first)}:${String(second)}` : `${String(second)}:${String(first)}`;
3228
+ }
3229
+ /**
3230
+ * The shortest signed angular difference between two angles, so interpolation never spins the long
3231
+ * way round.
3232
+ *
3233
+ * @param from - The start angle in radians.
3234
+ * @param to - The end angle in radians.
3235
+ * @returns The signed difference in `(-π, π]`.
3236
+ */
3237
+ function shortestArc(from, to) {
3238
+ const twoPi = Math.PI * 2;
3239
+ return ((to - from + Math.PI) % twoPi + twoPi) % twoPi - Math.PI;
3240
+ }
3241
+ /**
3242
+ * A monotonic millisecond reading for the `stepMs` counter. It never feeds the simulation, so it
3243
+ * does not break the "no wall-clock reads" rule of `09-physics.md` §8.
3244
+ *
3245
+ * @returns Milliseconds since the process started.
3246
+ */
3247
+ function now() {
3248
+ return performance.now();
3249
+ }
3250
+ //#endregion
3251
+ //#region src/runtime/systems.ts
3252
+ /**
3253
+ * The three systems `docs/architecture/01-lifecycle-and-time.md` §3 step 4 and `09-physics.md` §1
3254
+ * put physics in the frame with — identical in 2D, except that Rapier is stepped directly and there
3255
+ * is no Babylon Lite simulation scene:
3256
+ *
3257
+ * ```
3258
+ * Systems(FixedUpdate, -100) restore the authoritative pose of interpolated bodies
3259
+ * scripts.fixedUpdate(dt) forces, velocities, CharacterController2D.move()
3260
+ * Systems(FixedUpdate, 100) step Rapier, snapshot poses, dispatch collision and trigger events
3261
+ * Systems(PreRender, -500) write lerp(prev, cur, time.fixedStepAlpha)
3262
+ * ```
3263
+ *
3264
+ * Everything here is `@internal`.
3265
+ */
3266
+ /** The `FixedUpdate` order the restore system runs at. */
3267
+ const PHYSICS_2D_RESTORE_ORDER = -100;
3268
+ /** The `PreRender` order the interpolation system runs at. */
3269
+ const PHYSICS_2D_INTERPOLATE_ORDER = -500;
3270
+ /**
3271
+ * Undoes the display pose the interpolation system wrote, so `fixedUpdate` and Rapier both see the
3272
+ * authoritative pose.
3273
+ *
3274
+ * @internal
3275
+ */
3276
+ var Physics2DRestoreSystem = class {
3277
+ /** The name diagnostics and error reports use. */
3278
+ name = "physics2d-restore";
3279
+ #host;
3280
+ /**
3281
+ * Binds the system to the runtime holder.
3282
+ *
3283
+ * @param host - The holder the runtime lands in.
3284
+ */
3285
+ constructor(host) {
3286
+ this.#host = host;
3287
+ }
3288
+ /** Restores every interpolated body's authoritative pose. */
3289
+ update() {
3290
+ this.#host.runtime?.restorePoses();
3291
+ }
3292
+ };
3293
+ /**
3294
+ * Steps Rapier once, snapshots the poses, and dispatches the step's events.
3295
+ *
3296
+ * @internal
3297
+ */
3298
+ var Physics2DStepSystem = class {
3299
+ /** The name diagnostics and error reports use. */
3300
+ name = "physics2d-step";
3301
+ #host;
3302
+ /**
3303
+ * Binds the system to the runtime holder.
3304
+ *
3305
+ * @param host - The holder the runtime lands in.
3306
+ */
3307
+ constructor(host) {
3308
+ this.#host = host;
3309
+ }
3310
+ /**
3311
+ * Runs one fixed step.
3312
+ *
3313
+ * @param ctx - The world, clock, phase, and fixed delta.
3314
+ */
3315
+ update(ctx) {
3316
+ this.#host.runtime?.step(ctx.dt);
3317
+ }
3318
+ };
3319
+ /**
3320
+ * Writes the interpolated display pose once per frame.
3321
+ *
3322
+ * @internal
3323
+ */
3324
+ var Physics2DInterpolationSystem = class {
3325
+ /** The name diagnostics and error reports use. */
3326
+ name = "physics2d-interpolate";
3327
+ #host;
3328
+ /**
3329
+ * Binds the system to the runtime holder.
3330
+ *
3331
+ * @param host - The holder the runtime lands in.
3332
+ */
3333
+ constructor(host) {
3334
+ this.#host = host;
3335
+ }
3336
+ /**
3337
+ * Writes `lerp(prev, cur, alpha)` into every interpolated body's transform.
3338
+ *
3339
+ * @param ctx - The world, clock, phase, and frame delta.
3340
+ */
3341
+ update(ctx) {
3342
+ this.#host.runtime?.interpolate(ctx.time.fixedStepAlpha);
3343
+ }
3344
+ };
3345
+ //#endregion
3346
+ //#region src/service.ts
3347
+ /**
3348
+ * `app.physics2d` (`docs/architecture/11-2d-toolkit.md` §8, `09-physics.md` §5): gravity, the
3349
+ * queries, and the Rapier escape hatch.
3350
+ *
3351
+ * Every query is shape-accurate and reports the exact collider it hit, because Rapier's query
3352
+ * pipeline returns the `Collider` (`pipeline/world.d.ts`). The bounds-index approximations
3353
+ * `09-physics.md` §5 documents for Havok have no counterpart here.
3354
+ */
3355
+ /** How far a ray travels when the caller names no distance, in metres. */
3356
+ const DEFAULT_RAY_DISTANCE = 1e4;
3357
+ /** Every layer bit Rapier can express. */
3358
+ const ALL_LAYERS = 65535;
3359
+ /**
3360
+ * The service behind `app.physics2d`.
3361
+ *
3362
+ * @example
3363
+ * ```ts
3364
+ * const hit = app.physics2d.raycast({ x: 0, y: 5 }, { x: 0, y: -1 }, 20);
3365
+ * if (hit !== null) {
3366
+ * app.log.info(`ray hit ${hit.entity.name} at ${String(hit.point.y)}`);
3367
+ * }
3368
+ * ```
3369
+ *
3370
+ * @public
3371
+ */
3372
+ var Physics2DService = class {
3373
+ #host;
3374
+ #gravity = new Vec2();
3375
+ #point = new Vec2();
3376
+ #normal = new Vec2();
3377
+ #rayResult = {
3378
+ hit: false,
3379
+ collider: null,
3380
+ distance: 0
3381
+ };
3382
+ #sweepResult = {
3383
+ hit: false,
3384
+ collider: null,
3385
+ distance: 0
3386
+ };
3387
+ #hits = [];
3388
+ #overlaps = [];
3389
+ /**
3390
+ * Wraps the holder the extension fills in from `onStart`.
3391
+ *
3392
+ * @param host - The holder the runtime lands in.
3393
+ * @param settings - The resolved `physics2d` settings section.
3394
+ *
3395
+ * @internal
3396
+ */
3397
+ constructor(host, settings) {
3398
+ this.#host = host;
3399
+ this.#gravity.set(settings.gravity.x, settings.gravity.y);
3400
+ }
3401
+ /**
3402
+ * World gravity in metres per second squared.
3403
+ *
3404
+ * @returns A live view; writing to it does nothing, assign the property instead.
3405
+ */
3406
+ get gravity() {
3407
+ return this.#gravity;
3408
+ }
3409
+ /**
3410
+ * Replaces world gravity, which every body feels from the next fixed step.
3411
+ *
3412
+ * @param value - The new acceleration vector.
3413
+ */
3414
+ set gravity(value) {
3415
+ this.#gravity.set(value.x, value.y);
3416
+ this.#rt().setGravity(this.#gravity);
3417
+ }
3418
+ /**
3419
+ * The Rapier handles.
3420
+ *
3421
+ * @returns The world.
3422
+ */
3423
+ get rapier() {
3424
+ return { world: this.#rt().world };
3425
+ }
3426
+ /**
3427
+ * Casts a ray and returns the first entity it hits.
3428
+ *
3429
+ * @param origin - The world-space origin, in metres.
3430
+ * @param direction - The direction; it is normalised for you.
3431
+ * @param maxDistance - How far to travel; defaults to 10 km.
3432
+ * @param options - Layer mask and trigger behaviour.
3433
+ * @returns The hit, or `null` when the ray clears everything.
3434
+ * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet.
3435
+ */
3436
+ raycast(origin, direction, maxDistance = DEFAULT_RAY_DISTANCE, options) {
3437
+ this.#requireStep("raycast");
3438
+ const unit = normalize(direction);
3439
+ if (unit === null) return null;
3440
+ castRay(this.#rt().world, origin, unit, maxDistance, groupsOf(options), options?.hitTriggers === true, this.#point, this.#normal, this.#rayResult);
3441
+ return this.#toHit(this.#rayResult.collider, this.#rayResult.distance, this.#point, this.#normal);
3442
+ }
3443
+ /**
3444
+ * Casts a ray and returns every entity along it, nearest first.
3445
+ *
3446
+ * @param origin - The world-space origin.
3447
+ * @param direction - The direction; it is normalised for you.
3448
+ * @param maxDistance - How far to travel; defaults to 10 km.
3449
+ * @param options - Layer mask and trigger behaviour.
3450
+ * @returns The hits, in increasing distance. The array is reused between calls.
3451
+ * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet.
3452
+ */
3453
+ raycastAll(origin, direction, maxDistance = DEFAULT_RAY_DISTANCE, options) {
3454
+ this.#requireStep("raycastAll");
3455
+ this.#hits.length = 0;
3456
+ const unit = normalize(direction);
3457
+ if (unit === null) return this.#hits;
3458
+ const runtime = this.#rt();
3459
+ castRayAll(runtime.world, origin, unit, maxDistance, groupsOf(options), options?.hitTriggers === true, (collider, distance, normalX, normalY) => {
3460
+ const binding = runtime.bindingOf(collider);
3461
+ if (binding === null) return;
3462
+ this.#hits.push({
3463
+ entity: binding.record.entity,
3464
+ collider: binding.owner,
3465
+ point: new Vec2(origin.x + unit.x * distance, origin.y + unit.y * distance),
3466
+ normal: new Vec2(normalX, normalY),
3467
+ distance
3468
+ });
3469
+ });
3470
+ this.#hits.sort(byDistance);
3471
+ return this.#hits;
3472
+ }
3473
+ /**
3474
+ * Lists the entities a circle overlaps.
3475
+ *
3476
+ * @param centre - The circle's world position.
3477
+ * @param radius - Its radius in metres.
3478
+ * @param options - Layer mask and trigger behaviour.
3479
+ * @returns The overlapping entities, in Rapier's order. The array is reused between calls.
3480
+ * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet.
3481
+ */
3482
+ overlapCircle(centre, radius, options) {
3483
+ return this.#overlap({
3484
+ kind: "circle",
3485
+ radius
3486
+ }, centre, 0, options, "overlapCircle");
3487
+ }
3488
+ /**
3489
+ * Lists the entities a box overlaps.
3490
+ *
3491
+ * @param centre - The box's world position.
3492
+ * @param size - Its full width and height in metres.
3493
+ * @param rotation - Its rotation in degrees counter-clockwise; defaults to `0`.
3494
+ * @param options - Layer mask and trigger behaviour.
3495
+ * @returns The overlapping entities. The array is reused between calls.
3496
+ * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet.
3497
+ */
3498
+ overlapBox(centre, size, rotation = 0, options) {
3499
+ return this.#overlap({
3500
+ kind: "box",
3501
+ halfWidth: size.x / 2,
3502
+ halfHeight: size.y / 2
3503
+ }, centre, toRadians(rotation), options, "overlapBox");
3504
+ }
3505
+ /**
3506
+ * Sweeps a circle and returns the first contact.
3507
+ *
3508
+ * @param centre - Where the sweep starts.
3509
+ * @param radius - The circle's radius in metres.
3510
+ * @param direction - The sweep direction; it is normalised for you.
3511
+ * @param maxDistance - How far to sweep.
3512
+ * @param options - Layer mask and trigger behaviour.
3513
+ * @returns The hit, or `null`.
3514
+ * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet.
3515
+ */
3516
+ shapeCast(centre, radius, direction, maxDistance, options) {
3517
+ this.#requireStep("shapeCast");
3518
+ const unit = normalize(direction);
3519
+ if (unit === null) return null;
3520
+ castShape(this.#rt().world, {
3521
+ kind: "circle",
3522
+ radius
3523
+ }, centre, 0, unit, maxDistance, groupsOf(options), options?.hitTriggers === true, this.#point, this.#normal, this.#sweepResult);
3524
+ const binding = this.#rt().bindingOf(this.#sweepResult.collider);
3525
+ if (!this.#sweepResult.hit || binding === null) return null;
3526
+ return {
3527
+ entity: binding.record.entity,
3528
+ collider: binding.owner,
3529
+ point: this.#point.clone(),
3530
+ normal: this.#normal.clone(),
3531
+ distance: this.#sweepResult.distance,
3532
+ fraction: maxDistance > 0 ? this.#sweepResult.distance / maxDistance : 0
3533
+ };
3534
+ }
3535
+ /**
3536
+ * Runs one overlap query.
3537
+ *
3538
+ * @param shape - The query geometry.
3539
+ * @param centre - Its world position.
3540
+ * @param rotation - Its rotation in radians.
3541
+ * @param options - Layer mask and trigger behaviour.
3542
+ * @param name - The query name, for the diagnostic.
3543
+ * @returns The overlapping entities.
3544
+ */
3545
+ #overlap(shape, centre, rotation, options, name) {
3546
+ this.#requireStep(name);
3547
+ this.#overlaps.length = 0;
3548
+ const runtime = this.#rt();
3549
+ overlapShape(runtime.world, shape, centre, rotation, groupsOf(options), options?.hitTriggers === true, (collider) => {
3550
+ const entity = runtime.bindingOf(collider)?.record.entity ?? null;
3551
+ if (entity !== null && !this.#overlaps.includes(entity)) this.#overlaps.push(entity);
3552
+ });
3553
+ return this.#overlaps;
3554
+ }
3555
+ /**
3556
+ * Turns a Rapier collider and a distance into a public hit.
3557
+ *
3558
+ * @param collider - The collider that was hit, or `null`.
3559
+ * @param distance - How far away it was.
3560
+ * @param point - The contact point.
3561
+ * @param normal - The contact normal.
3562
+ * @returns The hit, or `null`.
3563
+ */
3564
+ #toHit(collider, distance, point, normal) {
3565
+ const binding = this.#rt().bindingOf(collider);
3566
+ if (binding === null) return null;
3567
+ return {
3568
+ entity: binding.record.entity,
3569
+ collider: binding.owner,
3570
+ point: point.clone(),
3571
+ normal: normal.clone(),
3572
+ distance
3573
+ };
3574
+ }
3575
+ /**
3576
+ * The runtime, once `onStart` has built it.
3577
+ *
3578
+ * @returns The runtime.
3579
+ * @throws IgnifxError with code `IGX-1150` when Rapier has not finished loading.
3580
+ */
3581
+ #rt() {
3582
+ const runtime = this.#host.runtime;
3583
+ if (runtime === null) throw physics2DError(Physics2DErrorCode.rapierUnavailable, "The 2D physics world does not exist yet.", { hint: "app.physics2d is usable from the first frame onwards; await app.start() first." });
3584
+ return runtime;
3585
+ }
3586
+ /**
3587
+ * Refuses a query that ran before Rapier built its broadphase (spike S6.2).
3588
+ *
3589
+ * @param query - The query name, for the diagnostic.
3590
+ * @throws IgnifxError with code `IGX-1153` when no step has completed.
3591
+ */
3592
+ #requireStep(query) {
3593
+ if (this.#rt().hasStepped) return;
3594
+ throw physics2DError(Physics2DErrorCode.queryBeforeStep, `${query}() ran before the first fixed step.`, {
3595
+ context: { query },
3596
+ hint: "Step the app at least once (app.step(1 / 60)) before querying; Rapier builds its broadphase in the first step."
3597
+ });
3598
+ }
3599
+ };
3600
+ /**
3601
+ * The packed interaction word a query filters with: it belongs to every layer and collides with the
3602
+ * layers the caller named.
3603
+ *
3604
+ * @param options - The query options.
3605
+ * @returns The packed word.
3606
+ */
3607
+ function groupsOf(options) {
3608
+ const mask = options?.layerMask?.bits ?? ALL_LAYERS;
3609
+ return interactionGroups(ALL_LAYERS, mask & ALL_LAYERS);
3610
+ }
3611
+ /**
3612
+ * Normalises a direction.
3613
+ *
3614
+ * @param direction - The direction.
3615
+ * @returns A fresh unit vector, or `null` when the direction is zero.
3616
+ */
3617
+ function normalize(direction) {
3618
+ const length = Math.hypot(direction.x, direction.y);
3619
+ return length === 0 ? null : {
3620
+ x: direction.x / length,
3621
+ y: direction.y / length
3622
+ };
3623
+ }
3624
+ /**
3625
+ * Orders two hits by distance.
3626
+ *
3627
+ * @param first - One hit.
3628
+ * @param second - The other.
3629
+ * @returns A negative number when `first` is nearer.
3630
+ */
3631
+ function byDistance(first, second) {
3632
+ return first.distance - second.distance;
3633
+ }
3634
+ //#endregion
3635
+ //#region src/settings.ts
3636
+ /**
3637
+ * The `physics2d` project settings section (`docs/architecture/11-2d-toolkit.md` §8,
3638
+ * `09-physics.md` §6). It mirrors the 3D `physics` section with a 2D gravity vector and Rapier's
3639
+ * solver knob in place of Havok's world velocity clamps, which Rapier has no equivalent of.
3640
+ *
3641
+ * `fixedDeltaTime` is **not** here: the fixed step is shared with the core `time` section and the
3642
+ * extension reads it from `app.time.fixedDeltaTime` on every step.
3643
+ */
3644
+ /**
3645
+ * The section name as it appears in `ignifx.config.ts`.
3646
+ *
3647
+ * @public
3648
+ */
3649
+ const PHYSICS_2D_SETTINGS_SECTION = "physics2d";
3650
+ /** Standard gravity in the 2D plane. */
3651
+ const DEFAULT_GRAVITY = Object.freeze({
3652
+ x: 0,
3653
+ y: -9.81
3654
+ });
3655
+ /** The default surface: ordinary friction, no bounce — the same numbers the 3D section uses. */
3656
+ const DEFAULT_MATERIAL = Object.freeze({
3657
+ friction: .6,
3658
+ restitution: 0
3659
+ });
3660
+ /** An empty matrix, which means "everything collides with everything". */
3661
+ const DEFAULT_COLLISION_MATRIX = Object.freeze({});
3662
+ /**
3663
+ * Builds the schema the `physics2d` section is validated against.
3664
+ *
3665
+ * @remarks
3666
+ * It is a function, not a module-level constant: every field kind is a function call, and module
3667
+ * scope holds declarations and immutable constants only (`CONSTITUTION.md` §3.5).
3668
+ *
3669
+ * @returns The schema.
3670
+ *
3671
+ * @public
3672
+ */
3673
+ function physics2DSettingsSchema() {
3674
+ return defineSchema({
3675
+ gravity: vec2(DEFAULT_GRAVITY),
3676
+ collisionMatrix: map(array(str())),
3677
+ defaultMaterial: record({
3678
+ friction: f32(DEFAULT_MATERIAL.friction, { min: 0 }),
3679
+ restitution: f32(DEFAULT_MATERIAL.restitution, {
3680
+ min: 0,
3681
+ max: 1
3682
+ })
3683
+ }),
3684
+ velocityIterations: u32(0),
3685
+ interpolation: bool(true)
3686
+ });
3687
+ }
3688
+ /**
3689
+ * The values used when a project omits the `physics2d` section.
3690
+ *
3691
+ * @returns A fresh defaults object.
3692
+ *
3693
+ * @public
3694
+ */
3695
+ function defaultPhysics2DSettings() {
3696
+ return {
3697
+ gravity: DEFAULT_GRAVITY,
3698
+ collisionMatrix: DEFAULT_COLLISION_MATRIX,
3699
+ defaultMaterial: DEFAULT_MATERIAL,
3700
+ velocityIterations: 0,
3701
+ interpolation: true
3702
+ };
3703
+ }
3704
+ //#endregion
3705
+ //#region src/extension.ts
3706
+ /**
3707
+ * Builds the 2D physics extension.
3708
+ *
3709
+ * @param options - Optionally, an already-instantiated Rapier module.
3710
+ * @returns The extension descriptor.
3711
+ *
3712
+ * @example
3713
+ * ```ts
3714
+ * const app = await createApp({ headless: true, extensions: [physics2d()] });
3715
+ * ```
3716
+ *
3717
+ * @public
3718
+ */
3719
+ const physics2d = defineExtension((options = {}) => {
3720
+ const host = new Physics2DHost();
3721
+ let context = null;
3722
+ return {
3723
+ name: "@ignifx/physics-2d",
3724
+ version: "0.0.0",
3725
+ engine: ">=0.0.0 <1.0.0",
3726
+ requires: [],
3727
+ optional: ["@ignifx/2d", "@ignifx/devtools"],
3728
+ register(ctx) {
3729
+ context = ctx;
3730
+ ctx.registerErrorCodes(PHYSICS_2D_ERROR_MESSAGES);
3731
+ ctx.registerSettings(PHYSICS_2D_SETTINGS_SECTION, physics2DSettingsSchema(), defaultPhysics2DSettings());
3732
+ ctx.registerComponents(PHYSICS_2D_COMPONENTS);
3733
+ ctx.registerAssetLoader(createPhysicsMaterial2DLoader());
3734
+ const settings = ctx.settings(PHYSICS_2D_SETTINGS_SECTION);
3735
+ const service = new Physics2DService(host, settings);
3736
+ ctx.registerService(Physics2DHostKey, host);
3737
+ ctx.registerService(Physics2DService, service);
3738
+ ctx.defineAppProperty("physics2d", () => service);
3739
+ ctx.registerSystem(new Physics2DRestoreSystem(host), {
3740
+ phase: Phase.FixedUpdate,
3741
+ order: PHYSICS_2D_RESTORE_ORDER
3742
+ });
3743
+ ctx.registerSystem(new Physics2DStepSystem(host), {
3744
+ phase: Phase.FixedUpdate,
3745
+ order: 100
3746
+ });
3747
+ ctx.registerSystem(new Physics2DInterpolationSystem(host), {
3748
+ phase: Phase.PreRender,
3749
+ order: PHYSICS_2D_INTERPOLATE_ORDER
3750
+ });
3751
+ },
3752
+ async onStart(app) {
3753
+ const ctx = context;
3754
+ if (ctx === null) return;
3755
+ if (app.world.lite.simulationScene !== null) throw physics2DError(Physics2DErrorCode.bothPhysicsExtensions, "A world uses either physics() or physics2d(), and this one registered both.", {
3756
+ context: { extension: "@ignifx/physics-2d" },
3757
+ hint: "Remove physics() from a 2D game, or physics2d() from a 3D one."
3758
+ });
3759
+ const settings = ctx.settings(PHYSICS_2D_SETTINGS_SECTION);
3760
+ const initialize = options.initialize ?? defaultInitialize;
3761
+ try {
3762
+ await initialize();
3763
+ } catch (cause) {
3764
+ throw physics2DError(Physics2DErrorCode.rapierUnavailable, "The Rapier 2D WebAssembly module could not be instantiated.", {
3765
+ cause,
3766
+ hint: "Reinstall @dimforge/rapier2d-compat; its WebAssembly is inlined and needs no serving."
3767
+ });
3768
+ }
3769
+ const world = createWorld(settings.gravity);
3770
+ const counters = app.diagnostics.registerGroup(PHYSICS_2D_DIAGNOSTICS_GROUP, PHYSICS_2D_DIAGNOSTICS_COUNTERS);
3771
+ host.runtime = new Physics2DRuntime({
3772
+ ctx,
3773
+ settings,
3774
+ world,
3775
+ counters
3776
+ });
3777
+ seedExistingBodies(app, host);
3778
+ },
3779
+ dispose() {
3780
+ host.runtime?.dispose();
3781
+ host.runtime = null;
3782
+ context = null;
3783
+ }
3784
+ };
3785
+ });
3786
+ /**
3787
+ * Instantiates Rapier's WebAssembly module, which is what `Physics2DOptions.initialize` replaces.
3788
+ *
3789
+ * @returns A promise that settles once Rapier is usable.
3790
+ */
3791
+ async function defaultInitialize() {
3792
+ await loadRapier();
3793
+ }
3794
+ /** Every component this extension registers, in a stable order. */
3795
+ const PHYSICS_2D_COMPONENTS = Object.freeze([
3796
+ Rigidbody2D,
3797
+ BoxCollider2D,
3798
+ CircleCollider2D,
3799
+ CapsuleCollider2D,
3800
+ PolygonCollider2D,
3801
+ EdgeCollider2D,
3802
+ TilemapCollider2D,
3803
+ CharacterController2D
3804
+ ]);
3805
+ /** The collider classes the seed pass walks, in registration order. */
3806
+ const COLLIDER_2D_TYPES = Object.freeze([
3807
+ BoxCollider2D,
3808
+ CircleCollider2D,
3809
+ CapsuleCollider2D,
3810
+ PolygonCollider2D,
3811
+ EdgeCollider2D,
3812
+ TilemapCollider2D
3813
+ ]);
3814
+ /**
3815
+ * Marks every collider and controller that already exists when the runtime is built.
3816
+ *
3817
+ * @remarks
3818
+ * A component attached before `onStart` — from a scene the `assets.preload` list pulled in, say —
3819
+ * called `markDirty` while the runtime was still `null`. This is the catch-up pass that makes those
3820
+ * bodies exist, and it walks the component store in registration order so body creation order stays
3821
+ * deterministic (`09-physics.md` §8).
3822
+ *
3823
+ * @param app - The app being started.
3824
+ * @param host - The holder the runtime landed in.
3825
+ */
3826
+ function seedExistingBodies(app, host) {
3827
+ const runtime = host.runtime;
3828
+ if (runtime === null) return;
3829
+ for (const type of COLLIDER_2D_TYPES) {
3830
+ const components = app.world.components(type);
3831
+ for (let index = 0; index < components.length; index += 1) {
3832
+ const collider = components[index];
3833
+ if (collider !== void 0) runtime.markDirty(collider.entity);
3834
+ }
3835
+ }
3836
+ const controllers = app.world.components(CharacterController2D);
3837
+ for (let index = 0; index < controllers.length; index += 1) {
3838
+ const controller = controllers[index];
3839
+ if (controller !== void 0) runtime.markDirty(controller.entity);
3840
+ }
3841
+ }
3842
+ //#endregion
3843
+ //#region src/schemas.ts
3844
+ /**
3845
+ * The documentation harness's view of everything `@ignifx/physics-2d` declares
3846
+ * (`docs/architecture/16-docs-harness-and-skill.md` §3, `scripts/README.md`).
3847
+ *
3848
+ * The `ignifx.physicsmaterial` **file format** is deliberately absent: `@ignifx/physics` already
3849
+ * documents it and this package reads the very same document (`material.ts`), so describing it
3850
+ * twice would render the same table on the formats page twice — the harness keeps the first
3851
+ * declaration and drops the rest (`scripts/lib/schema-source.ts`).
3852
+ */
3853
+ /**
3854
+ * Describes every component this package declares.
3855
+ *
3856
+ * @returns The records, keyed by namespaced type id.
3857
+ *
3858
+ * @example
3859
+ * ```ts
3860
+ * const schemas = describeSchemas();
3861
+ * schemas["ignifx/Rigidbody2D"].fields["mass"].default; // 1
3862
+ * ```
3863
+ *
3864
+ * @public
3865
+ */
3866
+ function describeSchemas() {
3867
+ return {
3868
+ "ignifx/Rigidbody2D": describeSchema("ignifx/Rigidbody2D", Rigidbody2D.schema, { description: "Makes an entity's 2D colliders a dynamic, kinematic, or static Rapier body." }),
3869
+ "ignifx/BoxCollider2D": describeSchema("ignifx/BoxCollider2D", BoxCollider2D.schema, { description: "An axis-aligned box, sized in local metres and scaled by the entity." }),
3870
+ "ignifx/CircleCollider2D": describeSchema("ignifx/CircleCollider2D", CircleCollider2D.schema, { description: "A circle; the larger scale axis wins." }),
3871
+ "ignifx/CapsuleCollider2D": describeSchema("ignifx/CapsuleCollider2D", CapsuleCollider2D.schema, { description: "A capsule standing along X or Y." }),
3872
+ "ignifx/PolygonCollider2D": describeSchema("ignifx/PolygonCollider2D", PolygonCollider2D.schema, { description: "The convex hull of a point list, in local metres." }),
3873
+ "ignifx/EdgeCollider2D": describeSchema("ignifx/EdgeCollider2D", EdgeCollider2D.schema, { description: "An open chain of line segments — a platformer's ground contour." }),
3874
+ "ignifx/TilemapCollider2D": describeSchema("ignifx/TilemapCollider2D", TilemapCollider2D.schema, { description: "The merged collision surface of a Tilemap, rebuilt when its tiles change." }),
3875
+ "ignifx/CharacterController2D": describeSchema("ignifx/CharacterController2D", CharacterController2D.schema, { description: "A kinematic character with collide-and-slide, slopes, autostep, and snap-to-ground." })
3876
+ };
3877
+ }
3878
+ //#endregion
3879
+ export { BODY_TYPES_2D, BoxCollider2D, CAPSULE_DIRECTIONS_2D, CHARACTER_SHAPES_2D, COLLISION_EVENT_MODES_2D, COMBINE_RULES, CapsuleCollider2D, CharacterController2D, CircleCollider2D, Collider2D, EdgeCollider2D, INTERPOLATION_MODES_2D, PHYSICS_2D_DIAGNOSTICS_COUNTERS, PHYSICS_2D_DIAGNOSTICS_GROUP, PHYSICS_2D_ERROR_MESSAGES, PHYSICS_2D_SETTINGS_SECTION, PHYSICS_MATERIAL_2D_ASSET_TYPE, PHYSICS_MATERIAL_2D_FILE_EXTENSION, PHYSICS_MATERIAL_2D_FILE_FORMAT, PHYSICS_MATERIAL_2D_FORMAT_VERSION, Physics2DErrorCode, Physics2DService, PhysicsMaterial2D, PolygonCollider2D, Rigidbody2D, TilemapCollider2D, collider2DFields, createPhysicsMaterial2DLoader, defaultPhysics2DSettings, describeSchemas, parsePhysicsMaterial2D, physics2DError, physics2DSettingsSchema, physics2d };
3880
+
3881
+ //# sourceMappingURL=index.js.map