@ludra/physics 0.0.0-stage → 0.9.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ludra contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,57 @@
1
+ import { type World as RapierWorld } from "@dimforge/rapier2d-deterministic-compat";
2
+ import { type Entity, type PhysicsEngine, type PhysicsHost, type PhysicsSettings, type RayHit, type RayOptions } from "@ludra/core";
3
+ /**
4
+ * Loads Rapier's WebAssembly and registers the "rapier" engine, so scenes with
5
+ * `"physics": "rapier"` can be created. Safe to call more than once.
6
+ */
7
+ export declare function loadRapierPhysics(): Promise<void>;
8
+ /**
9
+ * Ludra's real physics engine: Rapier's deterministic build, so a simulation is
10
+ * bit-for-bit identical on every machine and in the browser, and tests stay valid.
11
+ * Bodies rotate, can be circles or capsules, and have friction and mass.
12
+ */
13
+ export declare class RapierPhysics implements PhysicsEngine {
14
+ readonly name = "rapier";
15
+ /** The Rapier world itself, for anything the components don't cover. */
16
+ readonly world: RapierWorld;
17
+ private readonly events;
18
+ private readonly links;
19
+ private readonly byCollider;
20
+ /** Joints (entity and index in its list) waiting for the body on their other end. */
21
+ private pendingJoints;
22
+ constructor(settings: PhysicsSettings);
23
+ add(entity: Entity): void;
24
+ remove(entity: Entity): void;
25
+ /** Creates the joints whose two ends now both exist. */
26
+ private connectJoints;
27
+ /**
28
+ * A character controller moves a player the way games expect rather than the way
29
+ * rigid bodies do: up slopes without sliding back while standing, onto small steps,
30
+ * along walls without sticking, and into crates, pushing them.
31
+ */
32
+ private createController;
33
+ step(host: PhysicsHost): void;
34
+ /**
35
+ * Moves a player by its velocity (plus gravity for platformers, and the platform it
36
+ * stands on) through the character controller, which resolves slopes, steps and walls.
37
+ */
38
+ private moveCharacter;
39
+ /** A body is grounded when a solid touches it from below; it then stands on that solid. */
40
+ private updateGround;
41
+ mass(e: Entity): number;
42
+ applyImpulse(e: Entity, x: number, y: number, at?: {
43
+ x: number;
44
+ y: number;
45
+ }): void;
46
+ angularVelocity(e: Entity): number;
47
+ setAngularVelocity(e: Entity, value: number): void;
48
+ raycast(_host: PhysicsHost, from: {
49
+ x: number;
50
+ y: number;
51
+ }, to: {
52
+ x: number;
53
+ y: number;
54
+ }, options?: RayOptions): RayHit | undefined;
55
+ raw(resolve: (target: unknown) => Entity | undefined): unknown;
56
+ overlaps(a: Entity, b: Entity, margin?: number): boolean;
57
+ }
package/dist/index.js ADDED
@@ -0,0 +1,518 @@
1
+ import RAPIER, {} from "@dimforge/rapier2d-deterministic-compat";
2
+ import { at, clampToPatrol, clampToWorld, feet, FIXED_DT, registerPhysics, } from "@ludra/core";
3
+ /**
4
+ * Rapier works in any unit; this tells it how big "one meter" is in pixels, so its
5
+ * tolerances suit pixel-sized games. Positions, sizes and velocities stay in pixels.
6
+ */
7
+ /**
8
+ * Ludra's +y points down; Rapier runs with its natural +y up (its character controller
9
+ * only handles slopes correctly that way). Everything crossing over is mirrored in y:
10
+ * positions, velocities, gravity, and angles (a clockwise turn on screen is negative in Rapier).
11
+ */
12
+ function toRapier(x, y) {
13
+ return { x, y: -y };
14
+ }
15
+ /** A point or vector from Rapier's y-up space back to Ludra's. */
16
+ function fromRapier(v) {
17
+ return { x: v.x, y: -v.y };
18
+ }
19
+ /** How firmly a joint motor drives toward its target speed (it is capped by motorForce). */
20
+ const MOTOR_FACTOR = 1000;
21
+ const PIXELS_PER_METER = 50;
22
+ const DEGREES = Math.PI / 180;
23
+ /** Impacts slower than this (px/s) don't count as a bounce, as with arcade physics. */
24
+ const MIN_BOUNCE_SPEED = 40;
25
+ /** A contact whose normal points this much downward (from the body) is ground under it. */
26
+ const GROUND_NORMAL = 0.7;
27
+ /** Players fall no faster than this, as with arcade physics. */
28
+ const MAX_FALL_SPEED = 1400;
29
+ /** The gap a character controller keeps from obstacles, in px. */
30
+ const CONTROLLER_OFFSET = 0.5;
31
+ /** Players stay on the ground when walking down slopes and off small bumps up to this far, in px. */
32
+ const SNAP_TO_GROUND = 6;
33
+ /**
34
+ * A standing player that loses contact (it lands a hair above the ground, or walks off a ledge)
35
+ * still counts as grounded for this many steps: no flickering, and a moment to jump.
36
+ */
37
+ const COYOTE_STEPS = 3;
38
+ let loading;
39
+ /**
40
+ * Loads Rapier's WebAssembly and registers the "rapier" engine, so scenes with
41
+ * `"physics": "rapier"` can be created. Safe to call more than once.
42
+ */
43
+ export function loadRapierPhysics() {
44
+ loading ??= RAPIER.init().then(() => registerPhysics("rapier", (settings) => new RapierPhysics(settings)));
45
+ return loading;
46
+ }
47
+ // Rapier's memory lives in WebAssembly; free it when a world is garbage collected.
48
+ const cleanup = new FinalizationRegistry((world) => world.free());
49
+ /**
50
+ * Ludra's real physics engine: Rapier's deterministic build, so a simulation is
51
+ * bit-for-bit identical on every machine and in the browser, and tests stay valid.
52
+ * Bodies rotate, can be circles or capsules, and have friction and mass.
53
+ */
54
+ export class RapierPhysics {
55
+ name = "rapier";
56
+ /** The Rapier world itself, for anything the components don't cover. */
57
+ world;
58
+ events = new RAPIER.EventQueue(true);
59
+ links = new Map();
60
+ byCollider = new Map();
61
+ /** Joints (entity and index in its list) waiting for the body on their other end. */
62
+ pendingJoints = [];
63
+ constructor(settings) {
64
+ this.world = new RAPIER.World(toRapier(0, settings.gravity));
65
+ this.world.timestep = FIXED_DT;
66
+ this.world.integrationParameters.lengthUnit = PIXELS_PER_METER;
67
+ cleanup.register(this, this.world);
68
+ }
69
+ add(entity) {
70
+ const b = entity.body;
71
+ if (!b)
72
+ return;
73
+ const t = entity.transform;
74
+ const player = isPlayer(entity);
75
+ // Scenes put players exactly on the ground; a character controller starting in contact
76
+ // can't keep its gap and sinks in, so lift it clear first.
77
+ if (player)
78
+ t.y -= CONTROLLER_OFFSET;
79
+ const desc = b.type === "dynamic" && !player
80
+ ? RAPIER.RigidBodyDesc.dynamic()
81
+ : b.type === "static"
82
+ ? RAPIER.RigidBodyDesc.fixed()
83
+ : RAPIER.RigidBodyDesc.kinematicPositionBased();
84
+ desc.setTranslation(t.x, -t.y).setRotation(-t.rotation * DEGREES);
85
+ if (b.type === "dynamic" && !player) {
86
+ desc.setGravityScale(entity.topdown ? 0 : b.gravityScale).setLinvel(b.vx, -b.vy).setCcdEnabled(b.bullet);
87
+ // Players and fixed-rotation bodies stay upright.
88
+ if (b.fixedRotation || entity.platformer || entity.topdown)
89
+ desc.lockRotations();
90
+ }
91
+ const body = this.world.createRigidBody(desc);
92
+ const collider = this.world.createCollider(shape(b)
93
+ .setSensor(b.sensor)
94
+ .setRestitution(b.bounce)
95
+ // A bouncy ball stays bouncy against a dull wall, as with arcade physics.
96
+ .setRestitutionCombineRule(RAPIER.CoefficientCombineRule.Max)
97
+ .setFriction(b.friction)
98
+ .setDensity(b.density)
99
+ .setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS)
100
+ // Detect every pair, including sensors against static and kinematic bodies.
101
+ .setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL), body);
102
+ const link = { entity, body, collider, x: t.x, y: t.y, rotation: t.rotation, angle: body.rotation(), dx: 0, dy: 0, teleported: false, airborne: Infinity, joints: [], anchors: [] };
103
+ if (player)
104
+ link.controller = this.createController(entity);
105
+ entity.joints?.forEach((_, index) => this.pendingJoints.push({ entity, index }));
106
+ this.links.set(entity, link);
107
+ this.byCollider.set(collider.handle, link);
108
+ }
109
+ remove(entity) {
110
+ const link = this.links.get(entity);
111
+ if (!link)
112
+ return;
113
+ this.byCollider.delete(link.collider.handle);
114
+ this.links.delete(entity);
115
+ if (link.controller)
116
+ this.world.removeCharacterController(link.controller);
117
+ // Removing a body removes the joints attached to it.
118
+ this.world.removeRigidBody(link.body);
119
+ for (const anchor of link.anchors)
120
+ this.world.removeRigidBody(anchor);
121
+ this.pendingJoints = this.pendingJoints.filter((pending) => pending.entity !== entity);
122
+ }
123
+ /** Creates the joints whose two ends now both exist. */
124
+ connectJoints(host) {
125
+ if (this.pendingJoints.length === 0)
126
+ return;
127
+ const waiting = [];
128
+ for (const pending of this.pendingJoints) {
129
+ const { entity, index } = pending;
130
+ const link = this.links.get(entity);
131
+ const spec = entity.joints[index];
132
+ const target = spec.to ? host.entities.find((e) => e.id === spec.to) : undefined;
133
+ const other = target ? this.links.get(target) : undefined;
134
+ if (!link || (spec.to && !other)) {
135
+ waiting.push(pending);
136
+ continue;
137
+ }
138
+ // Where this end of the joint is, in the world (the anchor turns with the body).
139
+ const here = worldPoint(link.body, spec.anchor);
140
+ // Hinges, sliders and welds join both bodies at that same point; ropes and springs
141
+ // run from there to their other end (toAnchor).
142
+ const sameSpot = spec.type === "revolute" || spec.type === "prismatic" || spec.type === "fixed";
143
+ let base;
144
+ let there;
145
+ if (other) {
146
+ base = other.body;
147
+ there = sameSpot ? here : worldPoint(other.body, spec.toAnchor);
148
+ }
149
+ else {
150
+ // A fixed point in the world: hang the joint on an invisible fixed body there.
151
+ there = sameSpot ? here : { x: entity.transform.x + spec.toAnchor.x, y: entity.transform.y + spec.toAnchor.y };
152
+ base = this.world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(there.x, -there.y));
153
+ link.anchors.push(base);
154
+ }
155
+ // The other body (or the world) comes first, so Rapier measures this body relative to it.
156
+ const baseAnchor = localPoint(base, there);
157
+ const ownAnchor = toRapier(spec.anchor.x, spec.anchor.y);
158
+ const length = spec.length || Math.hypot(there.x - here.x, there.y - here.y);
159
+ const data = spec.type === "prismatic"
160
+ ? RAPIER.JointData.prismatic(baseAnchor, ownAnchor, rotateBack(base, toRapier(Math.cos(spec.axis * DEGREES), Math.sin(spec.axis * DEGREES))))
161
+ : spec.type === "fixed"
162
+ ? RAPIER.JointData.fixed(baseAnchor, 0, ownAnchor, base.rotation() - link.body.rotation())
163
+ : spec.type === "rope"
164
+ ? RAPIER.JointData.rope(length, baseAnchor, ownAnchor)
165
+ : spec.type === "spring"
166
+ ? RAPIER.JointData.spring(length, spec.stiffness, spec.damping, baseAnchor, ownAnchor)
167
+ : RAPIER.JointData.revolute(baseAnchor, ownAnchor);
168
+ const joint = this.world.createImpulseJoint(data, base, link.body, true);
169
+ joint.setContactsEnabled(spec.collide);
170
+ if (spec.type === "revolute" || spec.type === "prismatic") {
171
+ const unit = joint;
172
+ // Rapier is y-up: a clockwise angle is negative there. Positions along the axis keep their sign.
173
+ if (spec.min < spec.max) {
174
+ if (spec.type === "revolute")
175
+ unit.setLimits(-spec.max * DEGREES, -spec.min * DEGREES);
176
+ else
177
+ unit.setLimits(spec.min, spec.max);
178
+ }
179
+ if (spec.motorForce > 0) {
180
+ unit.setMotorMaxForce(spec.motorForce);
181
+ unit.configureMotorVelocity(spec.type === "revolute" ? -spec.motorSpeed * DEGREES : spec.motorSpeed, MOTOR_FACTOR);
182
+ }
183
+ }
184
+ link.joints[index] = joint;
185
+ }
186
+ this.pendingJoints = waiting;
187
+ }
188
+ /**
189
+ * A character controller moves a player the way games expect rather than the way
190
+ * rigid bodies do: up slopes without sliding back while standing, onto small steps,
191
+ * along walls without sticking, and into crates, pushing them.
192
+ */
193
+ createController(entity) {
194
+ const b = entity.body;
195
+ const p = entity.platformer;
196
+ const controller = this.world.createCharacterController(CONTROLLER_OFFSET);
197
+ controller.setSlideEnabled(true);
198
+ const push = p?.push ?? 1;
199
+ controller.setApplyImpulsesToDynamicBodies(push > 0);
200
+ if (push > 0)
201
+ controller.setCharacterMass(b.density * b.width * b.height * push);
202
+ if (p) {
203
+ controller.setMaxSlopeClimbAngle(p.maxSlope * DEGREES);
204
+ controller.setMinSlopeSlideAngle(p.maxSlope * DEGREES);
205
+ controller.enableSnapToGround(Math.max(SNAP_TO_GROUND, p.stepHeight));
206
+ if (p.stepHeight > 0)
207
+ controller.enableAutostep(p.stepHeight, b.width / 2, false);
208
+ }
209
+ return controller;
210
+ }
211
+ step(host) {
212
+ this.connectJoints(host);
213
+ // Hand gameplay's changes to Rapier: teleports, velocities, kinematic moves.
214
+ for (const link of this.links.values()) {
215
+ const { entity: e, body } = link;
216
+ const b = e.body;
217
+ const t = e.transform;
218
+ link.teleported = t.x !== link.x || t.y !== link.y || t.rotation !== link.rotation;
219
+ if (link.teleported) {
220
+ body.setTranslation(toRapier(t.x, t.y), true);
221
+ body.setRotation(-t.rotation * DEGREES, true);
222
+ link.angle = body.rotation();
223
+ }
224
+ if (link.controller)
225
+ continue;
226
+ if (b.type === "dynamic") {
227
+ body.setLinvel(toRapier(b.vx, b.vy), true);
228
+ }
229
+ else if (b.type === "kinematic") {
230
+ const { x, y } = t;
231
+ t.x += b.vx * FIXED_DT;
232
+ t.y += b.vy * FIXED_DT;
233
+ if (e.patrol)
234
+ clampToPatrol(e, e.patrol);
235
+ body.setNextKinematicTranslation(toRapier(t.x, t.y));
236
+ link.dx = t.x - x;
237
+ link.dy = t.y - y;
238
+ }
239
+ }
240
+ for (const link of this.links.values())
241
+ if (link.controller)
242
+ this.moveCharacter(link, host);
243
+ this.world.step(this.events);
244
+ // Read the results back into the entities.
245
+ for (const link of this.links.values()) {
246
+ const { entity: e, body } = link;
247
+ const b = e.body;
248
+ if (b.type !== "static") {
249
+ const t = e.transform;
250
+ const r = body.translation();
251
+ const p = { x: r.x, y: -r.y };
252
+ t.x = p.x;
253
+ t.y = p.y;
254
+ // Rapier wraps angles to ±180°; accumulate instead, so a ball that rolled two turns reads 720.
255
+ const angle = body.rotation();
256
+ let turn = angle - link.angle;
257
+ if (turn > Math.PI)
258
+ turn -= 2 * Math.PI;
259
+ else if (turn < -Math.PI)
260
+ turn += 2 * Math.PI;
261
+ if (turn !== 0)
262
+ t.rotation -= turn / DEGREES;
263
+ link.angle = angle;
264
+ if (b.type === "dynamic" && !link.controller) {
265
+ const v = body.linvel();
266
+ b.vx = v.x;
267
+ b.vy = -v.y;
268
+ if (e.topdown?.clampToWorld) {
269
+ clampToWorld(e, host);
270
+ if (t.x !== p.x || t.y !== p.y)
271
+ body.setTranslation(toRapier(t.x, t.y), true);
272
+ }
273
+ }
274
+ }
275
+ link.x = e.transform.x;
276
+ link.y = e.transform.y;
277
+ link.rotation = e.transform.rotation;
278
+ }
279
+ for (const link of this.links.values()) {
280
+ if (link.entity.body.type === "dynamic" && !link.controller)
281
+ this.updateGround(link, host);
282
+ }
283
+ this.events.drainCollisionEvents((handle1, handle2, started) => {
284
+ if (!started)
285
+ return;
286
+ const a = this.byCollider.get(handle1);
287
+ const c = this.byCollider.get(handle2);
288
+ if (!a || !c || a.collider.isSensor() || c.collider.isSensor())
289
+ return;
290
+ for (const [self, other] of [
291
+ [a, c],
292
+ [c, a],
293
+ ]) {
294
+ const b = self.entity.body;
295
+ if (b.type !== "dynamic" || b.bounce <= 0 || Math.hypot(b.vx, b.vy) < MIN_BOUNCE_SPEED)
296
+ continue;
297
+ host.emit({ type: "bounce", entity: self.entity.id, other: other.entity.id, ...at(self.entity) });
298
+ }
299
+ });
300
+ }
301
+ /**
302
+ * Moves a player by its velocity (plus gravity for platformers, and the platform it
303
+ * stands on) through the character controller, which resolves slopes, steps and walls.
304
+ */
305
+ moveCharacter(link, host) {
306
+ const { entity: e, body, collider } = link;
307
+ const controller = link.controller;
308
+ const b = e.body;
309
+ const t = e.transform;
310
+ const falls = !!e.platformer && b.type === "dynamic";
311
+ const wasGrounded = b.grounded;
312
+ if (link.teleported) {
313
+ // A respawn: start from rest where gameplay put it, just clear of the ground.
314
+ t.y -= CONTROLLER_OFFSET;
315
+ body.setTranslation(toRapier(t.x, t.y), true);
316
+ body.setNextKinematicTranslation(toRapier(t.x, t.y));
317
+ b.grounded = false;
318
+ b.standingOn = undefined;
319
+ link.airborne = Infinity;
320
+ return;
321
+ }
322
+ // Gravity only while not touching the ground (or jumping off): pressing a standing player into
323
+ // the ground makes round-bottomed ones slide and lose speed. Snapping keeps it down on slopes.
324
+ if (falls)
325
+ b.vy = link.airborne === 0 && b.vy >= 0 ? 0 : Math.min(b.vy + host.gravity * b.gravityScale * FIXED_DT, MAX_FALL_SPEED);
326
+ const ownX = b.vx * FIXED_DT;
327
+ const ownY = b.vy * FIXED_DT;
328
+ // Ride along with a moving platform (Rapier's controller doesn't carry a standing character).
329
+ const carrier = b.standingOn ? this.links.get(b.standingOn) : undefined;
330
+ const carryX = carrier?.dx ?? 0;
331
+ const carryY = carrier?.dy ?? 0;
332
+ controller.computeColliderMovement(collider, toRapier(ownX + carryX, ownY + carryY), RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, undefined, (other) => other.handle !== collider.handle);
333
+ const computed = controller.computedMovement();
334
+ const moved = { x: computed.x, y: -computed.y };
335
+ t.x += moved.x;
336
+ t.y += moved.y;
337
+ if (e.topdown?.clampToWorld)
338
+ clampToWorld(e, host);
339
+ body.setNextKinematicTranslation(toRapier(t.x, t.y));
340
+ // Ground: what's under the player now, if it fell or walked onto something.
341
+ const touching = falls && controller.computedGrounded();
342
+ link.airborne = touching ? 0 : link.airborne + 1;
343
+ b.grounded = touching || (falls && wasGrounded && b.vy >= 0 && link.airborne <= COYOTE_STEPS);
344
+ const previous = b.standingOn;
345
+ b.standingOn = undefined;
346
+ if (b.grounded) {
347
+ for (let i = 0; i < controller.numComputedCollisions(); i++) {
348
+ const hit = controller.computedCollision(i);
349
+ const other = hit?.collider ? this.byCollider.get(hit.collider.handle) : undefined;
350
+ if (other && other.entity.transform.y > t.y && Math.abs(hit.normal1.y) > GROUND_NORMAL)
351
+ b.standingOn = other.entity;
352
+ }
353
+ // Snapping down a slope reports no collision: it still stands where it stood.
354
+ b.standingOn ??= previous;
355
+ }
356
+ if (touching && b.vy > 0)
357
+ b.vy = 0;
358
+ // Bumped its head: stop rising. Only a ceiling counts (normals point at the player, y-up),
359
+ // not a wall it slides up, which shaves a hair off the rise.
360
+ if (ownY < 0 && moved.y - carryY > ownY + 1e-3) {
361
+ for (let i = 0; i < controller.numComputedCollisions(); i++) {
362
+ if (controller.computedCollision(i).normal1.y < -GROUND_NORMAL)
363
+ b.vy = 0;
364
+ }
365
+ }
366
+ if (b.grounded && !wasGrounded)
367
+ host.emit({ type: "land", entity: e.id, ...feet(e) });
368
+ }
369
+ /** A body is grounded when a solid touches it from below; it then stands on that solid. */
370
+ updateGround(link, host) {
371
+ const b = link.entity.body;
372
+ const wasGrounded = b.grounded;
373
+ b.grounded = false;
374
+ b.standingOn = undefined;
375
+ this.world.contactPairsWith(link.collider, (other) => {
376
+ if (b.grounded || other.isSensor())
377
+ return;
378
+ this.world.contactPair(link.collider, other, (manifold, flipped) => {
379
+ if (b.grounded || manifold.numContacts() === 0)
380
+ return;
381
+ // The normal points from the first collider to the second (flipped swaps them), in
382
+ // Rapier's y-up space: a solid below the body has a normal pointing to -y.
383
+ const down = flipped ? manifold.normal().y : -manifold.normal().y;
384
+ if (down > GROUND_NORMAL) {
385
+ b.grounded = true;
386
+ b.standingOn = this.byCollider.get(other.handle)?.entity;
387
+ }
388
+ });
389
+ });
390
+ if (b.grounded && !wasGrounded)
391
+ host.emit({ type: "land", entity: link.entity.id, ...feet(link.entity) });
392
+ }
393
+ mass(e) {
394
+ return this.links.get(e)?.collider.mass() ?? 0;
395
+ }
396
+ applyImpulse(e, x, y, at) {
397
+ const link = this.links.get(e);
398
+ const b = e.body;
399
+ if (!link || !b || b.type === "static")
400
+ return;
401
+ if (link.controller) {
402
+ // Players move by velocity: a kick changes it directly.
403
+ const mass = link.collider.mass() || 1;
404
+ b.vx += x / mass;
405
+ b.vy += y / mass;
406
+ return;
407
+ }
408
+ if (b.type !== "dynamic")
409
+ return;
410
+ if (at)
411
+ link.body.applyImpulseAtPoint(toRapier(x, y), toRapier(at.x, at.y), true);
412
+ else
413
+ link.body.applyImpulse(toRapier(x, y), true);
414
+ // Keep the entity's velocity in step, since it is written back to Rapier each step.
415
+ const v = fromRapier(link.body.linvel());
416
+ b.vx = v.x;
417
+ b.vy = v.y;
418
+ }
419
+ angularVelocity(e) {
420
+ const body = this.links.get(e)?.body;
421
+ return body ? -body.angvel() / DEGREES : 0;
422
+ }
423
+ setAngularVelocity(e, value) {
424
+ const link = this.links.get(e);
425
+ if (link && e.body?.type === "dynamic" && !link.controller)
426
+ link.body.setAngvel(-value * DEGREES, true);
427
+ }
428
+ raycast(_host, from, to, options = {}) {
429
+ const dx = to.x - from.x;
430
+ const dy = to.y - from.y;
431
+ const length = Math.hypot(dx, dy);
432
+ if (length === 0)
433
+ return undefined;
434
+ const origin = toRapier(from.x, from.y);
435
+ const ignored = new Set((options.ignore ?? []).map((e) => this.links.get(e)?.collider.handle));
436
+ const hit = this.world.castRayAndGetNormal(new RAPIER.Ray(origin, toRapier(dx, dy)), 1, true, options.sensors ? undefined : RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, undefined, undefined, undefined,
437
+ // Like arcade: a ray starting inside a body doesn't hit it, and only Ludra's bodies count.
438
+ (collider) => this.byCollider.has(collider.handle) && !ignored.has(collider.handle) && !collider.containsPoint(origin));
439
+ if (!hit)
440
+ return undefined;
441
+ const link = this.byCollider.get(hit.collider.handle);
442
+ const t = hit.timeOfImpact;
443
+ return { entity: link.entity, x: from.x + dx * t, y: from.y + dy * t, distance: t * length, normal: fromRapier(hit.normal) };
444
+ }
445
+ raw(resolve) {
446
+ const linkOf = (target) => {
447
+ const e = resolve(target);
448
+ return e ? this.links.get(e) : undefined;
449
+ };
450
+ return {
451
+ RAPIER,
452
+ world: this.world,
453
+ body: (target) => linkOf(target)?.body,
454
+ collider: (target) => linkOf(target)?.collider,
455
+ joint: (target, index = 0) => linkOf(target)?.joints[index],
456
+ toRapier: (p) => toRapier(p.x, p.y),
457
+ fromRapier,
458
+ };
459
+ }
460
+ overlaps(a, b, margin = 0) {
461
+ const la = this.links.get(a);
462
+ const lb = this.links.get(b);
463
+ if (!la || !lb)
464
+ return false;
465
+ if (la.collider.isSensor() || lb.collider.isSensor())
466
+ return this.world.intersectionPair(la.collider, lb.collider);
467
+ let hit = false;
468
+ this.world.contactPair(la.collider, lb.collider, (manifold) => {
469
+ for (let i = 0; i < manifold.numContacts(); i++)
470
+ if (manifold.contactDist(i) < margin)
471
+ hit = true;
472
+ });
473
+ return hit;
474
+ }
475
+ }
476
+ /** A point given as an offset from a body's center (in its own, turning frame), in Ludra's world. */
477
+ function worldPoint(body, offset) {
478
+ const p = body.translation();
479
+ const angle = body.rotation();
480
+ const local = toRapier(offset.x, offset.y);
481
+ const cos = Math.cos(angle);
482
+ const sin = Math.sin(angle);
483
+ return fromRapier({ x: p.x + local.x * cos - local.y * sin, y: p.y + local.x * sin + local.y * cos });
484
+ }
485
+ /** A Ludra world point in a body's own Rapier frame. */
486
+ function localPoint(body, point) {
487
+ const p = body.translation();
488
+ const r = toRapier(point.x, point.y);
489
+ return rotateBack(body, { x: r.x - p.x, y: r.y - p.y });
490
+ }
491
+ /** A Rapier world vector in a body's own frame. */
492
+ function rotateBack(body, v) {
493
+ const cos = Math.cos(-body.rotation());
494
+ const sin = Math.sin(-body.rotation());
495
+ return { x: v.x * cos - v.y * sin, y: v.x * sin + v.y * cos };
496
+ }
497
+ function isPlayer(e) {
498
+ return !!(e.platformer || e.topdown) && !!e.body && e.body.type !== "static";
499
+ }
500
+ function shape(b) {
501
+ if (b.shape === "circle")
502
+ return RAPIER.ColliderDesc.ball(b.width / 2);
503
+ if (b.shape === "capsule") {
504
+ // Rapier's capsules stand upright; lay it down when the body is wider than tall.
505
+ if (b.height >= b.width)
506
+ return RAPIER.ColliderDesc.capsule((b.height - b.width) / 2, b.width / 2);
507
+ return RAPIER.ColliderDesc.capsule((b.width - b.height) / 2, b.height / 2).setRotation(Math.PI / 2);
508
+ }
509
+ if (b.shape === "polygon" || b.shape === "polyline") {
510
+ // Points are mirrored into Rapier's y-up space.
511
+ const points = new Float32Array(b.points.flatMap(([x, y]) => [x, -y]));
512
+ if (b.shape === "polyline")
513
+ return RAPIER.ColliderDesc.polyline(points);
514
+ // A degenerate polygon (all points on a line) falls back to the bounding box.
515
+ return RAPIER.ColliderDesc.convexHull(points) ?? RAPIER.ColliderDesc.cuboid(b.width / 2, b.height / 2);
516
+ }
517
+ return RAPIER.ColliderDesc.cuboid(b.width / 2, b.height / 2);
518
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@ludra/physics",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.9.0",
4
+ "description": "Rapier physics for Ludra: rotation, circles and capsules, friction and mass, deterministic across machines.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ersingvn/ludra.git",
9
+ "directory": "packages/physics"
10
+ },
11
+ "homepage": "https://github.com/ersingvn/ludra#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/ersingvn/ludra/issues"
14
+ },
15
+ "engines": {
16
+ "node": ">=22.18"
17
+ },
18
+ "scripts": {
19
+ "prepack": "tsc -b --clean && tsc -b"
20
+ },
21
+ "type": "module",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "ludra-dev": "./src/index.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "src"
32
+ ],
33
+ "dependencies": {
34
+ "@dimforge/rapier2d-deterministic-compat": "0.21.0",
35
+ "@ludra/core": "0.9.0"
36
+ },
37
+ "keywords": [
38
+ "game-engine",
39
+ "physics",
40
+ "rapier",
41
+ "deterministic",
42
+ "ai",
43
+ "agents"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,565 @@
1
+ import RAPIER, {
2
+ type Collider,
3
+ type ColliderDesc,
4
+ type ImpulseJoint,
5
+ type KinematicCharacterController,
6
+ type RigidBody,
7
+ type World as RapierWorld,
8
+ } from "@dimforge/rapier2d-deterministic-compat";
9
+ import {
10
+ at,
11
+ clampToPatrol,
12
+ clampToWorld,
13
+ feet,
14
+ FIXED_DT,
15
+ registerPhysics,
16
+ type Body,
17
+ type Entity,
18
+ type PhysicsEngine,
19
+ type PhysicsHost,
20
+ type PhysicsSettings,
21
+ type RayHit,
22
+ type RayOptions,
23
+ } from "@ludra/core";
24
+
25
+ /**
26
+ * Rapier works in any unit; this tells it how big "one meter" is in pixels, so its
27
+ * tolerances suit pixel-sized games. Positions, sizes and velocities stay in pixels.
28
+ */
29
+ /**
30
+ * Ludra's +y points down; Rapier runs with its natural +y up (its character controller
31
+ * only handles slopes correctly that way). Everything crossing over is mirrored in y:
32
+ * positions, velocities, gravity, and angles (a clockwise turn on screen is negative in Rapier).
33
+ */
34
+ function toRapier(x: number, y: number): { x: number; y: number } {
35
+ return { x, y: -y };
36
+ }
37
+
38
+ /** A point or vector from Rapier's y-up space back to Ludra's. */
39
+ function fromRapier(v: { x: number; y: number }): { x: number; y: number } {
40
+ return { x: v.x, y: -v.y };
41
+ }
42
+
43
+ /** How firmly a joint motor drives toward its target speed (it is capped by motorForce). */
44
+ const MOTOR_FACTOR = 1000;
45
+
46
+ const PIXELS_PER_METER = 50;
47
+ const DEGREES = Math.PI / 180;
48
+ /** Impacts slower than this (px/s) don't count as a bounce, as with arcade physics. */
49
+ const MIN_BOUNCE_SPEED = 40;
50
+ /** A contact whose normal points this much downward (from the body) is ground under it. */
51
+ const GROUND_NORMAL = 0.7;
52
+ /** Players fall no faster than this, as with arcade physics. */
53
+ const MAX_FALL_SPEED = 1400;
54
+ /** The gap a character controller keeps from obstacles, in px. */
55
+ const CONTROLLER_OFFSET = 0.5;
56
+ /** Players stay on the ground when walking down slopes and off small bumps up to this far, in px. */
57
+ const SNAP_TO_GROUND = 6;
58
+ /**
59
+ * A standing player that loses contact (it lands a hair above the ground, or walks off a ledge)
60
+ * still counts as grounded for this many steps: no flickering, and a moment to jump.
61
+ */
62
+ const COYOTE_STEPS = 3;
63
+
64
+ /** A Ludra entity, its Rapier body and collider, and the transform last written to it. */
65
+ interface Link {
66
+ entity: Entity;
67
+ body: RigidBody;
68
+ collider: Collider;
69
+ x: number;
70
+ y: number;
71
+ rotation: number;
72
+ /** Rapier's own angle (radians, wrapped to ±π) at that time, to accumulate turns. */
73
+ angle: number;
74
+ /** Players are moved by a character controller instead of forces. */
75
+ controller?: KinematicCharacterController;
76
+ /** How far a kinematic mover moved in the last step, so players standing on it ride along. */
77
+ dx: number;
78
+ dy: number;
79
+ /** Gameplay moved the entity itself since the last step (a respawn). */
80
+ teleported: boolean;
81
+ /** Steps since a player last touched the ground. */
82
+ airborne: number;
83
+ /** The joints this entity's `joint` component made, in order, once both ends exist. */
84
+ joints: (ImpulseJoint | undefined)[];
85
+ /** For joints to a point in the world: the fixed bodies holding those points. */
86
+ anchors: RigidBody[];
87
+ }
88
+
89
+ let loading: Promise<void> | undefined;
90
+
91
+ /**
92
+ * Loads Rapier's WebAssembly and registers the "rapier" engine, so scenes with
93
+ * `"physics": "rapier"` can be created. Safe to call more than once.
94
+ */
95
+ export function loadRapierPhysics(): Promise<void> {
96
+ loading ??= RAPIER.init().then(() => registerPhysics("rapier", (settings) => new RapierPhysics(settings)));
97
+ return loading;
98
+ }
99
+
100
+ // Rapier's memory lives in WebAssembly; free it when a world is garbage collected.
101
+ const cleanup = new FinalizationRegistry<RapierWorld>((world) => world.free());
102
+
103
+ /**
104
+ * Ludra's real physics engine: Rapier's deterministic build, so a simulation is
105
+ * bit-for-bit identical on every machine and in the browser, and tests stay valid.
106
+ * Bodies rotate, can be circles or capsules, and have friction and mass.
107
+ */
108
+ export class RapierPhysics implements PhysicsEngine {
109
+ readonly name = "rapier";
110
+ /** The Rapier world itself, for anything the components don't cover. */
111
+ readonly world: RapierWorld;
112
+ private readonly events = new RAPIER.EventQueue(true);
113
+ private readonly links = new Map<Entity, Link>();
114
+ private readonly byCollider = new Map<number, Link>();
115
+ /** Joints (entity and index in its list) waiting for the body on their other end. */
116
+ private pendingJoints: { entity: Entity; index: number }[] = [];
117
+
118
+ constructor(settings: PhysicsSettings) {
119
+ this.world = new RAPIER.World(toRapier(0, settings.gravity));
120
+ this.world.timestep = FIXED_DT;
121
+ this.world.integrationParameters.lengthUnit = PIXELS_PER_METER;
122
+ cleanup.register(this, this.world);
123
+ }
124
+
125
+ add(entity: Entity): void {
126
+ const b = entity.body;
127
+ if (!b) return;
128
+ const t = entity.transform;
129
+ const player = isPlayer(entity);
130
+ // Scenes put players exactly on the ground; a character controller starting in contact
131
+ // can't keep its gap and sinks in, so lift it clear first.
132
+ if (player) t.y -= CONTROLLER_OFFSET;
133
+ const desc =
134
+ b.type === "dynamic" && !player
135
+ ? RAPIER.RigidBodyDesc.dynamic()
136
+ : b.type === "static"
137
+ ? RAPIER.RigidBodyDesc.fixed()
138
+ : RAPIER.RigidBodyDesc.kinematicPositionBased();
139
+ desc.setTranslation(t.x, -t.y).setRotation(-t.rotation * DEGREES);
140
+ if (b.type === "dynamic" && !player) {
141
+ desc.setGravityScale(entity.topdown ? 0 : b.gravityScale).setLinvel(b.vx, -b.vy).setCcdEnabled(b.bullet);
142
+ // Players and fixed-rotation bodies stay upright.
143
+ if (b.fixedRotation || entity.platformer || entity.topdown) desc.lockRotations();
144
+ }
145
+ const body = this.world.createRigidBody(desc);
146
+ const collider = this.world.createCollider(
147
+ shape(b)
148
+ .setSensor(b.sensor)
149
+ .setRestitution(b.bounce)
150
+ // A bouncy ball stays bouncy against a dull wall, as with arcade physics.
151
+ .setRestitutionCombineRule(RAPIER.CoefficientCombineRule.Max)
152
+ .setFriction(b.friction)
153
+ .setDensity(b.density)
154
+ .setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS)
155
+ // Detect every pair, including sensors against static and kinematic bodies.
156
+ .setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL),
157
+ body,
158
+ );
159
+ const link: Link = { entity, body, collider, x: t.x, y: t.y, rotation: t.rotation, angle: body.rotation(), dx: 0, dy: 0, teleported: false, airborne: Infinity, joints: [], anchors: [] };
160
+ if (player) link.controller = this.createController(entity);
161
+ entity.joints?.forEach((_, index) => this.pendingJoints.push({ entity, index }));
162
+ this.links.set(entity, link);
163
+ this.byCollider.set(collider.handle, link);
164
+ }
165
+
166
+ remove(entity: Entity): void {
167
+ const link = this.links.get(entity);
168
+ if (!link) return;
169
+ this.byCollider.delete(link.collider.handle);
170
+ this.links.delete(entity);
171
+ if (link.controller) this.world.removeCharacterController(link.controller);
172
+ // Removing a body removes the joints attached to it.
173
+ this.world.removeRigidBody(link.body);
174
+ for (const anchor of link.anchors) this.world.removeRigidBody(anchor);
175
+ this.pendingJoints = this.pendingJoints.filter((pending) => pending.entity !== entity);
176
+ }
177
+
178
+ /** Creates the joints whose two ends now both exist. */
179
+ private connectJoints(host: PhysicsHost): void {
180
+ if (this.pendingJoints.length === 0) return;
181
+ const waiting: { entity: Entity; index: number }[] = [];
182
+ for (const pending of this.pendingJoints) {
183
+ const { entity, index } = pending;
184
+ const link = this.links.get(entity);
185
+ const spec = entity.joints![index]!;
186
+ const target = spec.to ? host.entities.find((e) => e.id === spec.to) : undefined;
187
+ const other = target ? this.links.get(target) : undefined;
188
+ if (!link || (spec.to && !other)) {
189
+ waiting.push(pending);
190
+ continue;
191
+ }
192
+ // Where this end of the joint is, in the world (the anchor turns with the body).
193
+ const here = worldPoint(link.body, spec.anchor);
194
+ // Hinges, sliders and welds join both bodies at that same point; ropes and springs
195
+ // run from there to their other end (toAnchor).
196
+ const sameSpot = spec.type === "revolute" || spec.type === "prismatic" || spec.type === "fixed";
197
+ let base: RigidBody;
198
+ let there: { x: number; y: number };
199
+ if (other) {
200
+ base = other.body;
201
+ there = sameSpot ? here : worldPoint(other.body, spec.toAnchor);
202
+ } else {
203
+ // A fixed point in the world: hang the joint on an invisible fixed body there.
204
+ there = sameSpot ? here : { x: entity.transform.x + spec.toAnchor.x, y: entity.transform.y + spec.toAnchor.y };
205
+ base = this.world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(there.x, -there.y));
206
+ link.anchors.push(base);
207
+ }
208
+ // The other body (or the world) comes first, so Rapier measures this body relative to it.
209
+ const baseAnchor = localPoint(base, there);
210
+ const ownAnchor = toRapier(spec.anchor.x, spec.anchor.y);
211
+ const length = spec.length || Math.hypot(there.x - here.x, there.y - here.y);
212
+ const data =
213
+ spec.type === "prismatic"
214
+ ? RAPIER.JointData.prismatic(baseAnchor, ownAnchor, rotateBack(base, toRapier(Math.cos(spec.axis * DEGREES), Math.sin(spec.axis * DEGREES))))
215
+ : spec.type === "fixed"
216
+ ? RAPIER.JointData.fixed(baseAnchor, 0, ownAnchor, base.rotation() - link.body.rotation())
217
+ : spec.type === "rope"
218
+ ? RAPIER.JointData.rope(length, baseAnchor, ownAnchor)
219
+ : spec.type === "spring"
220
+ ? RAPIER.JointData.spring(length, spec.stiffness, spec.damping, baseAnchor, ownAnchor)
221
+ : RAPIER.JointData.revolute(baseAnchor, ownAnchor);
222
+ const joint = this.world.createImpulseJoint(data, base, link.body, true);
223
+ joint.setContactsEnabled(spec.collide);
224
+ if (spec.type === "revolute" || spec.type === "prismatic") {
225
+ const unit = joint as unknown as {
226
+ setLimits(min: number, max: number): void;
227
+ setMotorMaxForce(force: number): void;
228
+ configureMotorVelocity(velocity: number, factor: number): void;
229
+ };
230
+ // Rapier is y-up: a clockwise angle is negative there. Positions along the axis keep their sign.
231
+ if (spec.min < spec.max) {
232
+ if (spec.type === "revolute") unit.setLimits(-spec.max * DEGREES, -spec.min * DEGREES);
233
+ else unit.setLimits(spec.min, spec.max);
234
+ }
235
+ if (spec.motorForce > 0) {
236
+ unit.setMotorMaxForce(spec.motorForce);
237
+ unit.configureMotorVelocity(spec.type === "revolute" ? -spec.motorSpeed * DEGREES : spec.motorSpeed, MOTOR_FACTOR);
238
+ }
239
+ }
240
+ link.joints[index] = joint;
241
+ }
242
+ this.pendingJoints = waiting;
243
+ }
244
+
245
+ /**
246
+ * A character controller moves a player the way games expect rather than the way
247
+ * rigid bodies do: up slopes without sliding back while standing, onto small steps,
248
+ * along walls without sticking, and into crates, pushing them.
249
+ */
250
+ private createController(entity: Entity): KinematicCharacterController {
251
+ const b = entity.body!;
252
+ const p = entity.platformer;
253
+ const controller = this.world.createCharacterController(CONTROLLER_OFFSET);
254
+ controller.setSlideEnabled(true);
255
+ const push = p?.push ?? 1;
256
+ controller.setApplyImpulsesToDynamicBodies(push > 0);
257
+ if (push > 0) controller.setCharacterMass(b.density * b.width * b.height * push);
258
+ if (p) {
259
+ controller.setMaxSlopeClimbAngle(p.maxSlope * DEGREES);
260
+ controller.setMinSlopeSlideAngle(p.maxSlope * DEGREES);
261
+ controller.enableSnapToGround(Math.max(SNAP_TO_GROUND, p.stepHeight));
262
+ if (p.stepHeight > 0) controller.enableAutostep(p.stepHeight, b.width / 2, false);
263
+ }
264
+ return controller;
265
+ }
266
+
267
+ step(host: PhysicsHost): void {
268
+ this.connectJoints(host);
269
+ // Hand gameplay's changes to Rapier: teleports, velocities, kinematic moves.
270
+ for (const link of this.links.values()) {
271
+ const { entity: e, body } = link;
272
+ const b = e.body!;
273
+ const t = e.transform;
274
+ link.teleported = t.x !== link.x || t.y !== link.y || t.rotation !== link.rotation;
275
+ if (link.teleported) {
276
+ body.setTranslation(toRapier(t.x, t.y), true);
277
+ body.setRotation(-t.rotation * DEGREES, true);
278
+ link.angle = body.rotation();
279
+ }
280
+ if (link.controller) continue;
281
+ if (b.type === "dynamic") {
282
+ body.setLinvel(toRapier(b.vx, b.vy), true);
283
+ } else if (b.type === "kinematic") {
284
+ const { x, y } = t;
285
+ t.x += b.vx * FIXED_DT;
286
+ t.y += b.vy * FIXED_DT;
287
+ if (e.patrol) clampToPatrol(e, e.patrol);
288
+ body.setNextKinematicTranslation(toRapier(t.x, t.y));
289
+ link.dx = t.x - x;
290
+ link.dy = t.y - y;
291
+ }
292
+ }
293
+ for (const link of this.links.values()) if (link.controller) this.moveCharacter(link, host);
294
+
295
+ this.world.step(this.events);
296
+
297
+ // Read the results back into the entities.
298
+ for (const link of this.links.values()) {
299
+ const { entity: e, body } = link;
300
+ const b = e.body!;
301
+ if (b.type !== "static") {
302
+ const t = e.transform;
303
+ const r = body.translation();
304
+ const p = { x: r.x, y: -r.y };
305
+ t.x = p.x;
306
+ t.y = p.y;
307
+ // Rapier wraps angles to ±180°; accumulate instead, so a ball that rolled two turns reads 720.
308
+ const angle = body.rotation();
309
+ let turn = angle - link.angle;
310
+ if (turn > Math.PI) turn -= 2 * Math.PI;
311
+ else if (turn < -Math.PI) turn += 2 * Math.PI;
312
+ if (turn !== 0) t.rotation -= turn / DEGREES;
313
+ link.angle = angle;
314
+ if (b.type === "dynamic" && !link.controller) {
315
+ const v = body.linvel();
316
+ b.vx = v.x;
317
+ b.vy = -v.y;
318
+ if (e.topdown?.clampToWorld) {
319
+ clampToWorld(e, host);
320
+ if (t.x !== p.x || t.y !== p.y) body.setTranslation(toRapier(t.x, t.y), true);
321
+ }
322
+ }
323
+ }
324
+ link.x = e.transform.x;
325
+ link.y = e.transform.y;
326
+ link.rotation = e.transform.rotation;
327
+ }
328
+
329
+ for (const link of this.links.values()) {
330
+ if (link.entity.body!.type === "dynamic" && !link.controller) this.updateGround(link, host);
331
+ }
332
+
333
+ this.events.drainCollisionEvents((handle1, handle2, started) => {
334
+ if (!started) return;
335
+ const a = this.byCollider.get(handle1);
336
+ const c = this.byCollider.get(handle2);
337
+ if (!a || !c || a.collider.isSensor() || c.collider.isSensor()) return;
338
+ for (const [self, other] of [
339
+ [a, c],
340
+ [c, a],
341
+ ] as const) {
342
+ const b = self.entity.body!;
343
+ if (b.type !== "dynamic" || b.bounce <= 0 || Math.hypot(b.vx, b.vy) < MIN_BOUNCE_SPEED) continue;
344
+ host.emit({ type: "bounce", entity: self.entity.id, other: other.entity.id, ...at(self.entity) });
345
+ }
346
+ });
347
+ }
348
+
349
+ /**
350
+ * Moves a player by its velocity (plus gravity for platformers, and the platform it
351
+ * stands on) through the character controller, which resolves slopes, steps and walls.
352
+ */
353
+ private moveCharacter(link: Link, host: PhysicsHost): void {
354
+ const { entity: e, body, collider } = link;
355
+ const controller = link.controller!;
356
+ const b = e.body!;
357
+ const t = e.transform;
358
+ const falls = !!e.platformer && b.type === "dynamic";
359
+ const wasGrounded = b.grounded;
360
+ if (link.teleported) {
361
+ // A respawn: start from rest where gameplay put it, just clear of the ground.
362
+ t.y -= CONTROLLER_OFFSET;
363
+ body.setTranslation(toRapier(t.x, t.y), true);
364
+ body.setNextKinematicTranslation(toRapier(t.x, t.y));
365
+ b.grounded = false;
366
+ b.standingOn = undefined;
367
+ link.airborne = Infinity;
368
+ return;
369
+ }
370
+ // Gravity only while not touching the ground (or jumping off): pressing a standing player into
371
+ // the ground makes round-bottomed ones slide and lose speed. Snapping keeps it down on slopes.
372
+ if (falls) b.vy = link.airborne === 0 && b.vy >= 0 ? 0 : Math.min(b.vy + host.gravity * b.gravityScale * FIXED_DT, MAX_FALL_SPEED);
373
+ const ownX = b.vx * FIXED_DT;
374
+ const ownY = b.vy * FIXED_DT;
375
+ // Ride along with a moving platform (Rapier's controller doesn't carry a standing character).
376
+ const carrier = b.standingOn ? this.links.get(b.standingOn) : undefined;
377
+ const carryX = carrier?.dx ?? 0;
378
+ const carryY = carrier?.dy ?? 0;
379
+ controller.computeColliderMovement(collider, toRapier(ownX + carryX, ownY + carryY), RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, undefined, (other) => other.handle !== collider.handle);
380
+ const computed = controller.computedMovement();
381
+ const moved = { x: computed.x, y: -computed.y };
382
+ t.x += moved.x;
383
+ t.y += moved.y;
384
+ if (e.topdown?.clampToWorld) clampToWorld(e, host);
385
+ body.setNextKinematicTranslation(toRapier(t.x, t.y));
386
+
387
+ // Ground: what's under the player now, if it fell or walked onto something.
388
+ const touching = falls && controller.computedGrounded();
389
+ link.airborne = touching ? 0 : link.airborne + 1;
390
+ b.grounded = touching || (falls && wasGrounded && b.vy >= 0 && link.airborne <= COYOTE_STEPS);
391
+ const previous = b.standingOn;
392
+ b.standingOn = undefined;
393
+ if (b.grounded) {
394
+ for (let i = 0; i < controller.numComputedCollisions(); i++) {
395
+ const hit = controller.computedCollision(i);
396
+ const other = hit?.collider ? this.byCollider.get(hit.collider.handle) : undefined;
397
+ if (other && other.entity.transform.y > t.y && Math.abs(hit!.normal1.y) > GROUND_NORMAL) b.standingOn = other.entity;
398
+ }
399
+ // Snapping down a slope reports no collision: it still stands where it stood.
400
+ b.standingOn ??= previous;
401
+ }
402
+ if (touching && b.vy > 0) b.vy = 0;
403
+ // Bumped its head: stop rising. Only a ceiling counts (normals point at the player, y-up),
404
+ // not a wall it slides up, which shaves a hair off the rise.
405
+ if (ownY < 0 && moved.y - carryY > ownY + 1e-3) {
406
+ for (let i = 0; i < controller.numComputedCollisions(); i++) {
407
+ if (controller.computedCollision(i)!.normal1.y < -GROUND_NORMAL) b.vy = 0;
408
+ }
409
+ }
410
+ if (b.grounded && !wasGrounded) host.emit({ type: "land", entity: e.id, ...feet(e) });
411
+ }
412
+
413
+ /** A body is grounded when a solid touches it from below; it then stands on that solid. */
414
+ private updateGround(link: Link, host: PhysicsHost): void {
415
+ const b = link.entity.body!;
416
+ const wasGrounded = b.grounded;
417
+ b.grounded = false;
418
+ b.standingOn = undefined;
419
+ this.world.contactPairsWith(link.collider, (other) => {
420
+ if (b.grounded || other.isSensor()) return;
421
+ this.world.contactPair(link.collider, other, (manifold, flipped) => {
422
+ if (b.grounded || manifold.numContacts() === 0) return;
423
+ // The normal points from the first collider to the second (flipped swaps them), in
424
+ // Rapier's y-up space: a solid below the body has a normal pointing to -y.
425
+ const down = flipped ? manifold.normal().y : -manifold.normal().y;
426
+ if (down > GROUND_NORMAL) {
427
+ b.grounded = true;
428
+ b.standingOn = this.byCollider.get(other.handle)?.entity;
429
+ }
430
+ });
431
+ });
432
+ if (b.grounded && !wasGrounded) host.emit({ type: "land", entity: link.entity.id, ...feet(link.entity) });
433
+ }
434
+
435
+ mass(e: Entity): number {
436
+ return this.links.get(e)?.collider.mass() ?? 0;
437
+ }
438
+
439
+ applyImpulse(e: Entity, x: number, y: number, at?: { x: number; y: number }): void {
440
+ const link = this.links.get(e);
441
+ const b = e.body;
442
+ if (!link || !b || b.type === "static") return;
443
+ if (link.controller) {
444
+ // Players move by velocity: a kick changes it directly.
445
+ const mass = link.collider.mass() || 1;
446
+ b.vx += x / mass;
447
+ b.vy += y / mass;
448
+ return;
449
+ }
450
+ if (b.type !== "dynamic") return;
451
+ if (at) link.body.applyImpulseAtPoint(toRapier(x, y), toRapier(at.x, at.y), true);
452
+ else link.body.applyImpulse(toRapier(x, y), true);
453
+ // Keep the entity's velocity in step, since it is written back to Rapier each step.
454
+ const v = fromRapier(link.body.linvel());
455
+ b.vx = v.x;
456
+ b.vy = v.y;
457
+ }
458
+
459
+ angularVelocity(e: Entity): number {
460
+ const body = this.links.get(e)?.body;
461
+ return body ? -body.angvel() / DEGREES : 0;
462
+ }
463
+
464
+ setAngularVelocity(e: Entity, value: number): void {
465
+ const link = this.links.get(e);
466
+ if (link && e.body?.type === "dynamic" && !link.controller) link.body.setAngvel(-value * DEGREES, true);
467
+ }
468
+
469
+ raycast(_host: PhysicsHost, from: { x: number; y: number }, to: { x: number; y: number }, options: RayOptions = {}): RayHit | undefined {
470
+ const dx = to.x - from.x;
471
+ const dy = to.y - from.y;
472
+ const length = Math.hypot(dx, dy);
473
+ if (length === 0) return undefined;
474
+ const origin = toRapier(from.x, from.y);
475
+ const ignored = new Set((options.ignore ?? []).map((e) => this.links.get(e)?.collider.handle));
476
+ const hit = this.world.castRayAndGetNormal(
477
+ new RAPIER.Ray(origin, toRapier(dx, dy)),
478
+ 1,
479
+ true,
480
+ options.sensors ? undefined : RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
481
+ undefined,
482
+ undefined,
483
+ undefined,
484
+ // Like arcade: a ray starting inside a body doesn't hit it, and only Ludra's bodies count.
485
+ (collider) => this.byCollider.has(collider.handle) && !ignored.has(collider.handle) && !collider.containsPoint(origin),
486
+ );
487
+ if (!hit) return undefined;
488
+ const link = this.byCollider.get(hit.collider.handle)!;
489
+ const t = hit.timeOfImpact;
490
+ return { entity: link.entity, x: from.x + dx * t, y: from.y + dy * t, distance: t * length, normal: fromRapier(hit.normal) };
491
+ }
492
+
493
+ raw(resolve: (target: unknown) => Entity | undefined): unknown {
494
+ const linkOf = (target: unknown) => {
495
+ const e = resolve(target);
496
+ return e ? this.links.get(e) : undefined;
497
+ };
498
+ return {
499
+ RAPIER,
500
+ world: this.world,
501
+ body: (target: unknown) => linkOf(target)?.body,
502
+ collider: (target: unknown) => linkOf(target)?.collider,
503
+ joint: (target: unknown, index = 0) => linkOf(target)?.joints[index],
504
+ toRapier: (p: { x: number; y: number }) => toRapier(p.x, p.y),
505
+ fromRapier,
506
+ };
507
+ }
508
+
509
+ overlaps(a: Entity, b: Entity, margin = 0): boolean {
510
+ const la = this.links.get(a);
511
+ const lb = this.links.get(b);
512
+ if (!la || !lb) return false;
513
+ if (la.collider.isSensor() || lb.collider.isSensor()) return this.world.intersectionPair(la.collider, lb.collider);
514
+ let hit = false;
515
+ this.world.contactPair(la.collider, lb.collider, (manifold) => {
516
+ for (let i = 0; i < manifold.numContacts(); i++) if (manifold.contactDist(i) < margin) hit = true;
517
+ });
518
+ return hit;
519
+ }
520
+ }
521
+
522
+ /** A point given as an offset from a body's center (in its own, turning frame), in Ludra's world. */
523
+ function worldPoint(body: RigidBody, offset: { x: number; y: number }): { x: number; y: number } {
524
+ const p = body.translation();
525
+ const angle = body.rotation();
526
+ const local = toRapier(offset.x, offset.y);
527
+ const cos = Math.cos(angle);
528
+ const sin = Math.sin(angle);
529
+ return fromRapier({ x: p.x + local.x * cos - local.y * sin, y: p.y + local.x * sin + local.y * cos });
530
+ }
531
+
532
+ /** A Ludra world point in a body's own Rapier frame. */
533
+ function localPoint(body: RigidBody, point: { x: number; y: number }): { x: number; y: number } {
534
+ const p = body.translation();
535
+ const r = toRapier(point.x, point.y);
536
+ return rotateBack(body, { x: r.x - p.x, y: r.y - p.y });
537
+ }
538
+
539
+ /** A Rapier world vector in a body's own frame. */
540
+ function rotateBack(body: RigidBody, v: { x: number; y: number }): { x: number; y: number } {
541
+ const cos = Math.cos(-body.rotation());
542
+ const sin = Math.sin(-body.rotation());
543
+ return { x: v.x * cos - v.y * sin, y: v.x * sin + v.y * cos };
544
+ }
545
+
546
+ function isPlayer(e: Entity): boolean {
547
+ return !!(e.platformer || e.topdown) && !!e.body && e.body.type !== "static";
548
+ }
549
+
550
+ function shape(b: Body): ColliderDesc {
551
+ if (b.shape === "circle") return RAPIER.ColliderDesc.ball(b.width / 2);
552
+ if (b.shape === "capsule") {
553
+ // Rapier's capsules stand upright; lay it down when the body is wider than tall.
554
+ if (b.height >= b.width) return RAPIER.ColliderDesc.capsule((b.height - b.width) / 2, b.width / 2);
555
+ return RAPIER.ColliderDesc.capsule((b.width - b.height) / 2, b.height / 2).setRotation(Math.PI / 2);
556
+ }
557
+ if (b.shape === "polygon" || b.shape === "polyline") {
558
+ // Points are mirrored into Rapier's y-up space.
559
+ const points = new Float32Array(b.points.flatMap(([x, y]) => [x, -y]));
560
+ if (b.shape === "polyline") return RAPIER.ColliderDesc.polyline(points);
561
+ // A degenerate polygon (all points on a line) falls back to the bounding box.
562
+ return RAPIER.ColliderDesc.convexHull(points) ?? RAPIER.ColliderDesc.cuboid(b.width / 2, b.height / 2);
563
+ }
564
+ return RAPIER.ColliderDesc.cuboid(b.width / 2, b.height / 2);
565
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.