From c7f7bc4e7a67f9add575f3c1aa68e49095be998a Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 21 May 2026 07:27:32 +0800 Subject: [PATCH 01/11] =?UTF-8?q?chore(release):=2019.5=20prep=20=E2=80=94?= =?UTF-8?q?=20CHANGELOG=20date,=20README=20physics,=20planck=20test=20cove?= =?UTF-8?q?rage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG: stamp `## [19.5.0]` heading with the 2026-05-20 release date - README: Features list — add raycast / queryAABB and collision lifecycle hooks; add pluggable PhysicsAdapter mention; cleanup of stale SAT-only framing - README: Physics Adapters section — fleshed out matter / planck descriptions with the showcase examples, the portable adapter API surface (raycast / queryAABB / lifecycle), and links to the three wiki pages (migration / switching / quirks) - planck-adapter: new `tests/parity.spec.ts` — cross-adapter parity vs BuiltinAdapter (mirror of matter-adapter's parity spec, 68 tests). Box2D's polygon `radius` adds ~0.32 px / edge through the meter conversion, so AABB-dimension assertions use a 1 px tolerance instead of the matter spec's tighter 0.05 px; planck factory uses a real gravity (320 px/s² ≈ 1g) so the 60-tick fall window actually produces a floor contact for the collision-lifecycle tests. - planck-adapter: new `tests/planck-adapter-stress.spec.ts` — lifecycle leak guards (mirror of matter's stress spec, 7 tests). Mirrors the same add/remove / updateShape / init→destroy→init invariants; walks `world.getBodyList()` to verify the underlying planck world body count tracks the adapter's internal maps. Planck test count: 43 → 118. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 22 +- packages/melonjs/CHANGELOG.md | 2 +- packages/planck-adapter/tests/parity.spec.ts | 610 ++++++++++++++++++ .../tests/planck-adapter-stress.spec.ts | 196 ++++++ 4 files changed, 825 insertions(+), 5 deletions(-) create mode 100644 packages/planck-adapter/tests/parity.spec.ts create mode 100644 packages/planck-adapter/tests/planck-adapter-stress.spec.ts diff --git a/README.md b/README.md index 42db72d6c..1f8d33aa2 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,17 @@ Graphics Sound - Web Audio support with 3D spatial audio and stereo panning based on [Howler](https://howlerjs.com) +- Built-in procedural audio primitives (envelope-shaped oscillators, white/pink/brown noise) for SFX without sample assets +- Direct `AudioContext` / master-gain accessors for custom WebAudio graphs that mix with the engine's master volume / mute Physics - Polygon (SAT) based collision algorithm for accurate detection and response -- Fast Broad-phase collision detection using spatial partitioning (QuadTree) +- Fast broad-phase collision detection using spatial partitioning (QuadTree) +- Raycast and AABB region queries with precise entry geometry +- Collision lifecycle hooks on every `Renderable` - Collision filtering for optimized automatic collision detection - Multiple shapes per body for complex hitboxes +- Pluggable `PhysicsAdapter` interface for custom physics via official adapters Input - Mouse and Touch device support (with mouse emulation) @@ -219,9 +224,18 @@ If you wish to develop your own plugin, we also provide a [plugin template](http Physics Adapters ------------------------------------------------------------------------------- -Since 19.5, melonJS exposes a `PhysicsAdapter` interface so the same game code can run on either the built-in SAT physics (default) or on a third-party rigid-body engine, selected via the `physic` option on `Application`. Official adapter packages maintained by the melonJS team: -- [matter-adapter](https://github.com/melonjs/melonJS/tree/master/packages/matter-adapter) - [matter-js](https://brm.io/matter-js/) integration with rotational dynamics, constraints, sleeping bodies, continuous collision detection, and raycasts -- [planck-adapter](https://github.com/melonjs/melonJS/tree/master/packages/planck-adapter) - [planck.js](https://piqnt.com/planck.js/) integration (Box2D 2.3.0 port) with native joints, CCD bullet flag, sleeping bodies, raycasts, and per-body gravity scale +Since 19.5, melonJS exposes a `PhysicsAdapter` interface so the same game code can run on either the built-in SAT physics (default) or a third-party rigid-body engine, selected via the `physic` option on `Application`. Two official adapter packages, maintained by the melonJS team: + +- **[@melonjs/matter-adapter](https://github.com/melonjs/melonJS/tree/master/packages/matter-adapter)** — [matter-js](https://brm.io/matter-js/) integration. Rotational dynamics, constraints (springs / hinges / pins), sleeping bodies, continuous collision detection, raycasts. Showcased by the [Matter Platformer](https://melonjs.github.io/melonJS/examples/#/platformer-matter) and [Pool (Matter)](https://melonjs.github.io/melonJS/examples/#/pool-matter) examples. +- **[@melonjs/planck-adapter](https://github.com/melonjs/melonJS/tree/master/packages/planck-adapter)** — [planck.js](https://piqnt.com/planck.js/) integration (faithful Box2D 2.3.0 port). Native joints, CCD bullet flag, sleeping bodies, native raycasts, per-body gravity scale. Showcased by the [Neon Plinko (Planck)](https://melonjs.github.io/melonJS/examples/#/plinko-planck) example. + +Portable across every adapter (game code stays engine-agnostic): + +- `app.world.adapter.raycast(from, to)` — nearest body hit with precise entry geometry (`renderable`, `point`, `normal`, `fraction`) +- `app.world.adapter.queryAABB(rect)` — every renderable whose body overlaps a rectangle (AoE damage, picking, trigger sweeps) +- Collision lifecycle hooks (`onCollisionStart` / `onCollisionActive` / `onCollisionEnd`) dispatched consistently with a receiver-symmetric response shape (`response.a === this`, `response.b === other`) + +See the [Migrating to the Physics Adapter API](https://github.com/melonjs/melonJS/wiki/Migrating-to-the-Physics-Adapter-API), [Switching Physics Adapters](https://github.com/melonjs/melonJS/wiki/Switching-Physics-Adapters), and [BuiltinAdapter Quirks](https://github.com/melonjs/melonJS/wiki/BuiltinAdapter-Quirks) wiki pages for migration guides and the per-adapter behaviour table. Installation ------------------------------------------------------------------------------- diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 7d7f7d10f..11c39d707 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [19.5.0] (melonJS 2) - _unreleased_ +## [19.5.0] (melonJS 2) - _2026-05-20_ **Highlights:** physics-focused release. The headline is the new `PhysicsAdapter` abstraction — the same game code can now run on either the engine's built-in SAT physics (default, unchanged behavior) or on a third-party rigid-body engine via the `physic` Application setting. Two official adapters ship alongside the engine: `@melonjs/matter-adapter` (wraps matter-js — constraints, sleeping bodies, continuous collision detection, raycasts, and matter's solver) and `@melonjs/planck-adapter` (wraps planck.js — a faithful Box2D 2.3.0 port with the same feature set plus Box2D-native joints, CCD bullet flag, and per-body gravity scale). New examples demonstrate the abstraction — `Matter Platformer` (the canonical platformer ported to matter) and `Pool (Matter)` (top-down 8-ball pool, drag-to-aim cue with physics-driven break shots and pocket sensors). Collision dispatch also gains a proper lifecycle (`onCollisionStart` / `onCollisionActive` / `onCollisionEnd`) on every `Renderable`, dispatched consistently by all three adapters with a **receiver-symmetric contract** (`response.a === this`, `response.b === other`, `response.normal` is the MTV of the receiver). The legacy `onCollision` handler is preserved unchanged for backward compatibility, but is **superseded by `onCollisionActive` on a per-renderable basis** so users who migrate get a single, clean every-frame contract. Push-out semantics on the built-in adapter are now matter-aligned: dynamic non-sensor bodies separate by default whether or not `onCollision` is defined. diff --git a/packages/planck-adapter/tests/parity.spec.ts b/packages/planck-adapter/tests/parity.spec.ts new file mode 100644 index 000000000..fbca3b8ae --- /dev/null +++ b/packages/planck-adapter/tests/parity.spec.ts @@ -0,0 +1,610 @@ +/** + * Cross-adapter parity spec — `BuiltinAdapter` vs `PlanckAdapter`. + * + * Mirror of `@melonjs/matter-adapter/tests/parity.spec.ts`. Runs the + * same test body against both adapters to catch behavioural drift in + * the shared `PhysicsAdapter` contract. Things that are *supposed* to + * be identical regardless of physics engine live here; things that + * are inherently engine-dependent (Box2D's meters-based units, native + * rotational dynamics, sleeping bodies, etc.) belong in the adapter's + * own spec instead. + * + * If a test in this file passes on one adapter and fails on the other, + * that's a real bug in adapter parity, not an "expected difference." + */ + +import { + Bounds, + BuiltinAdapter, + boot, + collision, + Rect, + Renderable, + Vector2d, + video, + World, +} from "melonjs"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { PlanckAdapter } from "../src/index"; + +interface AdapterFactory { + name: string; + make(): { + adapter: BuiltinAdapter | PlanckAdapter; + world: World; + }; + /** + * Decimal-place precision for AABB assertions. The builtin SAT + * adapter is exact (precision 1 → within 0.05 px). Planck converts + * positions through meters, and Box2D's polygon `radius` + * (≈ 2 × b2_linearSlop ≈ 0.32 px at the default `pixelsPerMeter`) + * inflates AABBs by that amount in each direction — too far for + * precision 1, fine at precision 0 (within 0.5 px). + */ + aabbPrecision: number; +} + +const factories: AdapterFactory[] = [ + { + name: "BuiltinAdapter", + aabbPrecision: 1, + make() { + const adapter = new BuiltinAdapter({ + gravity: new Vector2d(0, 1), + }); + const world = new World(0, 0, 800, 600, adapter); + return { adapter, world }; + }, + }, + { + name: "PlanckAdapter", + aabbPrecision: 0, + make() { + // Gravity is in px/s² at the adapter surface (planck converts + // to meters internally via `pixelsPerMeter`). Use a real + // gravity value so the collision-lifecycle tests' 60-tick + // fall window actually produces a contact — at the matter + // parity's `(0, 1)`, planck would integrate at 0.03 m/s² + // and the ball wouldn't reach the floor. + const adapter = new PlanckAdapter({ + gravity: new Vector2d(0, 320), + }); + const world = new World(0, 0, 800, 600, adapter); + return { adapter, world }; + }, + }, +]; + +beforeAll(() => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); +}); + +for (const { name, make, aabbPrecision } of factories) { + describe(`Adapter parity — ${name}`, () => { + let adapter: BuiltinAdapter | PlanckAdapter; + let world: World; + + beforeEach(() => { + ({ adapter, world } = make()); + }); + + // Helper: add a renderable to the world container. The builtin + // adapter only integrates bodies whose renderable is in the scene + // tree (`body.ancestor` set); planck integrates anything added to + // the world. Going through the world container is the realistic + // usage and makes the two adapters comparable. + const addToWorld = ( + r: Renderable, + def: Parameters[1], + ) => { + r.alwaysUpdate = true; + r.bodyDef = def; + world.addChild(r); + return r; + }; + + describe("velocity API", () => { + it("velocity round-trips through set/get", () => { + const r = addToWorld(new Renderable(100, 100, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + adapter.setVelocity(r, new Vector2d(3.5, -2.0)); + const out = adapter.getVelocity(r); + expect(out.x).toBeCloseTo(3.5, 3); + expect(out.y).toBeCloseTo(-2.0, 3); + }); + + it("static body ignores gravity", () => { + const r = addToWorld(new Renderable(100, 100, 32, 32), { + type: "static", + shapes: [new Rect(0, 0, 32, 32)], + }); + const startY = r.pos.y; + for (let i = 0; i < 60; i++) { + adapter.step(16); + } + adapter.syncFromPhysics(); + expect(Math.abs(r.pos.y - startY)).toBeLessThan(0.01); + }); + + it("dynamic body falls under gravity", () => { + const r = addToWorld(new Renderable(100, 100, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + const startY = r.pos.y; + for (let i = 0; i < 60; i++) { + adapter.step(16); + } + adapter.syncFromPhysics(); + expect(r.pos.y).toBeGreaterThan(startY); + }); + }); + + describe("body lifecycle", () => { + it("removeBody silently no-ops on unknown renderables", () => { + const r = new Renderable(0, 0, 32, 32); + // removed without having been added — must not throw + expect(() => { + adapter.removeBody(r); + }).not.toThrow(); + }); + + it("removed body no longer integrates", () => { + const r = addToWorld(new Renderable(100, 100, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + adapter.step(16); + adapter.syncFromPhysics(); + adapter.removeBody(r); + const yAtRemove = r.pos.y; + for (let i = 0; i < 30; i++) { + adapter.step(16); + } + adapter.syncFromPhysics(); + expect(Math.abs(r.pos.y - yAtRemove)).toBeLessThan(0.1); + }); + }); + + describe("collision filter", () => { + it("setCollisionType propagates to body", () => { + const r = addToWorld(new Renderable(0, 0, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + collisionType: collision.types.PLAYER_OBJECT, + }); + adapter.setCollisionType(r, collision.types.ENEMY_OBJECT); + expect( + (r.body as unknown as { collisionType?: number }).collisionType, + ).toEqual(collision.types.ENEMY_OBJECT); + }); + + it("setCollisionMask propagates to body", () => { + const r = addToWorld(new Renderable(0, 0, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + adapter.setCollisionMask( + r, + collision.types.WORLD_SHAPE | collision.types.PLAYER_OBJECT, + ); + expect( + (r.body as unknown as { collisionMask?: number }).collisionMask, + ).toEqual(collision.types.WORLD_SHAPE | collision.types.PLAYER_OBJECT); + }); + }); + + describe("static toggle", () => { + it("setStatic stops a falling body in place", () => { + const r = addToWorld(new Renderable(100, 100, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + for (let i = 0; i < 10; i++) { + adapter.step(16); + } + adapter.syncFromPhysics(); + adapter.setStatic(r, true); + const lockedY = r.pos.y; + for (let i = 0; i < 30; i++) { + adapter.step(16); + } + adapter.syncFromPhysics(); + expect(Math.abs(r.pos.y - lockedY)).toBeLessThan(0.5); + }); + }); + + describe("setSensor", () => { + it("is callable through the interface (typed optional)", () => { + const r = addToWorld(new Renderable(0, 0, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + expect(typeof adapter.setSensor).toEqual("function"); + expect(() => { + adapter.setSensor?.(r, true); + }).not.toThrow(); + expect(() => { + adapter.setSensor?.(r, false); + }).not.toThrow(); + }); + }); + + describe("regression — Container.removeChild auto-cleanup", () => { + // Pool-recycled entities frequently take this path. `Container. + // removeChildNow` used to only call the adapter's removeBody + // when `child.body instanceof Body` (legacy melonJS Body). + // Third-party adapter bodies failed that check and stayed in + // the engine after the renderable was destroyed. + it("removeChild also removes the adapter-managed body", () => { + const r = addToWorld(new Renderable(100, 100, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + adapter.setVelocity(r, new Vector2d(5, 0)); + world.removeChildNow(r, true /* keepalive */); + expect(adapter.getVelocity(r).x).toEqual(0); + }); + }); + + describe("collision lifecycle events", () => { + // Drop a ball onto a floor and watch start/active/end fire. + const setupContact = () => { + const events: { kind: string; tick: number }[] = []; + let tick = 0; + class Reporter extends Renderable { + onCollisionStart() { + events.push({ kind: "start", tick }); + } + onCollisionActive() { + events.push({ kind: "active", tick }); + } + onCollisionEnd() { + events.push({ kind: "end", tick }); + } + } + const floor = new Reporter(0, 200, 800, 20); + addToWorld(floor, { + type: "static", + shapes: [new Rect(0, 0, 800, 20)], + }); + const ball = new Reporter(100, 150, 32, 32); + addToWorld(ball, { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + const step = (n = 1) => { + for (let i = 0; i < n; i++) { + tick++; + // world.update populates the SAT broadphase and then + // calls adapter.step — that's the realistic path the + // engine takes each frame. Stepping the adapter + // directly (as the simpler tests do) skips the + // broadphase, so SAT finds no collision candidates. + world.update(16); + } + adapter.syncFromPhysics(); + }; + return { events, step, floor, ball }; + }; + + it("onCollisionStart fires exactly once per contact entry", () => { + const { events, step } = setupContact(); + step(60); + const starts = events.filter((e) => e.kind === "start"); + // 2 = one per renderable (both have a Reporter handler); the + // key point is the count is small and bounded — *not* + // multiplied per active-frame. + expect(starts.length).toBeLessThanOrEqual(2); + expect(starts.length).toBeGreaterThanOrEqual(1); + }); + + it("onCollisionActive fires while bodies remain in contact", () => { + const { events, step } = setupContact(); + step(60); + const actives = events.filter((e) => e.kind === "active"); + expect(actives.length).toBeGreaterThan(5); + }); + + it("onCollisionEnd fires when bodies separate", () => { + const { events, step, ball } = setupContact(); + // Step long enough for the ball to actually reach the floor + // under both gravity regimes (builtin per-frame, planck + // per-second). 60 ticks is the same window the start / + // active tests use to register contact. + step(60); + adapter.setPosition(ball, new Vector2d(2000, 0)); + step(30); + const ends = events.filter((e) => e.kind === "end"); + expect(ends.length).toBeGreaterThan(0); + }); + }); + + describe("debug API (getBodyAABB / getBodyShapes)", () => { + // Adapter-side debug surface. Both methods must return geometry + // in renderable-LOCAL coordinates (relative to renderable.pos), + // so `@melonjs/debug-plugin` can blit them after translating + // to the renderable origin without knowing whether the + // underlying engine uses a world- or local-space body frame. + it("getBodyAABB returns local-space bounds", () => { + const r = addToWorld(new Renderable(100, 200, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + const out = new Bounds(); + const aabb = adapter.getBodyAABB?.(r, out); + expect(aabb).toBeDefined(); + expect(aabb!.left).toBeCloseTo(0, aabbPrecision); + expect(aabb!.top).toBeCloseTo(0, aabbPrecision); + // Dimensions are `right - left`, so any per-edge drift + // doubles. Use a slightly wider tolerance than the + // single-coordinate precision. + expect(Math.abs(aabb!.width - 32)).toBeLessThanOrEqual(1); + expect(Math.abs(aabb!.height - 32)).toBeLessThanOrEqual(1); + }); + + it("getBodyAABB writes into the supplied out Bounds", () => { + const r = addToWorld(new Renderable(50, 50, 16, 16), { + type: "dynamic", + shapes: [new Rect(0, 0, 16, 16)], + }); + const out = new Bounds(); + const aabb = adapter.getBodyAABB?.(r, out); + expect(aabb).toBe(out); // same reference, no allocation + }); + + it("getBodyAABB returns undefined for unregistered renderables", () => { + const r = new Renderable(0, 0, 32, 32); + const out = new Bounds(); + expect(adapter.getBodyAABB?.(r, out)).toBeUndefined(); + }); + + it("getBodyShapes returns the body's shape list", () => { + const shape = new Rect(0, 0, 32, 32); + const r = addToWorld(new Renderable(0, 0, 32, 32), { + type: "dynamic", + shapes: [shape], + }); + const shapes = adapter.getBodyShapes?.(r); + expect(shapes).toBeDefined(); + expect(shapes.length).toEqual(1); + }); + + it("getBodyShapes returns an empty list for unregistered renderables", () => { + const r = new Renderable(0, 0, 32, 32); + const shapes = adapter.getBodyShapes?.(r); + expect(shapes).toBeDefined(); + expect(shapes.length).toEqual(0); + }); + }); + + describe("debug API: coordinate-space adversarial", () => { + // These tests pin down the contract the debug plugin depends + // on: `getBodyAABB` must return bounds in **renderable-local** + // coordinates regardless of where the renderable lives in the + // world or how the body has been moved since registration. + it("AABB stays local-space at a non-trivial world position", () => { + const r = addToWorld(new Renderable(500, 300, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + const aabb = adapter.getBodyAABB?.(r, new Bounds())!; + expect(aabb.left).toBeCloseTo(0, aabbPrecision); + expect(aabb.top).toBeCloseTo(0, aabbPrecision); + expect(aabb.right).toBeCloseTo(32, aabbPrecision); + expect(aabb.bottom).toBeCloseTo(32, aabbPrecision); + }); + + it("AABB stays local-space at NEGATIVE world coords", () => { + const r = addToWorld(new Renderable(-450, -275, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + const aabb = adapter.getBodyAABB?.(r, new Bounds())!; + expect(aabb.left).toBeCloseTo(0, aabbPrecision); + expect(aabb.top).toBeCloseTo(0, aabbPrecision); + expect(aabb.right).toBeCloseTo(32, aabbPrecision); + expect(aabb.bottom).toBeCloseTo(32, aabbPrecision); + }); + + it("AABB reflects an OFFSET shape inside the renderable", () => { + const r = addToWorld(new Renderable(0, 0, 64, 64), { + type: "dynamic", + shapes: [new Rect(10, 12, 32, 40)], + }); + const aabb = adapter.getBodyAABB?.(r, new Bounds())!; + expect(aabb.left).toBeCloseTo(10, 0); + expect(aabb.top).toBeCloseTo(12, 0); + expect(Math.abs(aabb.width - 32)).toBeLessThanOrEqual(1); + expect(Math.abs(aabb.height - 40)).toBeLessThanOrEqual(1); + }); + + it("AABB encompasses a multi-shape body in local coords", () => { + const r = addToWorld(new Renderable(200, 200, 64, 64), { + type: "dynamic", + shapes: [new Rect(0, 0, 16, 16), new Rect(0, 0, 48, 32)], + }); + const aabb = adapter.getBodyAABB?.(r, new Bounds())!; + expect(aabb.left).toBeCloseTo(0, 0); + expect(aabb.top).toBeCloseTo(0, 0); + expect(Math.abs(aabb.right - 48)).toBeLessThanOrEqual(1); + expect(Math.abs(aabb.bottom - 32)).toBeLessThanOrEqual(1); + }); + + it("AABB stays local-space across many simulation steps (static body)", () => { + const r = addToWorld(new Renderable(100, 100, 32, 32), { + type: "static", + shapes: [new Rect(0, 0, 32, 32)], + }); + const before = adapter.getBodyAABB?.(r, new Bounds())!; + const beforeLeft = before.left; + const beforeTop = before.top; + const beforeRight = before.right; + const beforeBottom = before.bottom; + for (let i = 0; i < 50; i++) { + adapter.step(16); + } + const after = adapter.getBodyAABB?.(r, new Bounds())!; + expect(after.left).toBeCloseTo(beforeLeft, 3); + expect(after.top).toBeCloseTo(beforeTop, 3); + expect(after.right).toBeCloseTo(beforeRight, 3); + expect(after.bottom).toBeCloseTo(beforeBottom, 3); + }); + + it("AABB stays local-space while a dynamic body is falling", () => { + const r = addToWorld(new Renderable(100, 100, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + adapter.step(16); + const after = adapter.getBodyAABB?.(r, new Bounds())!; + const cx = (after.left + after.right) / 2; + const cy = (after.top + after.bottom) / 2; + expect(Math.abs(cx - 16)).toBeLessThan(10); + expect(Math.abs(cy - 16)).toBeLessThan(10); + }); + + it("AABB stays local-space after adapter.setPosition teleport", () => { + const r = addToWorld(new Renderable(0, 0, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + adapter.setPosition(r, new Vector2d(1234, -567)); + const aabb = adapter.getBodyAABB?.(r, new Bounds())!; + expect(aabb.left).toBeCloseTo(0, aabbPrecision); + expect(aabb.top).toBeCloseTo(0, aabbPrecision); + }); + + it("the debug-plugin draw transform reproduces the body's WORLD AABB", () => { + const r = addToWorld(new Renderable(400, 250, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + const aabb = adapter.getBodyAABB?.(r, new Bounds())!; + const worldLeft = r.pos.x + aabb.left; + const worldTop = r.pos.y + aabb.top; + expect(worldLeft).toBeCloseTo(400, aabbPrecision); + expect(worldTop).toBeCloseTo(250, aabbPrecision); + }); + + it("repeated polls are idempotent (no drift on the shared out)", () => { + const r = addToWorld(new Renderable(123, 456, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + const out1 = new Bounds(); + const out2 = new Bounds(); + adapter.getBodyAABB?.(r, out1); + adapter.getBodyAABB?.(r, out2); + expect(out1.left).toBeCloseTo(out2.left, 4); + expect(out1.top).toBeCloseTo(out2.top, 4); + expect(out1.right).toBeCloseTo(out2.right, 4); + expect(out1.bottom).toBeCloseTo(out2.bottom, 4); + }); + + it("getBodyShapes preserves the shape origin in local coords", () => { + const shape = new Rect(10, 12, 32, 40); + const r = addToWorld(new Renderable(500, 600, 64, 64), { + type: "dynamic", + shapes: [shape], + }); + const shapes = adapter.getBodyShapes?.(r); + expect(shapes.length).toEqual(1); + const sb = shapes[0].getBounds(); + expect(sb.left).toBeCloseTo(10, 0); + expect(sb.top).toBeCloseTo(12, 0); + expect(sb.width).toBeCloseTo(32, 0); + expect(sb.height).toBeCloseTo(40, 0); + }); + }); + + describe("capabilities advertise honestly", () => { + it("isGrounded capability matches actual implementation", () => { + const advertised = adapter.capabilities.isGrounded; + const implemented = typeof adapter.isGrounded === "function"; + expect(advertised).toEqual(implemented); + }); + + it("velocityLimit capability matches the setMaxVelocity contract", () => { + expect(adapter.capabilities.velocityLimit).toEqual(true); + expect(typeof adapter.setMaxVelocity).toEqual("function"); + }); + }); + + describe("setAngle is callable through the interface", () => { + it("setAngle either rotates (planck) or no-ops (builtin) without throwing", () => { + const r = addToWorld(new Renderable(0, 0, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + expect(typeof adapter.setAngle).toEqual("function"); + expect(() => { + adapter.setAngle?.(r, Math.PI / 4); + }).not.toThrow(); + }); + }); + + describe("destroy clears adapter state", () => { + it("after destroy(), the body is forgotten", () => { + const r = addToWorld(new Renderable(0, 0, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + adapter.setVelocity(r, new Vector2d(5, 5)); + expect(adapter.getVelocity(r).x).toBeCloseTo(5, 3); + adapter.destroy?.(); + // post-destroy, the adapter should no longer report the + // body's velocity (both adapters return 0 once unmanaged) + expect(adapter.getVelocity(r).x).toEqual(0); + }); + }); + + describe("add-remove-add cycle keeps state consistent", () => { + // Pool-recycled entities frequently take this path: a body is + // registered, the entity is removed from the world, then later + // re-added with fresh setup. The adapter must not leak the + // previous body and must accept the same renderable again + // without throwing. + it("a renderable can be re-added after removeBody", () => { + const r = new Renderable(0, 0, 32, 32); + r.alwaysUpdate = true; + const def = { + type: "dynamic" as const, + shapes: [new Rect(0, 0, 32, 32)], + }; + r.bodyDef = def; + world.addChild(r); + adapter.setVelocity(r, new Vector2d(7, 0)); + adapter.removeBody(r); + // re-adding must not throw + expect(() => adapter.addBody(r, def)).not.toThrow(); + // after re-add, the body responds to setVelocity / getVelocity + adapter.setVelocity(r, new Vector2d(3, 0)); + expect(adapter.getVelocity(r).x).toBeCloseTo(3, 3); + }); + }); + + describe("getVelocity with a pre-allocated out vector", () => { + it("writes into the caller's vector and returns it", () => { + const r = addToWorld(new Renderable(0, 0, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + adapter.setVelocity(r, new Vector2d(2.5, -1.5)); + const out = new Vector2d(99, 99); + const result = adapter.getVelocity(r, out); + // must reuse the supplied vector — no allocation + expect(result).toBe(out); + expect(out.x).toBeCloseTo(2.5, 3); + expect(out.y).toBeCloseTo(-1.5, 3); + }); + }); + }); +} diff --git a/packages/planck-adapter/tests/planck-adapter-stress.spec.ts b/packages/planck-adapter/tests/planck-adapter-stress.spec.ts new file mode 100644 index 000000000..a5875131e --- /dev/null +++ b/packages/planck-adapter/tests/planck-adapter-stress.spec.ts @@ -0,0 +1,196 @@ +/** + * Lifecycle leak stress tests for `@melonjs/planck-adapter`. + * + * Counterpart to `matter-adapter-stress.spec.ts`. Pins the invariant + * that internal maps (`bodyMap`, `renderableMap`, `velocityLimits`, + * `defMap`, `posOffsets`) return to their initial sizes after N + * add/remove cycles or init→destroy→init cycles, and that the + * underlying `planck.World` body count tracks them. + * + * Past bugs this guards against: + * - bodyMap / renderableMap not drained on removeBody + * - posOffsets not cleared on destroy + * - body maps growing across updateShape (remove + add) + * - planck bodies left in the world after the adapter loses track + */ + +import { boot, Rect, Renderable, video, World } from "melonjs"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { PlanckAdapter } from "../src/index"; + +const STRESS_CYCLES = 100; + +// PlanckAdapter's internal maps are `private readonly` for type safety — +// poke through the private shield only for leak observation. +type InternalsView = { + bodyMap: Map; + renderableMap: Map; + velocityLimits: Map; + defMap: Map; + posOffsets: Map; +}; + +const internals = (adapter: PlanckAdapter): InternalsView => + adapter as unknown as InternalsView; + +// Count bodies the planck world is actively simulating. Planck has no +// `getBodyCount` getter; walk the body list directly. +const planckBodyCount = (adapter: PlanckAdapter): number => { + let n = 0; + for (let b = adapter.world.getBodyList(); b; b = b.getNext()) n++; + return n; +}; + +const snapshot = (adapter: PlanckAdapter) => { + const i = internals(adapter); + return { + bodyMap: i.bodyMap.size, + renderableMap: i.renderableMap.size, + velocityLimits: i.velocityLimits.size, + defMap: i.defMap.size, + posOffsets: i.posOffsets.size, + planckBodies: planckBodyCount(adapter), + }; +}; + +describe("PlanckAdapter — lifecycle leak stress", () => { + let world: World; + let adapter: PlanckAdapter; + + beforeAll(() => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + }); + + beforeEach(() => { + adapter = new PlanckAdapter({ gravity: { x: 0, y: 1 } }); + world = new World(0, 0, 800, 600, adapter); + }); + + it("addBody/removeBody x100: every internal map returns to zero", () => { + const before = snapshot(adapter); + for (let i = 0; i < STRESS_CYCLES; i++) { + const r = new Renderable(i, i, 32, 32); + adapter.addBody(r, { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + adapter.removeBody(r); + } + expect(snapshot(adapter)).toEqual(before); + }); + + it("addBody with maxVelocity: velocityLimits drains on removeBody", () => { + for (let i = 0; i < STRESS_CYCLES; i++) { + const r = new Renderable(i, i, 32, 32); + adapter.addBody(r, { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + maxVelocity: { x: 5, y: 10 }, + }); + adapter.removeBody(r); + } + expect(internals(adapter).velocityLimits.size).toEqual(0); + }); + + it("addChild/removeChildNow via world x100: no leak", () => { + const before = snapshot(adapter); + for (let i = 0; i < STRESS_CYCLES; i++) { + const r = new Renderable(i, i, 32, 32); + r.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + world.addChild(r); + world.removeChildNow(r, true); + } + expect(snapshot(adapter)).toEqual(before); + }); + + // Regression: destroy() must clear every internal map, otherwise + // stale renderable→body mappings accumulate across re-inits. + it("addBody → destroy clears every map", () => { + const r = new Renderable(0, 0, 32, 32); + adapter.addBody(r, { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + expect(internals(adapter).bodyMap.size).toEqual(1); + expect(internals(adapter).renderableMap.size).toEqual(1); + expect(internals(adapter).defMap.size).toEqual(1); + expect(internals(adapter).posOffsets.size).toEqual(1); + expect(planckBodyCount(adapter)).toEqual(1); + adapter.destroy(); + expect(internals(adapter).bodyMap.size).toEqual(0); + expect(internals(adapter).renderableMap.size).toEqual(0); + expect(internals(adapter).defMap.size).toEqual(0); + expect(internals(adapter).posOffsets.size).toEqual(0); + expect(planckBodyCount(adapter)).toEqual(0); + }); + + // Regression: init() after destroy() must restore a usable world, + // not double-register listeners on the previous world. Planck's + // `init` creates a fresh `planck.World` instance per call so + // listeners don't accumulate, but the bookkeeping maps need to + // stay clean across cycles too. + it("init → destroy → init x10: maps stay at zero after each cycle", () => { + for (let i = 0; i < 10; i++) { + const w = adapter.melonWorld; + adapter.destroy(); + adapter.init(w); + expect(internals(adapter).bodyMap.size).toEqual(0); + expect(internals(adapter).renderableMap.size).toEqual(0); + expect(internals(adapter).defMap.size).toEqual(0); + expect(internals(adapter).posOffsets.size).toEqual(0); + expect(planckBodyCount(adapter)).toEqual(0); + } + }); + + // Regression: updateShape replaces the body via removeBody + addBody. + // If the old maps weren't drained, every shape change would leak one + // stale entry per internal map. Also verify the planck world doesn't + // accumulate orphan body instances. + it("updateShape x100 on the same renderable: maps stay at size 1", () => { + const r = new Renderable(0, 0, 32, 32); + adapter.addBody(r, { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + const after1 = snapshot(adapter); + for (let i = 0; i < STRESS_CYCLES; i++) { + const size = 16 + (i % 16); + adapter.updateShape(r, [new Rect(0, 0, size, size)]); + } + const afterN = snapshot(adapter); + expect(afterN).toEqual(after1); + expect(afterN.planckBodies).toEqual(1); + }); + + it("addChild/removeChildNow alternating: high-water + zero parity", () => { + const before = snapshot(adapter); + for (let sweep = 0; sweep < 3; sweep++) { + const bag: Renderable[] = []; + for (let i = 0; i < 50; i++) { + const r = new Renderable(i * 10, 0, 16, 16); + r.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 16, 16)], + }; + world.addChild(r); + bag.push(r); + } + expect(internals(adapter).bodyMap.size).toEqual(50); + expect(planckBodyCount(adapter)).toEqual(50); + for (const r of bag) { + world.removeChildNow(r, true); + } + expect(internals(adapter).bodyMap.size).toEqual(0); + expect(planckBodyCount(adapter)).toEqual(0); + } + expect(snapshot(adapter)).toEqual(before); + }); +}); From cf7db132cf0440c55639599a9be25da803727c19 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 21 May 2026 08:04:00 +0800 Subject: [PATCH 02/11] test(adapters): cross-adapter parity for raycast / queryAABB / capabilities / isGrounded / updateShape / sensor-pushout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface a documentation audit finding — the wiki described `raycast` and `queryAABB` as matter-only, but as of 19.5 both are portable across every adapter. These tests pin that contract: - **`raycast` portable hit shape** — `{ renderable, point, normal, fraction }` with the expected per-adapter precision (planck's polygon-radius slop ≈ 0.32 px / edge sets the planck tolerance at `aabbPrecision: 0`). - **`queryAABB` portable region query** — bounds-overlap, empty-on-miss, multi-body span. - **`adapter.capabilities` shape pin** — each adapter asserts the exact capability set it advertises (builtin: raycasts/velocityLimit/isGrounded only; matter/planck: all six). - **`isGrounded` in-air parity** — only the half both adapters actually agree on; the resting-on-floor case diverges by design (see the new BuiltinAdapter Quirks #10 in the wiki) and is intentionally out of scope. - **`updateShape` velocity preservation** — a moving body keeps its linear velocity when its shape is swapped (matter / planck rebuild the body and re-apply saved velocity; builtin mutates the shape list in place — same observable outcome). - **Sensor + push-out matrix** — the full 2×2 grid (dyn sensor × static sensor) confirms push-out happens iff both bodies are solid. Driving infrastructure additions: - Both factories grow an `expectedCapabilities` pin (used by the capabilities test). - Matter factory gains `rayPrecision: 0 / 1` to mirror planck's existing `aabbPrecision` — close-to tolerances differ per adapter. - Tests use `world.update(16)` (engine convention is dt in ms, not seconds) and `r.alwaysUpdate = true` because builtin's broadphase is populated by `world.update`, and its `step()` gates integration on `inViewport || alwaysUpdate` — both irrelevant to matter / planck but required for builtin parity in a headless test environment. Net: matter-adapter parity 70 → 96 (+26), planck-adapter parity 68 → 94 (+26). No adapter code changes — pure regression coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 - packages/matter-adapter/tests/parity.spec.ts | 258 ++++++++++++++++++- packages/planck-adapter/tests/parity.spec.ts | 242 ++++++++++++++++- 3 files changed, 498 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1f8d33aa2..9e79cd0b0 100644 --- a/README.md +++ b/README.md @@ -229,12 +229,6 @@ Since 19.5, melonJS exposes a `PhysicsAdapter` interface so the same game code c - **[@melonjs/matter-adapter](https://github.com/melonjs/melonJS/tree/master/packages/matter-adapter)** — [matter-js](https://brm.io/matter-js/) integration. Rotational dynamics, constraints (springs / hinges / pins), sleeping bodies, continuous collision detection, raycasts. Showcased by the [Matter Platformer](https://melonjs.github.io/melonJS/examples/#/platformer-matter) and [Pool (Matter)](https://melonjs.github.io/melonJS/examples/#/pool-matter) examples. - **[@melonjs/planck-adapter](https://github.com/melonjs/melonJS/tree/master/packages/planck-adapter)** — [planck.js](https://piqnt.com/planck.js/) integration (faithful Box2D 2.3.0 port). Native joints, CCD bullet flag, sleeping bodies, native raycasts, per-body gravity scale. Showcased by the [Neon Plinko (Planck)](https://melonjs.github.io/melonJS/examples/#/plinko-planck) example. -Portable across every adapter (game code stays engine-agnostic): - -- `app.world.adapter.raycast(from, to)` — nearest body hit with precise entry geometry (`renderable`, `point`, `normal`, `fraction`) -- `app.world.adapter.queryAABB(rect)` — every renderable whose body overlaps a rectangle (AoE damage, picking, trigger sweeps) -- Collision lifecycle hooks (`onCollisionStart` / `onCollisionActive` / `onCollisionEnd`) dispatched consistently with a receiver-symmetric response shape (`response.a === this`, `response.b === other`) - See the [Migrating to the Physics Adapter API](https://github.com/melonjs/melonJS/wiki/Migrating-to-the-Physics-Adapter-API), [Switching Physics Adapters](https://github.com/melonjs/melonJS/wiki/Switching-Physics-Adapters), and [BuiltinAdapter Quirks](https://github.com/melonjs/melonJS/wiki/BuiltinAdapter-Quirks) wiki pages for migration guides and the per-adapter behaviour table. Installation diff --git a/packages/matter-adapter/tests/parity.spec.ts b/packages/matter-adapter/tests/parity.spec.ts index bf378f4cb..20b3ca70c 100644 --- a/packages/matter-adapter/tests/parity.spec.ts +++ b/packages/matter-adapter/tests/parity.spec.ts @@ -32,11 +32,36 @@ interface AdapterFactory { adapter: BuiltinAdapter | MatterAdapter; world: World; }; + /** + * Decimal-place precision for raycast / position assertions. The + * builtin SAT adapter resolves contacts at full precision; matter + * inflates slightly through its Verlet integration step + collision + * margin, so close-to assertions use precision 0 (within 0.5 px). + */ + rayPrecision: number; + /** Expected `adapter.capabilities` shape — pinned per adapter. */ + expectedCapabilities: { + constraints: boolean; + continuousCollisionDetection: boolean; + sleepingBodies: boolean; + raycasts: boolean; + velocityLimit: boolean; + isGrounded: boolean; + }; } const factories: AdapterFactory[] = [ { name: "BuiltinAdapter", + rayPrecision: 1, + expectedCapabilities: { + constraints: false, + continuousCollisionDetection: false, + sleepingBodies: false, + raycasts: true, + velocityLimit: true, + isGrounded: true, + }, make() { const adapter = new BuiltinAdapter({ gravity: new Vector2d(0, 1), @@ -47,6 +72,15 @@ const factories: AdapterFactory[] = [ }, { name: "MatterAdapter", + rayPrecision: 0, + expectedCapabilities: { + constraints: true, + continuousCollisionDetection: true, + sleepingBodies: true, + raycasts: true, + velocityLimit: true, + isGrounded: true, + }, make() { const adapter = new MatterAdapter({ gravity: { x: 0, y: 1 } }); const world = new World(0, 0, 800, 600, adapter); @@ -64,7 +98,7 @@ beforeAll(() => { }); }); -for (const { name, make } of factories) { +for (const { name, make, rayPrecision, expectedCapabilities } of factories) { describe(`Adapter parity — ${name}`, () => { let adapter: BuiltinAdapter | MatterAdapter; let world: World; @@ -700,5 +734,227 @@ for (const { name, make } of factories) { } }); }); + + // ---------------------------------------------------------- + // 19.5 portable surface — coverage gaps surfaced when the + // release-prep wiki audit found `raycast` / `queryAABB` still + // described as matter-only. They're on every adapter now; + // these tests pin that. + // ---------------------------------------------------------- + + describe("adapter.capabilities — shape pin", () => { + it("matches the per-adapter expected capability set exactly", () => { + // Guards against a PR that flips a capability flag without + // updating callers, or a refactor that drops a key. The + // values themselves are pinned by the factory, so each + // adapter asserts its own shape. + expect(adapter.capabilities).toEqual(expectedCapabilities); + }); + }); + + describe("raycast — portable hit shape", () => { + // Static box at (200,200)..(240,240). Ray from x=0 at y=220 + // crosses the left face at x≈200; from x=500 at y=220 crosses + // the right face at x≈240. Ray well above the box must miss. + const placeBox = () => { + const wall = new Renderable(200, 200, 40, 40); + wall.alwaysUpdate = true; + wall.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 40, 40)], + }; + world.addChild(wall); + // `world.update(dt)` rebuilds the builtin broadphase each + // frame; raycast / queryAABB walk that broadphase. matter + // indexes on `addBody`, but a single `world.update` is the + // portable "everything's settled" handshake here. + world.update(16); + return wall; + }; + + it("returns a hit with point / normal / fraction / renderable when the ray crosses a body", () => { + const wall = placeBox(); + const hit = adapter.raycast( + new Vector2d(0, 220), + new Vector2d(400, 220), + ); + expect(hit).not.toBeNull(); + expect(hit!.renderable).toBe(wall); + expect(hit!.point.x).toBeCloseTo(200, rayPrecision); + expect(hit!.point.y).toBeCloseTo(220, rayPrecision); + expect(hit!.normal.x).toBeCloseTo(-1, rayPrecision); + expect(hit!.normal.y).toBeCloseTo(0, rayPrecision); + // (200 - 0) / (400 - 0) = 0.5 + expect(hit!.fraction).toBeCloseTo(0.5, rayPrecision); + }); + + it("returns a right-face hit (normal flips) when shot from the other side", () => { + placeBox(); + const hit = adapter.raycast( + new Vector2d(500, 220), + new Vector2d(0, 220), + ); + expect(hit).not.toBeNull(); + expect(hit!.point.x).toBeCloseTo(240, rayPrecision); + expect(hit!.normal.x).toBeCloseTo(1, rayPrecision); + }); + + it("returns null when the ray misses every body", () => { + placeBox(); + const hit = adapter.raycast(new Vector2d(0, 50), new Vector2d(400, 50)); + expect(hit).toBeNull(); + }); + }); + + describe("queryAABB — portable region query", () => { + const placeBox = (x: number, y: number) => { + const r = new Renderable(x, y, 40, 40); + r.alwaysUpdate = true; + r.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 40, 40)], + }; + world.addChild(r); + return r; + }; + const flush = () => world.update(16); + + it("returns every body whose AABB overlaps the region", () => { + const a = placeBox(100, 100); + placeBox(400, 100); + flush(); + const hits = adapter.queryAABB(new Rect(80, 80, 80, 80)); + expect(hits).toContain(a); + expect(hits.length).toEqual(1); + }); + + it("returns an empty array when the region overlaps nothing", () => { + placeBox(100, 100); + flush(); + const hits = adapter.queryAABB(new Rect(500, 500, 40, 40)); + expect(hits).toEqual([]); + }); + + it("returns multiple bodies when the region spans them", () => { + const a = placeBox(100, 100); + const b = placeBox(200, 100); + flush(); + const hits = adapter.queryAABB(new Rect(50, 50, 300, 100)); + expect(hits).toContain(a); + expect(hits).toContain(b); + expect(hits.length).toEqual(2); + }); + }); + + describe("isGrounded — in-air parity", () => { + // Cross-adapter parity intentionally tests the in-air case + // only — both adapters agree that an isolated body in + // free-fall is NOT grounded. The "resting on a static floor" + // half diverges by design: builtin tracks an internal + // `falling` / `jumping` flag pair that gravity toggles every + // frame, while matter / planck track real contact pairs. See + // the BuiltinAdapter Quirks wiki page. + it("a body in mid-air with no body below is not grounded", () => { + const ball = new Renderable(100, 50, 32, 32); + ball.alwaysUpdate = true; + ball.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + world.addChild(ball); + + // step once so contact lists / flags are populated under + // the current state on every adapter. + world.update(16); + adapter.syncFromPhysics(); + expect(adapter.isGrounded(ball)).toEqual(false); + }); + }); + + describe("updateShape — preserves linear velocity", () => { + it("a moving body keeps its velocity when its shape is swapped", () => { + const r = new Renderable(100, 100, 32, 32); + r.alwaysUpdate = true; + r.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + world.addChild(r); + + adapter.setVelocity(r, new Vector2d(5, -3)); + // updateShape rebuilds the underlying body on matter / planck + // (and mutates the shape list in place on builtin); either + // way, the public velocity must survive. + adapter.updateShape(r, [new Rect(0, 0, 16, 16)]); + const vel = adapter.getVelocity(r); + expect(vel.x).toBeCloseTo(5, rayPrecision); + expect(vel.y).toBeCloseTo(-3, rayPrecision); + }); + }); + + describe("sensor + push-out matrix", () => { + // Drop a dynamic body onto a static floor under each adapter's + // own gravity (geometry mirrored from the collision-lifecycle + // suite, which both adapters integrate cleanly within 60 ticks). + // Solid pair → body rests at the floor top. Any sensor → body + // passes through to below the floor bottom. + const setup = ( + dynSensor: boolean, + staticSensor: boolean, + ): { dyn: Renderable; floorY: number; floorBottom: number } => { + const floorY = 200; + const floorBottom = floorY + 20; + const floor = new Renderable(0, floorY, 800, 20); + floor.alwaysUpdate = true; + floor.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 800, 20)], + isSensor: staticSensor, + }; + world.addChild(floor); + + const dyn = new Renderable(100, 150, 32, 32); + dyn.alwaysUpdate = true; + dyn.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + isSensor: dynSensor, + }; + world.addChild(dyn); + return { dyn, floorY, floorBottom }; + }; + + it("(solid dynamic) vs (solid static): push-out — body stops above the floor", () => { + const { dyn, floorY } = setup(false, false); + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + // dyn.pos.y is the top edge; for a 32 px body resting on the + // floor it should be ≈ floorY - 32 = 168. Allow a small + // budget for solver penetration tolerance. + expect(dyn.pos.y).toBeLessThan(floorY + 1); + expect(dyn.pos.y).toBeGreaterThan(floorY - 40); + }); + + it("(sensor dynamic) vs (solid static): no push-out — body passes through", () => { + const { dyn, floorBottom } = setup(true, false); + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + expect(dyn.pos.y).toBeGreaterThan(floorBottom); + }); + + it("(solid dynamic) vs (sensor static): no push-out — body passes through", () => { + const { dyn, floorBottom } = setup(false, true); + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + expect(dyn.pos.y).toBeGreaterThan(floorBottom); + }); + + it("(sensor dynamic) vs (sensor static): no push-out — body passes through", () => { + const { dyn, floorBottom } = setup(true, true); + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + expect(dyn.pos.y).toBeGreaterThan(floorBottom); + }); + }); }); } diff --git a/packages/planck-adapter/tests/parity.spec.ts b/packages/planck-adapter/tests/parity.spec.ts index fbca3b8ae..154b620b2 100644 --- a/packages/planck-adapter/tests/parity.spec.ts +++ b/packages/planck-adapter/tests/parity.spec.ts @@ -42,12 +42,29 @@ interface AdapterFactory { * precision 1, fine at precision 0 (within 0.5 px). */ aabbPrecision: number; + /** Expected `adapter.capabilities` shape — pinned per adapter. */ + expectedCapabilities: { + constraints: boolean; + continuousCollisionDetection: boolean; + sleepingBodies: boolean; + raycasts: boolean; + velocityLimit: boolean; + isGrounded: boolean; + }; } const factories: AdapterFactory[] = [ { name: "BuiltinAdapter", aabbPrecision: 1, + expectedCapabilities: { + constraints: false, + continuousCollisionDetection: false, + sleepingBodies: false, + raycasts: true, + velocityLimit: true, + isGrounded: true, + }, make() { const adapter = new BuiltinAdapter({ gravity: new Vector2d(0, 1), @@ -59,6 +76,14 @@ const factories: AdapterFactory[] = [ { name: "PlanckAdapter", aabbPrecision: 0, + expectedCapabilities: { + constraints: true, + continuousCollisionDetection: true, + sleepingBodies: true, + raycasts: true, + velocityLimit: true, + isGrounded: true, + }, make() { // Gravity is in px/s² at the adapter surface (planck converts // to meters internally via `pixelsPerMeter`). Use a real @@ -84,7 +109,7 @@ beforeAll(() => { }); }); -for (const { name, make, aabbPrecision } of factories) { +for (const { name, make, aabbPrecision, expectedCapabilities } of factories) { describe(`Adapter parity — ${name}`, () => { let adapter: BuiltinAdapter | PlanckAdapter; let world: World; @@ -606,5 +631,220 @@ for (const { name, make, aabbPrecision } of factories) { expect(out.y).toBeCloseTo(-1.5, 3); }); }); + + // ---------------------------------------------------------- + // 19.5 portable surface — coverage gaps surfaced when the + // release-prep wiki audit found `raycast` / `queryAABB` still + // described as matter-only. They're on every adapter now; + // these tests pin that. + // ---------------------------------------------------------- + + describe("adapter.capabilities — shape pin", () => { + it("matches the per-adapter expected capability set exactly", () => { + // Guards against a PR that flips a capability flag without + // updating callers, or a refactor that drops a key. The + // values themselves are pinned by the factory, so each + // adapter asserts its own shape. + expect(adapter.capabilities).toEqual(expectedCapabilities); + }); + }); + + describe("raycast — portable hit shape", () => { + // Static box at (200,200)..(240,240). Ray from x=0 at y=220 + // crosses the left face at x≈200; from x=500 at y=220 crosses + // the right face at x≈240. Ray well above the box must miss. + const placeBox = () => { + const wall = new Renderable(200, 200, 40, 40); + wall.alwaysUpdate = true; + wall.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 40, 40)], + }; + world.addChild(wall); + // `world.update(dt)` rebuilds the builtin broadphase each + // frame; raycast / queryAABB walk that broadphase. planck + // indexes on `addBody`, but a single `world.update` is the + // portable "everything's settled" handshake here. + world.update(16); + return wall; + }; + + it("returns a hit with point / normal / fraction / renderable when the ray crosses a body", () => { + const wall = placeBox(); + const hit = adapter.raycast( + new Vector2d(0, 220), + new Vector2d(400, 220), + ); + expect(hit).not.toBeNull(); + expect(hit!.renderable).toBe(wall); + expect(hit!.point.x).toBeCloseTo(200, aabbPrecision); + expect(hit!.point.y).toBeCloseTo(220, aabbPrecision); + expect(hit!.normal.x).toBeCloseTo(-1, aabbPrecision); + expect(hit!.normal.y).toBeCloseTo(0, aabbPrecision); + // (200 - 0) / (400 - 0) = 0.5 + expect(hit!.fraction).toBeCloseTo(0.5, aabbPrecision); + }); + + it("returns a right-face hit (normal flips) when shot from the other side", () => { + placeBox(); + const hit = adapter.raycast( + new Vector2d(500, 220), + new Vector2d(0, 220), + ); + expect(hit).not.toBeNull(); + expect(hit!.point.x).toBeCloseTo(240, aabbPrecision); + expect(hit!.normal.x).toBeCloseTo(1, aabbPrecision); + }); + + it("returns null when the ray misses every body", () => { + placeBox(); + const hit = adapter.raycast(new Vector2d(0, 50), new Vector2d(400, 50)); + expect(hit).toBeNull(); + }); + }); + + describe("queryAABB — portable region query", () => { + const placeBox = (x: number, y: number) => { + const r = new Renderable(x, y, 40, 40); + r.alwaysUpdate = true; + r.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 40, 40)], + }; + world.addChild(r); + return r; + }; + const flush = () => world.update(16); + + it("returns every body whose AABB overlaps the region", () => { + const a = placeBox(100, 100); + placeBox(400, 100); + flush(); + const hits = adapter.queryAABB(new Rect(80, 80, 80, 80)); + expect(hits).toContain(a); + expect(hits.length).toEqual(1); + }); + + it("returns an empty array when the region overlaps nothing", () => { + placeBox(100, 100); + flush(); + const hits = adapter.queryAABB(new Rect(500, 500, 40, 40)); + expect(hits).toEqual([]); + }); + + it("returns multiple bodies when the region spans them", () => { + const a = placeBox(100, 100); + const b = placeBox(200, 100); + flush(); + const hits = adapter.queryAABB(new Rect(50, 50, 300, 100)); + expect(hits).toContain(a); + expect(hits).toContain(b); + expect(hits.length).toEqual(2); + }); + }); + + describe("isGrounded — in-air parity", () => { + // Cross-adapter parity intentionally tests the in-air case + // only — both adapters agree that an isolated body in + // free-fall is NOT grounded. The "resting on a static floor" + // half diverges by design: builtin tracks an internal + // `falling` / `jumping` flag pair that gravity toggles every + // frame, while matter / planck track real contact pairs. + // See the BuiltinAdapter Quirks wiki page. + it("a body in mid-air with no body below is not grounded", () => { + const ball = new Renderable(100, 50, 32, 32); + ball.alwaysUpdate = true; + ball.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + world.addChild(ball); + + world.update(16); + adapter.syncFromPhysics(); + expect(adapter.isGrounded(ball)).toEqual(false); + }); + }); + + describe("updateShape — preserves linear velocity", () => { + it("a moving body keeps its velocity when its shape is swapped", () => { + const r = new Renderable(100, 100, 32, 32); + r.alwaysUpdate = true; + r.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + world.addChild(r); + + adapter.setVelocity(r, new Vector2d(5, -3)); + // updateShape rebuilds the underlying body on matter / planck + // (and mutates the shape list in place on builtin); either + // way, the public velocity must survive. + adapter.updateShape(r, [new Rect(0, 0, 16, 16)]); + const vel = adapter.getVelocity(r); + expect(vel.x).toBeCloseTo(5, aabbPrecision); + expect(vel.y).toBeCloseTo(-3, aabbPrecision); + }); + }); + + describe("sensor + push-out matrix", () => { + // Drop a dynamic body onto a static floor under each adapter's + // own gravity. Solid pair → body rests at the floor top. + // Any sensor → body passes through to below the floor bottom. + const setup = ( + dynSensor: boolean, + staticSensor: boolean, + ): { dyn: Renderable; floorY: number; floorBottom: number } => { + const floorY = 200; + const floorBottom = floorY + 20; + const floor = new Renderable(0, floorY, 800, 20); + floor.alwaysUpdate = true; + floor.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 800, 20)], + isSensor: staticSensor, + }; + world.addChild(floor); + + const dyn = new Renderable(100, 150, 32, 32); + dyn.alwaysUpdate = true; + dyn.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + isSensor: dynSensor, + }; + world.addChild(dyn); + return { dyn, floorY, floorBottom }; + }; + + it("(solid dynamic) vs (solid static): push-out — body stops above the floor", () => { + const { dyn, floorY } = setup(false, false); + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + expect(dyn.pos.y).toBeLessThan(floorY + 1); + expect(dyn.pos.y).toBeGreaterThan(floorY - 40); + }); + + it("(sensor dynamic) vs (solid static): no push-out — body passes through", () => { + const { dyn, floorBottom } = setup(true, false); + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + expect(dyn.pos.y).toBeGreaterThan(floorBottom); + }); + + it("(solid dynamic) vs (sensor static): no push-out — body passes through", () => { + const { dyn, floorBottom } = setup(false, true); + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + expect(dyn.pos.y).toBeGreaterThan(floorBottom); + }); + + it("(sensor dynamic) vs (sensor static): no push-out — body passes through", () => { + const { dyn, floorBottom } = setup(true, true); + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + expect(dyn.pos.y).toBeGreaterThan(floorBottom); + }); + }); }); } From 8081f9b157e9b84c96c16b57d9262407e3908432 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 21 May 2026 12:15:04 +0800 Subject: [PATCH 03/11] =?UTF-8?q?test(adapters)=20+=20refactor(physics):?= =?UTF-8?q?=20adversarial=20parity,=20matter=20setPosition=20fix,=20Builti?= =?UTF-8?q?nAdapter=20=E2=86=92=20TS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## matter-adapter bug fix `MatterAdapter.setPosition` now zeroes `body.positionImpulse` after the teleport. Matter's `Resolver.postSolvePosition` applies the cached per-body position-correction every step via the warming mechanism, independently of `pairs.list`. After a discontinuous teleport out of a penetrating overlap, the cached impulse contained the OLD penetration vector and yanked the body back ≈ penetration depth on the next step. Builtin and planck handle this natively (planck inherits the correct behaviour from Box2D's `b2Body::SetTransform`); matter doesn't, so the adapter does it explicitly. Regression test in both parity specs pins drift = 0 on every adapter. ## Cross-adapter parity coverage Both `matter-adapter/tests/parity.spec.ts` and `planck-adapter/tests/parity.spec.ts` grow a substantial block of new tests, each running against BuiltinAdapter + the target adapter via the existing factory pattern: - **`adapter.capabilities` shape pin** — per-factory `expectedCapabilities` asserts the exact flag set (catches accidental capability flips). - **`raycast` / `queryAABB` portable hit shape** — pins the 19.5 contract (`renderable`, `point`, `normal`, `fraction`); previously described as matter-only in the wiki. - **Raycast/queryAABB are geometric, not collision-filtered** — explicitly pin that mask/type don't filter spatial-query results. - **`isGrounded` in-air parity** — only the half adapters actually agree on; the resting-on-floor case diverges by design (builtin flag-based vs. matter/planck contact-based). - **`updateShape` preserves linear velocity** — moving body's velocity survives a shape swap on every adapter. - **Sensor + push-out matrix** — full 2×2 grid (dyn sensor × static sensor), push-out happens iff both are solid. - **`maxVelocity` clamping under sustained force** — pins behavioural clamping, not just config propagation. Five adversarial common-gameplay-pattern tests added under `describe("adversarial — common gameplay patterns")`: 1. **Deferred-removal pickup pattern** — `onCollisionStart` flags coin → `removeChildNow` after step → `queryAABB` confirms gone, no double-fire. 2. **`setPosition` away from a resting body** — cutscene / level-transition teleport survives one step without being dragged back to the floor. 3. **`setStatic` mid-collision** — body in active contact freezes in place, no further drift. 4. **Nested-container `removeChildNow`** — body inside a sub-Container de-registers from the adapter (level-cleanup pattern). 5. **`setSensor` toggle mid-flight** — one-way-platform pattern; sensor=true passes through, sensor=false lands on subsequent contact. Plus per-adapter behavioural fills surfaced by an audit of existing coverage: - **matter `gravityScale: 0` prevents motion** + **`gravityScale: 0.5` falls half as fast** — pins the per-body counter-force emulation (matter-js 0.20 has no native per-body gravityScale; the adapter compensates in a `beforeUpdate` hook). The stress spec already covered the bookkeeping drainage; this covers the behavioural side. - **planck `maxVelocity`** — config propagation, `setMaxVelocity` override, sustained-force clamping. Was zero coverage before. Final test counts: matter 161 → 173 (+12), planck 153 → 165 (+12). ## Physics layer JS → TS migration Two brand-new-in-19.5 files that should match the rest of the new adapter surface (which is all TS): - `physics/builtin/builtin-adapter.js` → `.ts` (556 lines) - `physics/builtin/raycast.js` → `.ts` (293 lines) Both now `implements PhysicsAdapter` / type their exports against the public adapter interface. `raycastQuery` returns `RaycastHit[]` directly instead of the loose `{renderable: object, …}[]` it had as JSDoc, which lets `BuiltinAdapter.raycast` drop its boundary cast. Adjacent JS holdouts (`body.js`, `world.js`, `detector.js`, `aseprite.js`, `shine.js`) deferred — body / world / detector are legacy classes with substantial scope, aseprite is internal-only, shine matches other shader effects which are all JS. Progressive migration, one user-facing class at a time. ## Adapter source cleanup Zero `as unknown as` left in either adapter's `src/index.ts`: - **`positionImpulse`** access in matter-adapter — single intersection cast (`as Matter.Body & { positionImpulse: Matter.Vector }`) where the field exists on every runtime body but isn't in `@types/matter-js`. - **Ellipse shape conversion** in both adapters — entire double-cast deleted; the `instanceof Ellipse` already narrows the type and `radiusV` is always initialized in the constructor, so the `?.` / `??` fallbacks were unreachable. - **`renderable.currentTransform`** access in `syncFromPhysics` (both adapters) — the cast + per-method `typeof === "function"` runtime guards contradicted the actual `currentTransform: Matrix3d` typed field. Use it directly. ## Cycle clean-up Two physics-local cycles addressed in `madge --circular --ts-config`: - **`physics/bounds.ts` ↔ `physics/geometries/polygon.ts`** — fully eliminated. `Bounds.toPolygon()` removed (added 2014 for the `me.Rect → polygon for SAT` idiom, made obsolete years ago when the shape API learned to handle Rect/Ellipse/Polygon natively, zero callers across the entire monorepo for the last 5+ years). - **`physics/adapter.ts` ↔ `physics/world.js`** — adapter.ts no longer has inline `import("./world.js").default` / `import("../renderable/ renderable.js").default` in 30 type positions; collapsed to top-level `import type World` / `import type Renderable` aliases. Runtime-erased via `import type`, so the cycle is no longer a real one. (madge with `--ts-config` may still flag it conservatively; the remaining trio is static-analysis noise.) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/matter-adapter/src/index.ts | 93 +-- .../tests/matter-adapter.spec.ts | 48 ++ packages/matter-adapter/tests/parity.spec.ts | 314 ++++++++++ packages/melonjs/src/index.ts | 2 +- packages/melonjs/src/physics/adapter.ts | 124 +--- packages/melonjs/src/physics/bounds.ts | 14 - .../src/physics/builtin/builtin-adapter.js | 556 ------------------ .../src/physics/builtin/builtin-adapter.ts | 427 ++++++++++++++ .../melonjs/src/physics/builtin/detector.js | 2 +- .../builtin/{raycast.js => raycast.ts} | 158 ++--- packages/melonjs/src/physics/world.js | 2 +- packages/planck-adapter/src/index.ts | 48 +- packages/planck-adapter/tests/parity.spec.ts | 276 +++++++++ .../tests/planck-adapter.spec.ts | 49 ++ 14 files changed, 1300 insertions(+), 813 deletions(-) delete mode 100644 packages/melonjs/src/physics/builtin/builtin-adapter.js create mode 100644 packages/melonjs/src/physics/builtin/builtin-adapter.ts rename packages/melonjs/src/physics/builtin/{raycast.js => raycast.ts} (68%) diff --git a/packages/matter-adapter/src/index.ts b/packages/matter-adapter/src/index.ts index 1a4fc03fb..aa8f88249 100644 --- a/packages/matter-adapter/src/index.ts +++ b/packages/matter-adapter/src/index.ts @@ -332,32 +332,17 @@ export class MatterAdapter implements PhysicsAdapter { // `pos` would spin the sprite around its corner. Pre-translate // by the centroid-relative offset so the rotation lands on // the visible center regardless of anchor. - const t = ( - renderable as { - currentTransform?: { - identity?: () => unknown; - rotate?: (a: number) => unknown; - translate?: (x: number, y: number) => unknown; - }; - } - ).currentTransform; - if ( - t && - typeof t.identity === "function" && - typeof t.rotate === "function" && - typeof t.translate === "function" - ) { - const off2 = this.posOffsets.get(renderable); - const cx = off2 ? -off2.x : 0; - const cy = off2 ? -off2.y : 0; - t.identity(); - if (cx !== 0 || cy !== 0) { - t.translate(cx, cy); - t.rotate(body.angle); - t.translate(-cx, -cy); - } else { - t.rotate(body.angle); - } + const t = renderable.currentTransform; + const off2 = this.posOffsets.get(renderable); + const cx = off2 ? -off2.x : 0; + const cy = off2 ? -off2.y : 0; + t.identity(); + if (cx !== 0 || cy !== 0) { + t.translate(cx, cy); + t.rotate(body.angle); + t.translate(-cx, -cy); + } else { + t.rotate(body.angle); } } } @@ -672,11 +657,52 @@ export class MatterAdapter implements PhysicsAdapter { x: p.x - (off?.x ?? 0), y: p.y - (off?.y ?? 0), }); + // Matter caches contact pairs across steps for solver continuity. + // After a discontinuous teleport the cached pair still holds the + // previous penetration depth + normal, and matter's Baumgarte + // position-correction phase applies that on the next step before + // narrow-phase invalidates the pair — yanking the body back + // toward the old contact point by ≈ penetration depth. Builtin + // and planck handle this natively (planck's `b2Body::SetTransform` + // documents "contacts are updated on the next call to + // b2World::Step"); matter doesn't, so do it ourselves. + this.invalidateContactsFor(body); } renderable.pos.x = p.x; renderable.pos.y = p.y; } + /** + * Flush the per-body cached position-correction impulse so matter's + * position-warming mechanism doesn't reapply stale penetration data + * on the next step. Used by {@link setPosition} to keep a + * discontinuous teleport from being undone by the solver. + */ + private invalidateContactsFor(body: Matter.Body): void { + // Zero the per-body cached position-correction impulse. Matter's + // `Resolver.postSolvePosition` applies `body.positionImpulse` to + // the body every step via the position-warming mechanism (for + // solver continuity across frames), independently of whether + // `pairs.list` still has an active contact. After a discontinuous + // teleport that cached impulse contains the OLD penetration + // vector, and reapplying it yanks the body back toward the old + // contact point by ≈ penetration depth. Zeroing it disables the + // warming for one frame — matter will rebuild a fresh impulse + // from genuine contacts on the next step if any exist. + // Note: engine.pairs is left alone deliberately so matter's + // natural pair-lifecycle (Pairs.update marking the stale pair + // as inactive on the next step, then moving it to collisionEnd + // and firing the event) still works. + // `positionImpulse` is a real per-body field but isn't in + // `@types/matter-js` (matter exposes it as a solver internal); + // intersect the public Body type with the field we touch rather + // than discarding the type entirely. + const impulse = (body as Matter.Body & { positionImpulse: Matter.Vector }) + .positionImpulse; + impulse.x = 0; + impulse.y = 0; + } + setAngle(renderable: Renderable, angle: number): void { const body = this.bodyMap.get(renderable); if (body) { @@ -1072,15 +1098,12 @@ export class MatterAdapter implements PhysicsAdapter { // Matter has no native ellipse — approximate as a circle with the // average radius. For tall/narrow ellipses this is a poor fit; // a future improvement could synthesize a polygon hull. - const e = shape as unknown as { - pos: { x: number; y: number }; - radiusV?: { x: number; y: number }; - radius?: number; - }; - const rx = e.radiusV?.x ?? e.radius ?? 1; - const ry = e.radiusV?.y ?? e.radius ?? 1; - const radius = (rx + ry) / 2; - return Matter.Bodies.circle(baseX + e.pos.x, baseY + e.pos.y, radius); + const radius = (shape.radiusV.x + shape.radiusV.y) / 2; + return Matter.Bodies.circle( + baseX + shape.pos.x, + baseY + shape.pos.y, + radius, + ); } if (shape instanceof Polygon) { // translate polygon vertices into world-space, then let Matter diff --git a/packages/matter-adapter/tests/matter-adapter.spec.ts b/packages/matter-adapter/tests/matter-adapter.spec.ts index c3a86d701..2c560f2c3 100644 --- a/packages/matter-adapter/tests/matter-adapter.spec.ts +++ b/packages/matter-adapter/tests/matter-adapter.spec.ts @@ -840,4 +840,52 @@ describe("MatterAdapter — feature parity with BuiltinAdapter", () => { expect(matches).toContain(r); }); }); + + describe("gravityScale emulation", () => { + // matter-js 0.20 has no per-body gravityScale field — the adapter + // emulates it via a counter-force loop tracked in `bodyGravityScale` + // (see `src/index.ts:144`). The stress spec already pins that the + // internal map is drained on removeBody; this test pins the + // behavioural side: `gravityScale: 0` actually nulls out gravity + // so the body doesn't fall. Easy to silently break if the gravity + // application path is refactored. + it("gravityScale: 0 prevents a body from falling under world gravity", () => { + const r = new Renderable(100, 100, 32, 32); + adapter.addBody(r, { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + gravityScale: 0, + }); + const startY = r.pos.y; + for (let i = 0; i < 60; i++) adapter.step(16); + adapter.syncFromPhysics(); + expect(Math.abs(r.pos.y - startY)).toBeLessThan(0.1); + }); + + it("gravityScale: 0.5 falls roughly half as fast as scale: 1", () => { + const rFull = new Renderable(100, 100, 32, 32); + adapter.addBody(rFull, { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + gravityScale: 1, + }); + const rHalf = new Renderable(200, 100, 32, 32); + adapter.addBody(rHalf, { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + gravityScale: 0.5, + }); + for (let i = 0; i < 60; i++) adapter.step(16); + adapter.syncFromPhysics(); + const fallFull = rFull.pos.y - 100; + const fallHalf = rHalf.pos.y - 100; + // Half-gravity body falls slower than full-gravity body. + expect(fallHalf).toBeLessThan(fallFull); + // And the ratio is in the ballpark of 0.5 (matter integrates + // over dt so the exact ratio drifts a bit; allow a wide band). + const ratio = fallHalf / fallFull; + expect(ratio).toBeGreaterThan(0.3); + expect(ratio).toBeLessThan(0.7); + }); + }); }); diff --git a/packages/matter-adapter/tests/parity.spec.ts b/packages/matter-adapter/tests/parity.spec.ts index 20b3ca70c..8c31962d5 100644 --- a/packages/matter-adapter/tests/parity.spec.ts +++ b/packages/matter-adapter/tests/parity.spec.ts @@ -16,6 +16,7 @@ import { Bounds, BuiltinAdapter, boot, + Container, collision, Rect, Renderable, @@ -956,5 +957,318 @@ for (const { name, make, rayPrecision, expectedCapabilities } of factories) { expect(dyn.pos.y).toBeGreaterThan(floorBottom); }); }); + + describe("raycast / queryAABB are geometric (not collision-filtered)", () => { + // Raycast and queryAABB are pure spatial queries — they return + // every body whose geometry intersects the query, regardless of + // `collisionType` / `collisionMask`. Box2D's RayCast and matter's + // Query.ray both follow this convention; the portable adapter + // surface inherits it. Pin that here so a future PR that adds + // "helpful" implicit mask filtering to one adapter is caught. + const placeWall = ( + x: number, + collisionType: number, + collisionMask: number, + ) => { + const wall = new Renderable(x, 200, 40, 40); + wall.alwaysUpdate = true; + wall.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 40, 40)], + collisionType, + collisionMask, + }; + world.addChild(wall); + return wall; + }; + + it("raycast returns the nearest hit regardless of collisionMask", () => { + // Two walls in line. Near wall has a mask that explicitly + // would NOT collide with anything (mask === 0). A + // collision-filtered raycast would skip it; a geometric + // raycast must return it because it's nearer. + const near = placeWall(100, collision.types.WORLD_SHAPE, 0); + placeWall(300, collision.types.WORLD_SHAPE, collision.types.ALL_OBJECT); + world.update(16); + const hit = adapter.raycast( + new Vector2d(0, 220), + new Vector2d(500, 220), + ); + expect(hit).not.toBeNull(); + expect(hit!.renderable).toBe(near); + }); + + it("queryAABB returns every overlapping body regardless of collisionMask", () => { + const a = placeWall(100, collision.types.WORLD_SHAPE, 0); + const b = placeWall( + 200, + collision.types.WORLD_SHAPE, + collision.types.ALL_OBJECT, + ); + world.update(16); + const hits = adapter.queryAABB(new Rect(50, 150, 300, 200)); + expect(hits).toContain(a); + expect(hits).toContain(b); + expect(hits.length).toEqual(2); + }); + }); + + describe("adversarial — common gameplay patterns", () => { + // Patterns every game hits sooner or later. Bugs here cascade + // silently in production, so each test pins an invariant that + // is easy to break by refactoring the contact / lifecycle path. + + it("deferred-removal pickup pattern — body is gone from queries the next frame", () => { + // The portable pickup idiom: onCollisionStart flags the coin + // for removal, the actual removeChildNow happens AFTER the + // world step. Matches the recommendation in BuiltinAdapter + // Quirks #6 ("defer destructive ops in collision callbacks"). + // Pins that: (a) the contact fires exactly once, (b) the + // post-removal queryAABB no longer returns the coin. + let pickedUp: Renderable | undefined; + const events: string[] = []; + class Coin extends Renderable { + onCollisionStart() { + events.push("pickup"); + pickedUp = this; + } + } + const player = new Renderable(100, 100, 32, 32); + player.alwaysUpdate = true; + player.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + gravityScale: 0, + }; + world.addChild(player); + const coin = new Coin(108, 100, 16, 16); + coin.alwaysUpdate = true; + coin.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 16, 16)], + isSensor: true, + }; + world.addChild(coin); + + world.update(16); + expect(pickedUp).toBeDefined(); + expect(events.length).toEqual(1); + + // Deferred removal — safe on every adapter. + pickedUp!.ancestor.removeChildNow(pickedUp!); + world.update(16); + adapter.syncFromPhysics(); + + // Coin must not show up in subsequent spatial queries. + const hits = adapter.queryAABB(new Rect(100, 95, 30, 30)); + expect(hits).not.toContain(pickedUp); + // And no second pickup fired. + expect(events.length).toEqual(1); + }); + + it("setPosition out of penetration — no stale-contact correction", () => { + // Regression: matter caches per-body position-correction + // impulse across steps (the "warming" mechanism) and + // would reapply the OLD penetration vector for one frame + // after a teleport, yanking the body back ≈ penetration + // depth toward the wall. Builtin and planck handle this + // natively; matter-adapter fixes it by zeroing + // `body.positionImpulse` inside `setPosition`. Pin + // drift = 0 on every adapter. + const wall = new Renderable(200, 200, 40, 40); + wall.alwaysUpdate = true; + wall.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 40, 40)], + }; + world.addChild(wall); + + const mover = new Renderable(195, 200, 32, 32); + mover.alwaysUpdate = true; + mover.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + gravityScale: 0, + }; + world.addChild(mover); + // Step once so matter caches the penetrating contact pair. + world.update(16); + + adapter.setPosition(mover, new Vector2d(500, 100)); + adapter.setVelocity(mover, new Vector2d(0, 0)); + world.update(16); + adapter.syncFromPhysics(); + expect(Math.abs(mover.pos.x - 500)).toBeLessThan(1); + expect(Math.abs(mover.pos.y - 100)).toBeLessThan(1); + }); + + it("setPosition while in contact — teleport away from a resting body works", () => { + // Common cutscene / level-transition pattern: a body + // resting on a floor is teleported elsewhere. One step + // later it must be where we put it, not pulled back to + // the floor by a stale contact-resolution force. + const floor = new Renderable(0, 200, 800, 20); + floor.alwaysUpdate = true; + floor.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 800, 20)], + }; + world.addChild(floor); + + const ball = new Renderable(100, 150, 32, 32); + ball.alwaysUpdate = true; + ball.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + world.addChild(ball); + + // Let it settle on the floor (real contact, no penetration). + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + + // Teleport up high, zero velocity. + adapter.setPosition(ball, new Vector2d(400, 50)); + adapter.setVelocity(ball, new Vector2d(0, 0)); + world.update(16); + adapter.syncFromPhysics(); + // Position should be near the teleport target. Some y drift + // is allowed under gravity for one step (gravity is tiny); + // x has no force on it so should be exact. + expect(Math.abs(ball.pos.x - 400)).toBeLessThan(2); + expect(Math.abs(ball.pos.y - 50)).toBeLessThan(5); + }); + + it("setStatic mid-collision — frozen body stops, partner stops being pushed", () => { + // A dynamic body sitting on a floor (in active contact) + // is converted to static. After the toggle the formerly + // dynamic body must not move — and gravity must not keep + // applying through the (now static) contact. + const floor = new Renderable(0, 200, 800, 20); + floor.alwaysUpdate = true; + floor.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 800, 20)], + }; + world.addChild(floor); + + const dyn = new Renderable(100, 150, 32, 32); + dyn.alwaysUpdate = true; + dyn.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + world.addChild(dyn); + // Let it land + settle into contact with the floor. + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + const restingY = dyn.pos.y; + + adapter.setStatic(dyn, true); + for (let i = 0; i < 30; i++) world.update(16); + adapter.syncFromPhysics(); + expect(Math.abs(dyn.pos.y - restingY)).toBeLessThan(1); + }); + + it("nested-container removeChildNow de-registers the body from the adapter", () => { + // Common level-management pattern: bodies live inside a + // "level" subcontainer that gets cleared on transition. + // Removing the child must drain the adapter's bookkeeping, + // otherwise stale bodies keep showing up in raycast / + // queryAABB after the level is gone. + const sub = new Container(0, 0, 800, 600); + world.addChild(sub); + + const r = new Renderable(100, 100, 32, 32); + r.alwaysUpdate = true; + r.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + sub.addChild(r); + world.update(16); + + const seenBefore = adapter.queryAABB(new Rect(90, 90, 50, 50)); + expect(seenBefore).toContain(r); + + sub.removeChildNow(r, true); + world.update(16); + const seenAfter = adapter.queryAABB(new Rect(90, 90, 50, 50)); + expect(seenAfter).not.toContain(r); + }); + + it("setSensor toggle mid-flight — one-way-platform pattern", () => { + // A solid floor becomes a sensor for one frame so a falling + // body passes through, then flips back to solid. Pin both + // transitions: sensor=true ⇒ body falls past; sensor=false + // ⇒ body lands on subsequent contact. + const floor = new Renderable(0, 200, 800, 20); + floor.alwaysUpdate = true; + floor.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 800, 20)], + }; + world.addChild(floor); + + const ball = new Renderable(100, 150, 32, 32); + ball.alwaysUpdate = true; + ball.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + world.addChild(ball); + + // Flip the floor to sensor before contact — the ball must + // pass straight through. + adapter.setSensor?.(floor, true); + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + expect(ball.pos.y).toBeGreaterThan(220); // past the floor's bottom + + // Now flip back to solid; teleport the ball above the + // floor again and assert it lands on top this time. + adapter.setSensor?.(floor, false); + adapter.setPosition(ball, new Vector2d(100, 150)); + adapter.setVelocity(ball, new Vector2d(0, 0)); + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + // Resting on top of the floor: ball top edge ≈ 200 - 32 = 168. + expect(ball.pos.y).toBeLessThan(201); + expect(ball.pos.y).toBeGreaterThan(140); + }); + }); + + describe("maxVelocity — actually clamps under sustained force", () => { + // `maxVelocity` is a hard ceiling — applying a force every step + // must not let `|vel|` exceed the configured limit. Pinning the + // behaviour, not just the config propagation that the per-adapter + // specs already cover. + it("|vel.x| stays at or below the configured cap", () => { + const r = new Renderable(100, 100, 32, 32); + r.alwaysUpdate = true; + r.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + maxVelocity: { x: 5, y: 5 }, + // Disable gravity so the y component stays a clean + // signal of the cap (gravity would constantly fight + // the clamp from below). + gravityScale: 0, + }; + world.addChild(r); + + // Hammer with sustained force to push the body well past + // the cap if clamping is broken. + for (let i = 0; i < 30; i++) { + adapter.applyForce(r, new Vector2d(100, 0)); + world.update(16); + } + const vel = adapter.getVelocity(r); + // Allow a tiny per-adapter slop (matter integrates over dt + // so the cap is reached asymptotically; planck rounds + // through meters). + expect(Math.abs(vel.x)).toBeLessThanOrEqual(5 + 0.1); + }); + }); }); } diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index b56353d11..29a3d7aae 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -132,7 +132,7 @@ export type { RaycastHit, } from "./physics/adapter.ts"; export { Bounds } from "./physics/bounds.ts"; -export { default as BuiltinAdapter } from "./physics/builtin/builtin-adapter.js"; +export { default as BuiltinAdapter } from "./physics/builtin/builtin-adapter.ts"; export { collision } from "./physics/collision.js"; export * as plugin from "./plugin/plugin.ts"; export { getPool } from "./pool.ts"; diff --git a/packages/melonjs/src/physics/adapter.ts b/packages/melonjs/src/physics/adapter.ts index ff5cf5c11..710e53b36 100644 --- a/packages/melonjs/src/physics/adapter.ts +++ b/packages/melonjs/src/physics/adapter.ts @@ -2,12 +2,9 @@ import type { Ellipse } from "../geometries/ellipse.ts"; import type { Polygon } from "../geometries/polygon.ts"; import type { Rect } from "../geometries/rectangle.ts"; import type { Vector2d } from "../math/vector2d.ts"; +import type Renderable from "../renderable/renderable.js"; import type { Bounds } from "./bounds.ts"; - -/** - * @import Renderable from "../renderable/renderable.js"; - * @import World from "./world.js"; - */ +import type World from "./world.js"; /** * Body simulation kind. `static` bodies never move (terrain, walls); @@ -44,9 +41,9 @@ export type BodyShape = Rect | Ellipse | Polygon; */ export interface CollisionResponse { /** the renderable whose handler is firing — always `=== this`. */ - a: import("../renderable/renderable.js").default; + a: Renderable; /** the partner renderable — always `=== other`. */ - b: import("../renderable/renderable.js").default; + b: Renderable; /** * Unit minimum-translation vector for the receiver: the direction `a` * must move to separate from `b`. Same sign convention across every @@ -320,7 +317,7 @@ export interface AdapterOptions { /** Result of a successful {@link PhysicsAdapter.raycast}. */ export interface RaycastHit { /** the renderable whose body the ray hit */ - renderable: import("../renderable/renderable.js").default; + renderable: Renderable; /** world-space hit point */ point: Vector2d; /** surface normal at the hit point */ @@ -404,7 +401,7 @@ export interface PhysicsAdapter { * Adapters may register internal listeners, allocate native engine * state, or read world bounds here. */ - init?(world: import("./world.js").default): void; + init?(world: World): void; /** Called when the adapter is being torn down; release native resources. */ destroy?(): void; @@ -427,9 +424,7 @@ export interface PhysicsAdapter { * also expose this via {@link setPosition} and skip implementing * it directly. */ - syncToPhysics?( - renderable: import("../renderable/renderable.js").default, - ): void; + syncToPhysics?(renderable: Renderable): void; /** * Register a body with the simulation. Returns an opaque handle that @@ -446,23 +441,17 @@ export interface PhysicsAdapter { * without a matching `addChild` will integrate (velocity, forces) but * never collide. */ - addBody( - renderable: import("../renderable/renderable.js").default, - def: BodyDefinition, - ): PhysicsBody; + addBody(renderable: Renderable, def: BodyDefinition): PhysicsBody; /** * Unregister a body. Called automatically when `Container.removeChild` * detaches the renderable; direct calls are the inverse of a direct * `addBody` (rare — use `removeChild` for the normal lifecycle). */ - removeBody(renderable: import("../renderable/renderable.js").default): void; + removeBody(renderable: Renderable): void; /** Replace the body's collision geometry without re-creating the body. */ - updateShape?( - renderable: import("../renderable/renderable.js").default, - shapes: BodyShape[], - ): void; + updateShape?(renderable: Renderable, shapes: BodyShape[]): void; /** * Portable velocity / force / position API. Every adapter implements @@ -470,48 +459,24 @@ export interface PhysicsAdapter { * mutating the body handle directly when writing adapter-agnostic * code. */ - getVelocity( - renderable: import("../renderable/renderable.js").default, - out?: Vector2d, - ): Vector2d; - setVelocity( - renderable: import("../renderable/renderable.js").default, - v: Vector2d, - ): void; - applyForce( - renderable: import("../renderable/renderable.js").default, - force: Vector2d, - point?: Vector2d, - ): void; + getVelocity(renderable: Renderable, out?: Vector2d): Vector2d; + setVelocity(renderable: Renderable, v: Vector2d): void; + applyForce(renderable: Renderable, force: Vector2d, point?: Vector2d): void; applyImpulse( - renderable: import("../renderable/renderable.js").default, + renderable: Renderable, impulse: Vector2d, point?: Vector2d, ): void; - setPosition( - renderable: import("../renderable/renderable.js").default, - p: Vector2d, - ): void; - setAngle?( - renderable: import("../renderable/renderable.js").default, - angle: number, - ): void; + setPosition(renderable: Renderable, p: Vector2d): void; + setAngle?(renderable: Renderable, angle: number): void; /** Read absolute rotation angle (radians). Returns 0 if not tracked. */ - getAngle?(renderable: import("../renderable/renderable.js").default): number; + getAngle?(renderable: Renderable): number; /** Set angular velocity (rad / frame). */ - setAngularVelocity?( - renderable: import("../renderable/renderable.js").default, - omega: number, - ): void; + setAngularVelocity?(renderable: Renderable, omega: number): void; /** Read angular velocity (rad / frame). Returns 0 if not tracked. */ - getAngularVelocity?( - renderable: import("../renderable/renderable.js").default, - ): number; + getAngularVelocity?(renderable: Renderable): number; /** Apply an angular impulse (`Δω = τ / inertia`). */ - applyTorque?( - renderable: import("../renderable/renderable.js").default, - torque: number, - ): void; + applyTorque?(renderable: Renderable, torque: number): void; /** * Runtime body-property mutators. Each maps to the corresponding @@ -520,40 +485,25 @@ export interface PhysicsAdapter { * route to their native engine (BuiltinAdapter writes to the `Body` * handle; MatterAdapter calls Matter's `Body.set*` helpers). */ - setStatic( - renderable: import("../renderable/renderable.js").default, - isStatic: boolean, - ): void; - setGravityScale( - renderable: import("../renderable/renderable.js").default, - scale: number, - ): void; + setStatic(renderable: Renderable, isStatic: boolean): void; + setGravityScale(renderable: Renderable, scale: number): void; setFrictionAir( - renderable: import("../renderable/renderable.js").default, + renderable: Renderable, friction: number | { x: number; y: number }, ): void; - setMaxVelocity( - renderable: import("../renderable/renderable.js").default, - limit: { x: number; y: number }, - ): void; + setMaxVelocity(renderable: Renderable, limit: { x: number; y: number }): void; /** * Read the body's current velocity cap (mirror of * {@link setMaxVelocity}). Returns plain `{x, y}` so callers don't * need to import a vector type. Optional — adapters that don't * implement velocity caps omit this method. */ - getMaxVelocity?(renderable: import("../renderable/renderable.js").default): { + getMaxVelocity?(renderable: Renderable): { x: number; y: number; }; - setCollisionType( - renderable: import("../renderable/renderable.js").default, - type: number, - ): void; - setCollisionMask( - renderable: import("../renderable/renderable.js").default, - mask: number, - ): void; + setCollisionType(renderable: Renderable, type: number): void; + setCollisionMask(renderable: Renderable, mask: number): void; /** * Toggle a body between solid and sensor mode. A sensor still fires * collision events (`onCollisionStart` / `onCollisionActive` / @@ -564,10 +514,7 @@ export interface PhysicsAdapter { * Adapters without a native sensor flag emulate by toggling the * collision mask between its previous value and `NO_OBJECT`. */ - setSensor?( - renderable: import("../renderable/renderable.js").default, - isSensor: boolean, - ): void; + setSensor?(renderable: Renderable, isSensor: boolean): void; /** * Whether the body has at least one active contact with a surface @@ -576,9 +523,7 @@ export interface PhysicsAdapter { * has no direct equivalent and the MatterAdapter implements it by * scanning active pairs each call. */ - isGrounded?( - renderable: import("../renderable/renderable.js").default, - ): boolean; + isGrounded?(renderable: Renderable): boolean; /** * Spatial queries. @@ -592,7 +537,7 @@ export interface PhysicsAdapter { * exposing it costs nothing). */ raycast?(from: Vector2d, to: Vector2d): RaycastHit | null; - queryAABB(rect: Rect): import("../renderable/renderable.js").default[]; + queryAABB(rect: Rect): Renderable[]; /** * Return the body's axis-aligned bounding box in **renderable-local** @@ -612,10 +557,7 @@ export interface PhysicsAdapter { * space representation tools rely on regardless of which engine * the adapter wraps. */ - getBodyAABB( - renderable: import("../renderable/renderable.js").default, - out: Bounds, - ): Bounds | undefined; + getBodyAABB(renderable: Renderable, out: Bounds): Bounds | undefined; /** * Return a snapshot of the body's collision shapes in @@ -629,7 +571,5 @@ export interface PhysicsAdapter { * the underlying physics-engine body objects. Required by the * adapter contract. */ - getBodyShapes( - renderable: import("../renderable/renderable.js").default, - ): readonly BodyShape[]; + getBodyShapes(renderable: Renderable): readonly BodyShape[]; } diff --git a/packages/melonjs/src/physics/bounds.ts b/packages/melonjs/src/physics/bounds.ts index f6966b923..5bb35fa84 100644 --- a/packages/melonjs/src/physics/bounds.ts +++ b/packages/melonjs/src/physics/bounds.ts @@ -1,5 +1,4 @@ import { Point } from "../geometries/point.ts"; -import { polygonPool } from "../geometries/polygon.ts"; import { Matrix2d } from "../math/matrix2d.ts"; import { Matrix3d } from "../math/matrix3d.ts"; import { Vector2d } from "../math/vector2d.ts"; @@ -476,19 +475,6 @@ export class Bounds { bounds.addBounds(this); return bounds; } - - /** - * Returns a polygon whose edges are the same as this bounds. - * @returns A new Polygon that represents this bounds. - */ - toPolygon() { - return polygonPool.get(this.x, this.y, [ - new Vector2d(0, 0), - new Vector2d(this.width, 0), - new Vector2d(this.width, this.height), - new Vector2d(0, this.height), - ]); - } } /** diff --git a/packages/melonjs/src/physics/builtin/builtin-adapter.js b/packages/melonjs/src/physics/builtin/builtin-adapter.js deleted file mode 100644 index 3dab358a1..000000000 --- a/packages/melonjs/src/physics/builtin/builtin-adapter.js +++ /dev/null @@ -1,556 +0,0 @@ -import { Vector2d } from "../../math/vector2d.ts"; -import state from "../../state/state.ts"; -import Body from "./body.js"; -import Detector from "./detector.js"; -import { raycastQuery } from "./raycast.js"; - -/** - * @import Renderable from "../../renderable/renderable.js"; - * @import World from "../world.js"; - * @import { AdapterCapabilities, AdapterOptions, BodyDefinition, BodyShape, PhysicsAdapter, RaycastHit } from "../adapter.ts"; - */ - -/** - * Default {@link PhysicsAdapter} that wraps melonJS's native SAT-based - * physics. Owns the active body set, the {@link Detector}, gravity, and - * the simulation step. Returns the legacy {@link Body} class as its body - * handle so existing property-based game code (`body.vel.x = 5`, - * `body.isStatic = true`) keeps working unchanged. - * - * Instantiated by default during `Application` construction; user code - * only touches this directly when explicitly wiring it via - * `new Application(w, h, { physic: { adapter: new BuiltinAdapter() } })`. - * - * @implements {PhysicsAdapter} - * @category Physics - */ -export default class BuiltinAdapter { - /** - * Short adapter identifier exposed as `world.physic`. Lets user code - * branch on the active physics implementation without importing the - * concrete adapter class. - * @type {string} - * @default "builtin" - */ - physicLabel = "builtin"; - - /** - * @param {AdapterOptions} [options] - */ - constructor(options = {}) { - /** - * Advertised capabilities; user code may branch on these. - * @type {AdapterCapabilities} - */ - this.capabilities = { - constraints: false, - continuousCollisionDetection: false, - sleepingBodies: false, - raycasts: true, - velocityLimit: true, - isGrounded: true, - }; - - /** - * World gravity. Mutate to change at runtime. - * @type {Vector2d} - * @default <0, 0.98> - */ - this.gravity = options.gravity ?? new Vector2d(0, 0.98); - - /** - * Active physics bodies in this simulation. - * @type {Set} - */ - this.bodies = new Set(); - - /** - * Collision detector instance, created in {@link init}. - * @type {Detector} - */ - this.detector = undefined; - - /** - * Back-reference to the owning world, set in {@link init}. - * @type {World} - * @private - */ - this.world = undefined; - } - - /** - * Called once after the adapter is attached to a {@link World}. - * @param {World} world - */ - init(world) { - this.world = world; - this.detector = new Detector(world); - } - - /** - * Release resources. - */ - destroy() { - this.bodies.clear(); - } - - /** - * Advance the simulation by one frame. - * @param {number} dt - time since the last frame, in ms - */ - step(dt) { - const isPaused = state.isPaused(); - // open the per-frame collision diff so the detector can synthesize - // start/end events on top of SAT's frame-by-frame overlap reports - this.detector.beginFrame(); - // iterate through all bodies - for (const body of this.bodies) { - const ancestor = body.ancestor; - if (!body.isStatic && ancestor) { - // if the game is not paused, and ancestor can be updated - if ( - !(isPaused && !ancestor.updateWhenPaused) && - (ancestor.inViewport || ancestor.alwaysUpdate) - ) { - this.applyGravity(body); - if (body.update(dt) === true) { - ancestor.isDirty = true; - } - this.detector.collisions(ancestor); - } - } - // Always clear the force accumulator each step — even for static - // bodies, out-of-viewport bodies, and paused bodies. Otherwise a - // stray applyForce call would leak indefinitely and fire as a - // surprise impulse when the body becomes simulatable again. - body.force.set(0, 0); - } - // fire onCollisionEnd for pairs that separated this step - this.detector.endFrame(); - } - - /** - * No-op: BuiltinAdapter mutates `renderable.pos` directly during - * {@link step} via `body.update(dt)`, so there is nothing to copy - * back after the step. - */ - syncFromPhysics() { - // intentionally empty — see method docs - } - - /** - * Register a body with the simulation. Returns the legacy `Body` - * class as the body handle; the field is also written to - * `renderable.body` so property-based code keeps working. - * - * Two entry points converge here: - * 1. Explicit (preferred): `world.adapter.addBody(this, { type: "dynamic", shapes: [...] })` - * 2. Legacy migration: `this.body = new Body(this, shapes)` already - * created a body but it wasn't registered with the adapter yet; - * we register the existing instance and map any supplied def - * fields onto it. - * - * Pick ONE registration path per body. Calling `addBody` twice on - * the same renderable after it's already adapter-managed throws - * — the second call is a programming error. - * - * @param {Renderable} renderable - * @param {BodyDefinition} def - * @returns {Body} - * @throws {Error} if the renderable is already adapter-managed - */ - addBody(renderable, def) { - let body = renderable.body; - // Legacy bridge: an existing Body that the adapter doesn't - // know about yet — register it. The same path catches direct - // `new Body(...)` constructions that the user wants to migrate - // to the adapter API without losing the existing instance. - const isAlreadyAdapterManaged = - body instanceof Body && this.bodies.has(body); - if (isAlreadyAdapterManaged) { - throw new Error( - "BuiltinAdapter.addBody: renderable is already adapter-managed. " + - "Use adapter.updateShape() / property mutation / adapter.removeBody() " + - "first if you need to change the body.", - ); - } - if (!(body instanceof Body)) { - body = new Body(renderable, def.shapes); - renderable.body = body; - } else if (Array.isArray(def.shapes) && def.shapes.length > 0) { - // legacy-bridge path: replace existing shapes if the def - // provides them (otherwise keep what the user already built) - body.shapes.length = 0; - body.getBounds().clear(); - for (const s of def.shapes) { - body.addShape(s); - } - } - // map portable BodyDefinition fields onto Body properties - body.setStatic(def.type === "static"); - if (typeof def.collisionType === "number") { - body.collisionType = def.collisionType; - } - if (typeof def.collisionMask === "number") { - body.collisionMask = def.collisionMask; - } - if (def.frictionAir !== undefined) { - if (typeof def.frictionAir === "number") { - body.setFriction(def.frictionAir, def.frictionAir); - } else { - body.setFriction(def.frictionAir.x, def.frictionAir.y); - } - } - if (typeof def.restitution === "number") { - body.bounce = def.restitution; - } - if (typeof def.density === "number") { - body.mass = def.density; - } - if (typeof def.gravityScale === "number") { - body.gravityScale = def.gravityScale; - } - if (def.maxVelocity !== undefined) { - body.setMaxVelocity(def.maxVelocity.x, def.maxVelocity.y); - } - if (def.isSensor === true) { - body.isSensor = true; - } - this.bodies.add(body); - return body; - } - - /** - * Unregister a body. Called when the renderable leaves the world. - * @param {Renderable} renderable - */ - removeBody(renderable) { - const body = renderable.body; - if (body instanceof Body) { - this.bodies.delete(body); - } - } - - /** - * Replace the body's collision geometry without re-creating the body. - * @param {Renderable} renderable - * @param {BodyShape[]} shapes - */ - updateShape(renderable, shapes) { - const body = renderable.body; - if (!(body instanceof Body)) { - return; - } - // clear existing shapes, then add new ones - body.shapes.length = 0; - body.getBounds().clear(); - for (const s of shapes) { - body.addShape(s); - } - } - - /** - * @param {Renderable} renderable - * @param {Vector2d} [out] - * @returns {Vector2d} - */ - getVelocity(renderable, out) { - const body = renderable.body; - const target = out ?? new Vector2d(); - // after removeBody the body reference may still dangle on the - // renderable — return zero so the behaviour matches adapters that - // fully forget the body (e.g. MatterAdapter clears its bodyMap). - if (!body || !this.bodies.has(body)) { - return target.set(0, 0); - } - return target.set(body.vel.x, body.vel.y); - } - - /** - * @param {Renderable} renderable - * @param {Vector2d} v - */ - setVelocity(renderable, v) { - renderable.body.vel.setV(v); - } - - /** - * @param {Renderable} renderable - * @param {Vector2d} force - * @param {Vector2d} [point] - world point at which the force is applied; - * when provided and different from the body's centroid, the resulting - * lever arm generates a torque that contributes to - * {@link Body#angularVelocity} via {@link Body#applyForce}'s - * 4-argument form. Omit for pure linear force (legacy behavior). - */ - applyForce(renderable, force, point) { - // Delegate to Body so the lever-arm → torque computation lives in - // one place. The 2-arg call (no point) preserves the pre-angular - // behavior exactly: just accumulate into the linear force vector. - if (point) { - renderable.body.applyForce(force.x, force.y, point.x, point.y); - } else { - renderable.body.applyForce(force.x, force.y); - } - } - - /** - * @param {Renderable} renderable - * @param {Vector2d} impulse - */ - applyImpulse(renderable, impulse) { - // impulse = mass * dv → dv = impulse / mass - const body = renderable.body; - const invMass = body.mass > 0 ? 1 / body.mass : 0; - body.vel.x += impulse.x * invMass; - body.vel.y += impulse.y * invMass; - } - - /** - * @param {Renderable} renderable - * @param {Vector2d} p - */ - setPosition(renderable, p) { - // `pos` is an ObservableVector3d on Renderable; preserve the - // existing Z (`pos.z` is the depth field, set independently). - renderable.pos.x = p.x; - renderable.pos.y = p.y; - } - - /** - * Set absolute body rotation angle (radians). Under BuiltinAdapter - * the rotation is **visual only** — SAT collisions remain - * axis-aligned against the original shapes — but the renderable's - * `currentTransform` is updated so the sprite rotates correctly. - * @param {Renderable} renderable - * @param {number} angle - */ - setAngle(renderable, angle) { - renderable.body.setAngle(angle); - } - - /** - * Read body rotation angle (radians). - * @param {Renderable} renderable - * @returns {number} - */ - getAngle(renderable) { - return renderable.body.angle; - } - - /** - * Set body angular velocity (rad / frame). The next call to - * {@link Body#update} integrates it into the body's angle and - * re-syncs the renderable's transform. - * @param {Renderable} renderable - * @param {number} omega - */ - setAngularVelocity(renderable, omega) { - renderable.body.setAngularVelocity(omega); - } - - /** - * Read body angular velocity (rad / frame). - * @param {Renderable} renderable - * @returns {number} - */ - getAngularVelocity(renderable) { - return renderable.body.angularVelocity; - } - - /** - * Apply an angular impulse to the body: `Δω = τ / pseudoInertia`. - * @param {Renderable} renderable - * @param {number} torque - */ - applyTorque(renderable, torque) { - renderable.body.applyTorque(torque); - } - - /** - * @param {Renderable} renderable - * @param {boolean} isStatic - */ - setStatic(renderable, isStatic) { - renderable.body.setStatic(isStatic); - } - - /** - * @param {Renderable} renderable - * @param {number} scale - */ - setGravityScale(renderable, scale) { - renderable.body.gravityScale = scale; - } - - /** - * @param {Renderable} renderable - * @param {number | {x: number, y: number}} friction - */ - setFrictionAir(renderable, friction) { - const body = renderable.body; - if (typeof friction === "number") { - body.setFriction(friction, friction); - } else { - body.setFriction(friction.x, friction.y); - } - } - - /** - * @param {Renderable} renderable - * @param {{x: number, y: number}} limit - */ - setMaxVelocity(renderable, limit) { - renderable.body.setMaxVelocity(limit.x, limit.y); - } - - /** - * @param {Renderable} renderable - * @returns {{x: number, y: number}} - */ - getMaxVelocity(renderable) { - const v = renderable.body.maxVel; - return { x: v.x, y: v.y }; - } - - /** - * @param {Renderable} renderable - * @param {number} type - */ - setCollisionType(renderable, type) { - renderable.body.collisionType = type; - } - - /** - * @param {Renderable} renderable - * @param {number} mask - */ - setCollisionMask(renderable, mask) { - renderable.body.setCollisionMask(mask); - } - - /** - * Toggle a body between solid and sensor mode. A sensor body still - * fires collision events (`onCollisionStart` / `onCollisionActive` / - * `onCollisionEnd`) — the SAT detector skips only the positional - * push-out (`respondToCollision`). Same semantics as Matter's - * `isSensor`, so adapter-agnostic trigger / one-way-platform code - * behaves identically on both adapters. - * @param {Renderable} renderable - * @param {boolean} isSensor - */ - setSensor(renderable, isSensor) { - renderable.body.isSensor = isSensor; - } - - /** - * Adapter-side debug surface: the body's AABB in renderable-local - * coordinates. Builtin Body already stores its bounds in local space, - * so we just copy into the caller's `out`. - * @param {Renderable} renderable - * @param {import("../bounds.ts").Bounds} out - * @returns {import("../bounds.ts").Bounds | undefined} - */ - getBodyAABB(renderable, out) { - const body = renderable.body; - if (body === undefined) { - return undefined; - } - const b = body.bounds; - out.setMinMax(b.min.x, b.min.y, b.max.x, b.max.y); - return out; - } - - /** - * Adapter-side debug surface: the body's collision shapes in - * renderable-local coordinates. Returned array is the body's live - * `shapes` list — read-only, callers must not mutate. - * @param {Renderable} renderable - * @returns {ReadonlyArray} - */ - getBodyShapes(renderable) { - return renderable.body?.shapes ?? []; - } - - /** - * Whether the body has at least one active downward-facing contact. - * BuiltinAdapter derives this from the SAT body's `falling` flag, - * which is updated by the detector each frame: a body is grounded - * when it's not currently moving downward off a surface. - * @param {Renderable} renderable - * @returns {boolean} - */ - isGrounded(renderable) { - const body = renderable.body; - return !body.falling && !body.jumping; - } - - /** - * Cast a ray from `from` to `to` and return the nearest body hit, or - * `null` if the ray reaches `to` without hitting anything. Goes - * through the same SAT-based broadphase walk as the legacy - * {@link Detector#rayCast} (both share `raycastQuery` in - * `./raycast.js`), but returns the portable `RaycastHit` shape - * (`{ renderable, point, normal, fraction }`) for parity with the - * matter / planck adapters. `point` is the precise parametric entry - * point on the shape surface (line-segment vs polygon edges, - * quadratic ray vs ellipse), `normal` is the outward-facing surface - * normal at that entry, and `fraction` is `0..1` along the ray. - * @param {Vector2d} from - * @param {Vector2d} to - * @returns {RaycastHit | null} - */ - raycast(from, to) { - const hits = raycastQuery(this.world, from.x, from.y, to.x, to.y); - return hits[0] ?? null; - } - - /** - * Return every renderable whose bounding box overlaps the given - * world-space rectangle. Walks the SAT broadphase quadtree to get a - * candidate set then filters by actual AABB overlap, so output is the - * precise intersection — not just same-partition neighbours. Useful - * for AoE damage queries, mouse picking, trigger-zone sweeps. Portable - * across `BuiltinAdapter`, `MatterAdapter`, and `PlanckAdapter`. - * @param {import("../../geometries/rectangle.ts").Rect} rect - * @returns {Renderable[]} - */ - queryAABB(rect) { - const queryBounds = rect.getBounds(); - // Pass our own array to `retrieve` so we never touch the - // broadphase's shared scratch — user code may call this from - // inside an `onCollisionStart` handler firing mid-iteration of - // the SAT detector's scratch walk; sharing would clobber the - // outer iteration. Then filter in place so the call costs a - // single allocation, not two. - const result = []; - this.world.broadphase.retrieve(rect, undefined, result); - let writeIdx = 0; - for (let i = 0, len = result.length; i < len; i++) { - const r = result[i]; - const b = r.getBounds?.(); - if (b && b.overlaps(queryBounds)) { - result[writeIdx++] = r; - } - } - result.length = writeIdx; - return result; - } - - /** - * Apply gravity to the given body. Backward-compat shim for the - * legacy `World.bodyApplyGravity` method. - * @param {Body} body - */ - applyGravity(body) { - // `ignoreGravity` is deprecated in favor of `gravityScale = 0` - // (see Body#ignoreGravity JSDoc). Keep both checks until the - // legacy field is removed — `gravityScale = 0` is the portable - // path that also works under matter; `ignoreGravity = true` is - // the legacy path that only this adapter reads. - if (body.gravityScale !== 0 && !body.ignoreGravity) { - body.force.x += body.mass * this.gravity.x * body.gravityScale; - body.force.y += body.mass * this.gravity.y * body.gravityScale; - } - } -} diff --git a/packages/melonjs/src/physics/builtin/builtin-adapter.ts b/packages/melonjs/src/physics/builtin/builtin-adapter.ts new file mode 100644 index 000000000..520b5f3b2 --- /dev/null +++ b/packages/melonjs/src/physics/builtin/builtin-adapter.ts @@ -0,0 +1,427 @@ +import type { Rect } from "../../geometries/rectangle.ts"; +import { Vector2d } from "../../math/vector2d.ts"; +import type Renderable from "../../renderable/renderable.js"; +import state from "../../state/state.ts"; +import type { + AdapterCapabilities, + AdapterOptions, + BodyDefinition, + BodyShape, + PhysicsAdapter, + RaycastHit, +} from "../adapter.ts"; +import type { Bounds } from "../bounds.ts"; +import type World from "../world.js"; +import Body from "./body.js"; +import Detector from "./detector.js"; +import { raycastQuery } from "./raycast.ts"; + +/** + * Default {@link PhysicsAdapter} that wraps melonJS's native SAT-based + * physics. Owns the active body set, the {@link Detector}, gravity, and + * the simulation step. Returns the legacy {@link Body} class as its body + * handle so existing property-based game code (`body.vel.x = 5`, + * `body.isStatic = true`) keeps working unchanged. + * + * Instantiated by default during `Application` construction; user code + * only touches this directly when explicitly wiring it via + * `new Application(w, h, { physic: { adapter: new BuiltinAdapter() } })`. + * @category Physics + */ +export default class BuiltinAdapter implements PhysicsAdapter { + /** + * Short adapter identifier exposed as `world.physic`. Lets user code + * branch on the active physics implementation without importing the + * concrete adapter class. + */ + readonly physicLabel = "builtin"; + + /** + * Advertised capabilities; user code may branch on these. + */ + readonly capabilities: AdapterCapabilities = { + constraints: false, + continuousCollisionDetection: false, + sleepingBodies: false, + raycasts: true, + velocityLimit: true, + isGrounded: true, + }; + + /** + * World gravity. Mutate to change at runtime. + * @default <0, 0.98> + */ + gravity: Vector2d; + + /** + * Active physics bodies in this simulation. + */ + readonly bodies: Set = new Set(); + + /** + * Collision detector instance, created in {@link init}. + */ + detector!: Detector; + + /** + * Back-reference to the owning world, set in {@link init}. + */ + private world!: World; + + constructor(options: AdapterOptions = {}) { + this.gravity = options.gravity ?? new Vector2d(0, 0.98); + } + + init(world: World): void { + this.world = world; + this.detector = new Detector(world); + } + + destroy(): void { + this.bodies.clear(); + } + + step(dt: number): void { + const isPaused = state.isPaused(); + // open the per-frame collision diff so the detector can synthesize + // start/end events on top of SAT's frame-by-frame overlap reports + this.detector.beginFrame(); + // iterate through all bodies + for (const body of this.bodies) { + const ancestor = body.ancestor; + if (!body.isStatic && ancestor) { + // if the game is not paused, and ancestor can be updated + if ( + !(isPaused && !ancestor.updateWhenPaused) && + (ancestor.inViewport || ancestor.alwaysUpdate) + ) { + this.applyGravity(body); + // `Body.update` is documented `@protected` in body.js + // JSDoc, but the adapter is its sole legitimate caller + // (the adapter step IS what advances the body). The + // JSDoc also documents a `dt` param the runtime ignores + // (it uses `timer.tick` instead); we pass it through + // anyway for future compatibility / legibility. + // @ts-expect-error -- @protected method called from the adapter that owns the body lifecycle + if (body.update(dt)) { + ancestor.isDirty = true; + } + this.detector.collisions(ancestor); + } + } + // Always clear the force accumulator each step — even for static + // bodies, out-of-viewport bodies, and paused bodies. Otherwise a + // stray applyForce call would leak indefinitely and fire as a + // surprise impulse when the body becomes simulatable again. + body.force.set(0, 0); + } + // fire onCollisionEnd for pairs that separated this step + this.detector.endFrame(); + } + + syncFromPhysics(): void { + // No-op: BuiltinAdapter mutates `renderable.pos` directly during + // `step` via `body.update(dt)`, so there is nothing to copy back + // after the step. + } + + addBody(renderable: Renderable, def: BodyDefinition): Body { + // Returns the legacy `Body` class as the body handle; the field + // is also written to `renderable.body` so property-based code + // keeps working. + // + // Two entry points converge here: + // 1. Explicit (preferred): `world.adapter.addBody(this, { type: "dynamic", shapes: [...] })` + // 2. Legacy migration: `this.body = new Body(this, shapes)` already + // created a body but it wasn't registered with the adapter yet; + // we register the existing instance and map any supplied def + // fields onto it. + // + // Pick ONE registration path per body. Calling `addBody` twice on + // the same renderable after it's already adapter-managed throws — + // the second call is a programming error. + let body = renderable.body as Body | undefined; + const isAlreadyAdapterManaged = + body instanceof Body && this.bodies.has(body); + if (isAlreadyAdapterManaged) { + throw new Error( + "BuiltinAdapter.addBody: renderable is already adapter-managed. " + + "Use adapter.updateShape() / property mutation / adapter.removeBody() " + + "first if you need to change the body.", + ); + } + if (!(body instanceof Body)) { + body = new Body(renderable, def.shapes); + renderable.body = body; + } else if (def.shapes.length > 0) { + // legacy-bridge path: replace existing shapes if the def + // provides them (otherwise keep what the user already built). + // `body.shapes` is typed in body.js as a union that includes a + // scalar `Point` variant — never used at runtime, but TS sees + // it so `.length` doesn't narrow. Cast to the array form. + (body.shapes as BodyShape[]).length = 0; + body.getBounds().clear(); + for (const s of def.shapes) { + body.addShape(s); + } + } + // map portable BodyDefinition fields onto Body properties + body.setStatic(def.type === "static"); + if (typeof def.collisionType === "number") { + body.collisionType = def.collisionType; + } + if (typeof def.collisionMask === "number") { + body.collisionMask = def.collisionMask; + } + if (def.frictionAir !== undefined) { + if (typeof def.frictionAir === "number") { + body.setFriction(def.frictionAir, def.frictionAir); + } else { + body.setFriction(def.frictionAir.x, def.frictionAir.y); + } + } + if (typeof def.restitution === "number") { + body.bounce = def.restitution; + } + if (typeof def.density === "number") { + body.mass = def.density; + } + if (typeof def.gravityScale === "number") { + body.gravityScale = def.gravityScale; + } + if (def.maxVelocity !== undefined) { + body.setMaxVelocity(def.maxVelocity.x, def.maxVelocity.y); + } + if (def.isSensor === true) { + body.isSensor = true; + } + this.bodies.add(body); + return body; + } + + removeBody(renderable: Renderable): void { + const body = renderable.body; + if (body instanceof Body) { + this.bodies.delete(body); + } + } + + updateShape(renderable: Renderable, shapes: BodyShape[]): void { + const body = renderable.body; + if (!(body instanceof Body)) { + return; + } + // clear existing shapes, then add new ones. `body.shapes` is + // typed too widely (see `addBody`); cast to the array form. + (body.shapes as BodyShape[]).length = 0; + body.getBounds().clear(); + for (const s of shapes) { + body.addShape(s); + } + } + + getVelocity(renderable: Renderable, out?: Vector2d): Vector2d { + const body = renderable.body as Body | undefined; + const target = out ?? new Vector2d(); + // after removeBody the body reference may still dangle on the + // renderable — return zero so the behaviour matches adapters that + // fully forget the body (e.g. MatterAdapter clears its bodyMap). + if (!body || !this.bodies.has(body)) { + return target.set(0, 0); + } + return target.set(body.vel.x, body.vel.y); + } + + setVelocity(renderable: Renderable, v: Vector2d): void { + (renderable.body as Body).vel.setV(v); + } + + applyForce(renderable: Renderable, force: Vector2d, point?: Vector2d): void { + // When `point` is provided and differs from the body's centroid, + // `Body.applyForce`'s 4-arg form generates a torque from the lever + // arm (`τ = r × F`). The 2-arg call (no point) preserves the + // pre-angular behavior exactly: just accumulate into the linear + // force vector. + const body = renderable.body as Body; + if (point) { + body.applyForce(force.x, force.y, point.x, point.y); + } else { + body.applyForce(force.x, force.y); + } + } + + applyImpulse(renderable: Renderable, impulse: Vector2d): void { + // impulse = mass * dv → dv = impulse / mass + const body = renderable.body as Body; + const invMass = body.mass > 0 ? 1 / body.mass : 0; + body.vel.x += impulse.x * invMass; + body.vel.y += impulse.y * invMass; + } + + setPosition(renderable: Renderable, p: Vector2d): void { + // `pos` is an ObservableVector3d on Renderable; preserve the + // existing Z (`pos.z` is the depth field, set independently). + renderable.pos.x = p.x; + renderable.pos.y = p.y; + } + + setAngle(renderable: Renderable, angle: number): void { + // Under BuiltinAdapter the rotation is *visual only* — SAT + // collisions remain axis-aligned against the original shapes — + // but the renderable's `currentTransform` is updated so the + // sprite rotates correctly. + (renderable.body as Body).setAngle(angle); + } + + getAngle(renderable: Renderable): number { + return (renderable.body as Body).angle; + } + + setAngularVelocity(renderable: Renderable, omega: number): void { + (renderable.body as Body).setAngularVelocity(omega); + } + + getAngularVelocity(renderable: Renderable): number { + return (renderable.body as Body).angularVelocity; + } + + applyTorque(renderable: Renderable, torque: number): void { + (renderable.body as Body).applyTorque(torque); + } + + setStatic(renderable: Renderable, isStatic: boolean): void { + (renderable.body as Body).setStatic(isStatic); + } + + setGravityScale(renderable: Renderable, scale: number): void { + (renderable.body as Body).gravityScale = scale; + } + + setFrictionAir( + renderable: Renderable, + friction: number | { x: number; y: number }, + ): void { + const body = renderable.body as Body; + if (typeof friction === "number") { + body.setFriction(friction, friction); + } else { + body.setFriction(friction.x, friction.y); + } + } + + setMaxVelocity( + renderable: Renderable, + limit: { x: number; y: number }, + ): void { + (renderable.body as Body).setMaxVelocity(limit.x, limit.y); + } + + getMaxVelocity(renderable: Renderable): { x: number; y: number } { + const v = (renderable.body as Body).maxVel; + return { x: v.x, y: v.y }; + } + + setCollisionType(renderable: Renderable, type: number): void { + (renderable.body as Body).collisionType = type; + } + + setCollisionMask(renderable: Renderable, mask: number): void { + (renderable.body as Body).setCollisionMask(mask); + } + + setSensor(renderable: Renderable, isSensor: boolean): void { + // A sensor body still fires collision events (`onCollisionStart` / + // `onCollisionActive` / `onCollisionEnd`) — the SAT detector skips + // only the positional push-out (`respondToCollision`). Same + // semantics as Matter's `isSensor`, so adapter-agnostic trigger / + // one-way-platform code behaves identically on both adapters. + (renderable.body as Body).isSensor = isSensor; + } + + getBodyAABB(renderable: Renderable, out: Bounds): Bounds | undefined { + // Adapter-side debug surface: the body's AABB in renderable-local + // coordinates. Builtin Body already stores its bounds in local + // space, so we just copy into the caller's `out`. + const body = renderable.body as Body | undefined; + if (body === undefined) { + return undefined; + } + const b = body.bounds; + out.setMinMax(b.min.x, b.min.y, b.max.x, b.max.y); + return out; + } + + getBodyShapes(renderable: Renderable): readonly BodyShape[] { + // Adapter-side debug surface: live `shapes` list in renderable- + // local coordinates. Read-only — callers must not mutate. + // `body.shapes` is typed in body.js as a union that includes a + // scalar `Point` variant (legacy, never produced at runtime). + return ( + ((renderable.body as Body | undefined)?.shapes as + | BodyShape[] + | undefined) ?? [] + ); + } + + isGrounded(renderable: Renderable): boolean { + // Derived from the SAT body's `falling` flag, updated by the + // detector each frame: a body is grounded when it's not currently + // moving downward off a surface. + const body = renderable.body as Body; + return !body.falling && !body.jumping; + } + + raycast(from: Vector2d, to: Vector2d): RaycastHit | null { + // Goes through the same SAT-based broadphase walk as the legacy + // `Detector.rayCast` (both share `raycastQuery` in `./raycast.js`), + // and returns the portable `RaycastHit` shape (`renderable`, + // `point`, `normal`, `fraction`) for parity with the matter / + // planck adapters. `point` is the precise parametric entry point + // on the shape surface (line-segment vs polygon edges, quadratic + // ray vs ellipse); `normal` is the outward-facing surface normal + // at that entry; `fraction` is `0..1` along the ray. + const hits = raycastQuery(this.world, from.x, from.y, to.x, to.y); + return hits[0] ?? null; + } + + queryAABB(rect: Rect): Renderable[] { + // Walks the SAT broadphase quadtree to get a candidate set then + // filters by actual AABB overlap, so output is the precise + // intersection — not just same-partition neighbours. Useful for + // AoE damage queries, mouse picking, trigger-zone sweeps. + const queryBounds = rect.getBounds(); + // Pass our own array to `retrieve` so we never touch the + // broadphase's shared scratch — user code may call this from + // inside an `onCollisionStart` handler firing mid-iteration of + // the SAT detector's scratch walk; sharing would clobber the + // outer iteration. Then filter in place so the call costs a + // single allocation, not two. + const result: Renderable[] = []; + this.world.broadphase.retrieve(rect, undefined, result); + let writeIdx = 0; + for (let i = 0, len = result.length; i < len; i++) { + const r = result[i]; + const b = r.getBounds(); + if (b.overlaps(queryBounds)) { + result[writeIdx++] = r; + } + } + result.length = writeIdx; + return result; + } + + applyGravity(body: Body): void { + // Backward-compat shim for the legacy `World.bodyApplyGravity`. + // `ignoreGravity` is deprecated in favor of `gravityScale = 0` + // (see Body#ignoreGravity JSDoc). Keep both checks until the + // legacy field is removed — `gravityScale = 0` is the portable + // path that also works under matter; `ignoreGravity = true` is + // the legacy path that only this adapter reads. + // eslint-disable-next-line @typescript-eslint/no-deprecated -- legacy field is read intentionally for backward compatibility + if (body.gravityScale !== 0 && !body.ignoreGravity) { + body.force.x += body.mass * this.gravity.x * body.gravityScale; + body.force.y += body.mass * this.gravity.y * body.gravityScale; + } + } +} diff --git a/packages/melonjs/src/physics/builtin/detector.js b/packages/melonjs/src/physics/builtin/detector.js index c77a2ff8e..835f0b68f 100644 --- a/packages/melonjs/src/physics/builtin/detector.js +++ b/packages/melonjs/src/physics/builtin/detector.js @@ -1,6 +1,6 @@ import { Bounds } from "../bounds.ts"; import ResponseObject from "../response.js"; -import { raycastQuery } from "./raycast.js"; +import { raycastQuery } from "./raycast.ts"; import { testEllipseEllipse, testEllipsePolygon, diff --git a/packages/melonjs/src/physics/builtin/raycast.js b/packages/melonjs/src/physics/builtin/raycast.ts similarity index 68% rename from packages/melonjs/src/physics/builtin/raycast.js rename to packages/melonjs/src/physics/builtin/raycast.ts index 1aae3f474..5397302c2 100644 --- a/packages/melonjs/src/physics/builtin/raycast.js +++ b/packages/melonjs/src/physics/builtin/raycast.ts @@ -1,9 +1,10 @@ +import type { Ellipse } from "../../geometries/ellipse.ts"; import { linePool } from "../../geometries/line.ts"; +import type { Polygon } from "../../geometries/polygon.ts"; import { Vector2d } from "../../math/vector2d.ts"; - -/** - * @import World from "../world.js"; - */ +import type Renderable from "../../renderable/renderable.js"; +import type { RaycastHit } from "../adapter.ts"; +import type World from "../world.js"; /** * Shared internal raycast walker for the built-in physics path. Used by @@ -24,25 +25,30 @@ import { Vector2d } from "../../math/vector2d.ts"; * touch first"). */ -/** - * 2D cross product of (a → b) and (a → c): positive if c is to the - * left of a → b, negative if right, zero if colinear. Reused by the - * segment-vs-segment intersection. - */ -function _cross(ax, ay, bx, by) { +interface ShapeHit { + t: number; + normalX: number; + normalY: number; +} + +function _cross(ax: number, ay: number, bx: number, by: number): number { + // 2D cross product of (a → b) and (a → c): positive if c is to the + // left of a → b, negative if right, zero if colinear. Reused by the + // segment-vs-segment intersection. return ax * by - ay * bx; } -/** - * Compute the absolute world position of a shape attached to a body - * attached to a renderable. Mirrors what SAT does: - * absPos = renderable.pos + ancestor.absPos + shape.pos - * @param {object} renderable - * @param {object} shape - * @param {Vector2d} outPos - */ -function _computeShapeAbsPos(renderable, shape, outPos) { - const ancestor = renderable.ancestor; +function _computeShapeAbsPos( + renderable: Renderable, + shape: Polygon | Ellipse, + outPos: Vector2d, +): void { + // Compute the absolute world position of a shape attached to a body + // attached to a renderable. Mirrors what SAT does: + // absPos = renderable.pos + ancestor.absPos + shape.pos + const ancestor = renderable.ancestor as + | { getAbsolutePosition?(): { x: number; y: number } } + | undefined; const ancestorAbs = ancestor && typeof ancestor.getAbsolutePosition === "function" ? ancestor.getAbsolutePosition() @@ -55,26 +61,23 @@ function _computeShapeAbsPos(renderable, shape, outPos) { const _shapeAbs = new Vector2d(0, 0); -/** - * Find the smallest ray parameter `t` (in `[0, 1]`) where the ray - * `from` → `to` enters the polygon, along with the outward normal of - * the hit edge. Returns `null` if the ray misses every edge. - * - * Iterates polygon edges as line segments and solves the - * line-segment vs line-segment intersection: - * ray: from + t * (to - from), t ∈ [0, 1] - * edge: A + s * (B - A), s ∈ [0, 1] - * - * @param {number} fromX - * @param {number} fromY - * @param {number} dx - to.x - from.x - * @param {number} dy - to.y - from.y - * @param {object} shape - a Polygon (or Rect / RoundRect that extends it) - * @param {number} shapeAbsX - * @param {number} shapeAbsY - * @returns {{ t: number, normalX: number, normalY: number } | null} - */ -function _raycastPolygon(fromX, fromY, dx, dy, shape, shapeAbsX, shapeAbsY) { +function _raycastPolygon( + fromX: number, + fromY: number, + dx: number, + dy: number, + shape: Polygon, + shapeAbsX: number, + shapeAbsY: number, +): ShapeHit | null { + // Find the smallest ray parameter `t` (in `[0, 1]`) where the ray + // `from` → `to` enters the polygon, along with the outward normal of + // the hit edge. Returns `null` if the ray misses every edge. + // + // Iterates polygon edges as line segments and solves the line- + // segment vs line-segment intersection: + // ray: from + t * (to - from), t ∈ [0, 1] + // edge: A + s * (B - A), s ∈ [0, 1] const points = shape.points; const normals = shape.normals; const len = points.length; @@ -111,26 +114,24 @@ function _raycastPolygon(fromX, fromY, dx, dy, shape, shapeAbsX, shapeAbsY) { return { t: bestT, normalX: normal.x, normalY: normal.y }; } -/** - * Find the smallest ray parameter `t` where the ray enters the - * ellipse, plus the surface normal at that point. Solves the standard - * quadratic for `((px - cx) / rx)² + ((py - cy) / ry)² = 1`: - * A·t² + B·t + C = 0 - * where: - * A = (dx/rx)² + (dy/ry)² - * B = 2 · ((dx · (fx-cx))/rx² + (dy · (fy-cy))/ry²) - * C = ((fx-cx)/rx)² + ((fy-cy)/ry)² - 1 - * - * @param {number} fromX - * @param {number} fromY - * @param {number} dx - * @param {number} dy - * @param {object} shape - an Ellipse (pos = local centre, radiusV = {x, y}) - * @param {number} shapeAbsX - * @param {number} shapeAbsY - * @returns {{ t: number, normalX: number, normalY: number } | null} - */ -function _raycastEllipse(fromX, fromY, dx, dy, shape, shapeAbsX, shapeAbsY) { +function _raycastEllipse( + fromX: number, + fromY: number, + dx: number, + dy: number, + shape: Ellipse, + shapeAbsX: number, + shapeAbsY: number, +): ShapeHit | null { + // Find the smallest ray parameter `t` where the ray enters the + // ellipse, plus the surface normal at that point. Solves the + // standard quadratic for + // `((px - cx) / rx)² + ((py - cy) / ry)² = 1`: + // A·t² + B·t + C = 0 + // where: + // A = (dx/rx)² + (dy/ry)² + // B = 2 · ((dx · (fx-cx))/rx² + (dy · (fy-cy))/ry²) + // C = ((fx-cx)/rx)² + ((fy-cy)/ry)² - 1 const cx = shapeAbsX; const cy = shapeAbsY; const rx = shape.radiusV.x; @@ -155,7 +156,7 @@ function _raycastEllipse(fromX, fromY, dx, dy, shape, shapeAbsX, shapeAbsY) { // Smaller positive root in [0, 1] is the entry; if ray origin is // inside the ellipse (`t0 < 0`), the entry is behind us so we use // `t1` (the exit point on this side). - let t; + let t: number; if (t0 >= 0 && t0 <= 1) { t = t0; } else if (t1 >= 0 && t1 <= 1) { @@ -177,17 +178,15 @@ function _raycastEllipse(fromX, fromY, dx, dy, shape, shapeAbsX, shapeAbsY) { return { t, normalX: nx, normalY: ny }; } -/** - * Walk the world's broadphase and return every body the ray - * `(fromX, fromY) → (toX, toY)` enters, sorted nearest-first. - * @param {World} world - * @param {number} fromX - * @param {number} fromY - * @param {number} toX - * @param {number} toY - * @returns {Array<{renderable: object, point: Vector2d, normal: Vector2d, fraction: number}>} - */ -export function raycastQuery(world, fromX, fromY, toX, toY) { +export function raycastQuery( + world: World, + fromX: number, + fromY: number, + toX: number, + toY: number, +): RaycastHit[] { + // Walk the world's broadphase and return every body the ray + // `(fromX, fromY) → (toX, toY)` enters, sorted nearest-first. const dx = toX - fromX; const dy = toY - fromY; const segLen = Math.hypot(dx, dy); @@ -209,20 +208,23 @@ export function raycastQuery(world, fromX, fromY, toX, toY) { // from inside an `onCollisionStart` handler that itself fires mid // iteration of the SAT detector's scratch walk; sharing would clobber // the outer iteration. - const candidates = []; + const candidates: Renderable[] = []; world.broadphase.retrieve(line, undefined, candidates); - const hits = []; + const hits: RaycastHit[] = []; for (let i = candidates.length - 1; i >= 0; i--) { const objB = candidates[i]; - if (!objB || !objB.body) { + if (!objB.body) { continue; } if (!line.getBounds().overlaps(objB.getBounds())) { continue; } - const bodyB = objB.body; + const bodyB = objB.body as unknown as { + shapes: ArrayLike; + getShape(i: number): Polygon | Ellipse; + }; const shapeCount = bodyB.shapes.length; if (shapeCount === 0) { continue; @@ -244,7 +246,7 @@ export function raycastQuery(world, fromX, fromY, toX, toY) { fromY, dx, dy, - shapeB, + shapeB as Ellipse, _shapeAbs.x, _shapeAbs.y, ) @@ -253,7 +255,7 @@ export function raycastQuery(world, fromX, fromY, toX, toY) { fromY, dx, dy, - shapeB, + shapeB as Polygon, _shapeAbs.x, _shapeAbs.y, ); diff --git a/packages/melonjs/src/physics/world.js b/packages/melonjs/src/physics/world.js index 23cb4e3b0..a4bf1e541 100644 --- a/packages/melonjs/src/physics/world.js +++ b/packages/melonjs/src/physics/world.js @@ -7,7 +7,7 @@ import { on, WORLD_STEP, } from "../system/event.ts"; -import BuiltinAdapter from "./builtin/builtin-adapter.js"; +import BuiltinAdapter from "./builtin/builtin-adapter.ts"; import QuadTree from "./builtin/quadtree.js"; import { collision } from "./collision.js"; diff --git a/packages/planck-adapter/src/index.ts b/packages/planck-adapter/src/index.ts index 756b450f6..1a8b60473 100644 --- a/packages/planck-adapter/src/index.ts +++ b/packages/planck-adapter/src/index.ts @@ -316,31 +316,16 @@ export class PlanckAdapter implements PhysicsAdapter { // centroid at addBody time), but `Renderable.preDraw` pivots // at `renderable.pos`. The difference is exactly `-posOffset`. const angle = body.getAngle(); - const t = ( - renderable as { - currentTransform?: { - identity?: () => unknown; - rotate?: (a: number) => unknown; - translate?: (x: number, y: number) => unknown; - }; - } - ).currentTransform; - if ( - t && - typeof t.identity === "function" && - typeof t.rotate === "function" && - typeof t.translate === "function" - ) { - const cx = off ? -off.x : 0; - const cy = off ? -off.y : 0; - t.identity(); - if (cx !== 0 || cy !== 0) { - t.translate(cx, cy); - t.rotate(angle); - t.translate(-cx, -cy); - } else { - t.rotate(angle); - } + const t = renderable.currentTransform; + const cx = off ? -off.x : 0; + const cy = off ? -off.y : 0; + t.identity(); + if (cx !== 0 || cy !== 0) { + t.translate(cx, cy); + t.rotate(angle); + t.translate(-cx, -cy); + } else { + t.rotate(angle); } } } @@ -1140,16 +1125,9 @@ export class PlanckAdapter implements PhysicsAdapter { // Box2D has no native ellipse — approximate as a circle with // the average radius. Future improvement: polygon hull for // tall/narrow ellipses. - const e = shape as unknown as { - pos: { x: number; y: number }; - radiusV?: { x: number; y: number }; - radius?: number; - }; - const rx = e.radiusV?.x ?? e.radius ?? 1; - const ry = e.radiusV?.y ?? e.radius ?? 1; - const radius = (rx + ry) / 2; - const cx = e.pos.x - centroid.x; - const cy = e.pos.y - centroid.y; + const radius = (shape.radiusV.x + shape.radiusV.y) / 2; + const cx = shape.pos.x - centroid.x; + const cy = shape.pos.y - centroid.y; return new planck.Circle( new planck.Vec2(this.px2m(cx), this.px2m(cy)), this.px2m(radius), diff --git a/packages/planck-adapter/tests/parity.spec.ts b/packages/planck-adapter/tests/parity.spec.ts index 154b620b2..5f266c68b 100644 --- a/packages/planck-adapter/tests/parity.spec.ts +++ b/packages/planck-adapter/tests/parity.spec.ts @@ -17,6 +17,7 @@ import { Bounds, BuiltinAdapter, boot, + Container, collision, Rect, Renderable, @@ -846,5 +847,280 @@ for (const { name, make, aabbPrecision, expectedCapabilities } of factories) { expect(dyn.pos.y).toBeGreaterThan(floorBottom); }); }); + + describe("raycast / queryAABB are geometric (not collision-filtered)", () => { + // Raycast and queryAABB are pure spatial queries — they return + // every body whose geometry intersects the query, regardless of + // `collisionType` / `collisionMask`. Box2D's RayCast and matter's + // Query.ray both follow this convention; the portable adapter + // surface inherits it. Pin that here so a future PR that adds + // "helpful" implicit mask filtering to one adapter is caught. + const placeWall = ( + x: number, + collisionType: number, + collisionMask: number, + ) => { + const wall = new Renderable(x, 200, 40, 40); + wall.alwaysUpdate = true; + wall.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 40, 40)], + collisionType, + collisionMask, + }; + world.addChild(wall); + return wall; + }; + + it("raycast returns the nearest hit regardless of collisionMask", () => { + const near = placeWall(100, collision.types.WORLD_SHAPE, 0); + placeWall(300, collision.types.WORLD_SHAPE, collision.types.ALL_OBJECT); + world.update(16); + const hit = adapter.raycast( + new Vector2d(0, 220), + new Vector2d(500, 220), + ); + expect(hit).not.toBeNull(); + expect(hit!.renderable).toBe(near); + }); + + it("queryAABB returns every overlapping body regardless of collisionMask", () => { + const a = placeWall(100, collision.types.WORLD_SHAPE, 0); + const b = placeWall( + 200, + collision.types.WORLD_SHAPE, + collision.types.ALL_OBJECT, + ); + world.update(16); + const hits = adapter.queryAABB(new Rect(50, 150, 300, 200)); + expect(hits).toContain(a); + expect(hits).toContain(b); + expect(hits.length).toEqual(2); + }); + }); + + describe("adversarial — common gameplay patterns", () => { + // Patterns every game hits sooner or later. Bugs here cascade + // silently in production, so each test pins an invariant that + // is easy to break by refactoring the contact / lifecycle path. + + it("deferred-removal pickup pattern — body is gone from queries the next frame", () => { + // The portable pickup idiom: onCollisionStart flags the coin + // for removal, the actual removeChildNow happens AFTER the + // world step. Matches the recommendation in BuiltinAdapter + // Quirks #6 ("defer destructive ops in collision callbacks"). + let pickedUp: Renderable | undefined; + const events: string[] = []; + class Coin extends Renderable { + onCollisionStart() { + events.push("pickup"); + pickedUp = this; + } + } + const player = new Renderable(100, 100, 32, 32); + player.alwaysUpdate = true; + player.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + gravityScale: 0, + }; + world.addChild(player); + const coin = new Coin(108, 100, 16, 16); + coin.alwaysUpdate = true; + coin.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 16, 16)], + isSensor: true, + }; + world.addChild(coin); + + world.update(16); + expect(pickedUp).toBeDefined(); + expect(events.length).toEqual(1); + + pickedUp!.ancestor.removeChildNow(pickedUp!); + world.update(16); + adapter.syncFromPhysics(); + + const hits = adapter.queryAABB(new Rect(100, 95, 30, 30)); + expect(hits).not.toContain(pickedUp); + expect(events.length).toEqual(1); + }); + + it("setPosition out of penetration — no stale-contact correction", () => { + // Regression mirror of the matter-adapter parity test: + // teleporting a body OUT of a penetrating overlap must + // leave it exactly at the teleport target on the next + // step, with no carryover Baumgarte correction from the + // stale contact pair. Planck inherits the correct behavior + // from Box2D's b2Body::SetTransform (which documents + // "contacts are updated on the next call to b2World::Step"). + const wall = new Renderable(200, 200, 40, 40); + wall.alwaysUpdate = true; + wall.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 40, 40)], + }; + world.addChild(wall); + + const mover = new Renderable(195, 200, 32, 32); + mover.alwaysUpdate = true; + mover.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + gravityScale: 0, + }; + world.addChild(mover); + world.update(16); + + adapter.setPosition(mover, new Vector2d(500, 100)); + adapter.setVelocity(mover, new Vector2d(0, 0)); + world.update(16); + adapter.syncFromPhysics(); + expect(Math.abs(mover.pos.x - 500)).toBeLessThan(1); + expect(Math.abs(mover.pos.y - 100)).toBeLessThan(1); + }); + + it("setPosition while in contact — teleport away from a resting body works", () => { + // Common cutscene / level-transition pattern: a body + // resting on a floor is teleported elsewhere. One step + // later it must be where we put it, not pulled back to + // the floor by a stale contact-resolution force. + const floor = new Renderable(0, 200, 800, 20); + floor.alwaysUpdate = true; + floor.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 800, 20)], + }; + world.addChild(floor); + + const ball = new Renderable(100, 150, 32, 32); + ball.alwaysUpdate = true; + ball.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + world.addChild(ball); + + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + + adapter.setPosition(ball, new Vector2d(400, 50)); + adapter.setVelocity(ball, new Vector2d(0, 0)); + world.update(16); + adapter.syncFromPhysics(); + expect(Math.abs(ball.pos.x - 400)).toBeLessThan(2); + expect(Math.abs(ball.pos.y - 50)).toBeLessThan(5); + }); + + it("setStatic mid-collision — frozen body stops, partner stops being pushed", () => { + const floor = new Renderable(0, 200, 800, 20); + floor.alwaysUpdate = true; + floor.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 800, 20)], + }; + world.addChild(floor); + + const dyn = new Renderable(100, 150, 32, 32); + dyn.alwaysUpdate = true; + dyn.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + world.addChild(dyn); + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + const restingY = dyn.pos.y; + + adapter.setStatic(dyn, true); + for (let i = 0; i < 30; i++) world.update(16); + adapter.syncFromPhysics(); + expect(Math.abs(dyn.pos.y - restingY)).toBeLessThan(1); + }); + + it("nested-container removeChildNow de-registers the body from the adapter", () => { + // Common level-management pattern: bodies live inside a + // "level" subcontainer that gets cleared on transition. + // Removing the child must drain the adapter's bookkeeping, + // otherwise stale bodies keep showing up in raycast / + // queryAABB after the level is gone. + const sub = new Container(0, 0, 800, 600); + world.addChild(sub); + + const r = new Renderable(100, 100, 32, 32); + r.alwaysUpdate = true; + r.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + sub.addChild(r); + world.update(16); + + const seenBefore = adapter.queryAABB(new Rect(90, 90, 50, 50)); + expect(seenBefore).toContain(r); + + sub.removeChildNow(r, true); + world.update(16); + const seenAfter = adapter.queryAABB(new Rect(90, 90, 50, 50)); + expect(seenAfter).not.toContain(r); + }); + + it("setSensor toggle mid-flight — one-way-platform pattern", () => { + // A solid floor becomes a sensor for one frame so a falling + // body passes through, then flips back to solid. Pin both + // transitions: sensor=true ⇒ body falls past; sensor=false + // ⇒ body lands on subsequent contact. + const floor = new Renderable(0, 200, 800, 20); + floor.alwaysUpdate = true; + floor.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 800, 20)], + }; + world.addChild(floor); + + const ball = new Renderable(100, 150, 32, 32); + ball.alwaysUpdate = true; + ball.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + world.addChild(ball); + + adapter.setSensor?.(floor, true); + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + expect(ball.pos.y).toBeGreaterThan(220); + + adapter.setSensor?.(floor, false); + adapter.setPosition(ball, new Vector2d(100, 150)); + adapter.setVelocity(ball, new Vector2d(0, 0)); + for (let i = 0; i < 60; i++) world.update(16); + adapter.syncFromPhysics(); + expect(ball.pos.y).toBeLessThan(201); + expect(ball.pos.y).toBeGreaterThan(140); + }); + }); + + describe("maxVelocity — actually clamps under sustained force", () => { + it("|vel.x| stays at or below the configured cap", () => { + const r = new Renderable(100, 100, 32, 32); + r.alwaysUpdate = true; + r.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + maxVelocity: { x: 5, y: 5 }, + gravityScale: 0, + }; + world.addChild(r); + + for (let i = 0; i < 30; i++) { + adapter.applyForce(r, new Vector2d(100, 0)); + world.update(16); + } + const vel = adapter.getVelocity(r); + expect(Math.abs(vel.x)).toBeLessThanOrEqual(5 + 0.1); + }); + }); }); } diff --git a/packages/planck-adapter/tests/planck-adapter.spec.ts b/packages/planck-adapter/tests/planck-adapter.spec.ts index 799a73f98..5f636516c 100644 --- a/packages/planck-adapter/tests/planck-adapter.spec.ts +++ b/packages/planck-adapter/tests/planck-adapter.spec.ts @@ -614,6 +614,55 @@ describe("PlanckAdapter — feature parity with BuiltinAdapter", () => { expect(shapes[0]).toEqual(rect); }); }); + + describe("maxVelocity", () => { + // The other two adapter specs (builtin + matter) have maxVelocity + // coverage; planck didn't until now. Pin both halves: that + // `bodyDef.maxVelocity` propagates into the internal velocity-cap + // state, and that the cap actually clamps under sustained force. + it("bodyDef.maxVelocity is recorded in the velocityLimits map", () => { + const r = new Renderable(0, 0, 32, 32); + adapter.addBody(r, { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + maxVelocity: { x: 4, y: 9 }, + }); + const cap = adapter.getMaxVelocity?.(r); + expect(cap).not.toBeUndefined(); + expect(cap!.x).toEqual(4); + expect(cap!.y).toEqual(9); + }); + + it("setMaxVelocity overrides the bodyDef value", () => { + const r = new Renderable(0, 0, 32, 32); + adapter.addBody(r, { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + maxVelocity: { x: 4, y: 9 }, + }); + adapter.setMaxVelocity(r, { x: 7, y: 12 }); + const cap = adapter.getMaxVelocity?.(r); + expect(cap!.x).toEqual(7); + expect(cap!.y).toEqual(12); + }); + + it("|vel.x| is clamped to maxVelocity under sustained force", () => { + const r = new Renderable(100, 100, 32, 32); + r.alwaysUpdate = true; + adapter.addBody(r, { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + maxVelocity: { x: 5, y: 5 }, + gravityScale: 0, + }); + for (let i = 0; i < 30; i++) { + adapter.applyForce(r, new Vector2d(100, 0)); + adapter.step(16); + } + const vel = adapter.getVelocity(r); + expect(Math.abs(vel.x)).toBeLessThanOrEqual(5 + 0.1); + }); + }); }); describe("PlanckAdapter — unit conversion", () => { From 3f8614cf188c3f1e67d5cf1ba0582368f90fe183 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 21 May 2026 12:20:44 +0800 Subject: [PATCH 04/11] test(planck-adapter): simplify body-count helper to use native getBodyCount() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review caught that the stress-spec helper had stale commentary ("Planck has no getBodyCount getter; walk the body list directly") — `world.getBodyCount()` is in planck's public API and is used elsewhere in this package's tests (`planck-adapter.spec.ts:98, 111, 120, 122`). Replace the manual `getBodyList()` linked-list walk with the direct getter for clarity. Behaviour unchanged; stress spec still 7/7 green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/planck-adapter-stress.spec.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/planck-adapter/tests/planck-adapter-stress.spec.ts b/packages/planck-adapter/tests/planck-adapter-stress.spec.ts index a5875131e..5916b76d7 100644 --- a/packages/planck-adapter/tests/planck-adapter-stress.spec.ts +++ b/packages/planck-adapter/tests/planck-adapter-stress.spec.ts @@ -33,13 +33,10 @@ type InternalsView = { const internals = (adapter: PlanckAdapter): InternalsView => adapter as unknown as InternalsView; -// Count bodies the planck world is actively simulating. Planck has no -// `getBodyCount` getter; walk the body list directly. -const planckBodyCount = (adapter: PlanckAdapter): number => { - let n = 0; - for (let b = adapter.world.getBodyList(); b; b = b.getNext()) n++; - return n; -}; +// Count bodies the planck world is actively simulating — direct +// passthrough to planck's native `getBodyCount()`. +const planckBodyCount = (adapter: PlanckAdapter): number => + adapter.world.getBodyCount(); const snapshot = (adapter: PlanckAdapter) => { const i = internals(adapter); From 27af71d98996a96b81745d808a3ba5dae327ff3c Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 21 May 2026 12:28:23 +0800 Subject: [PATCH 05/11] fix(physics): Body.addShape(bounds) + BuiltinAdapter debug-surface contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues flagged by Copilot review on #1457: 1. **`Body.addShape(bounds)` was broken** by the previous removal of `Bounds.toPolygon()`. The legacy path at `body.js:592` handled both `Rect` and `Bounds` via `shape.toPolygon()`; with that method gone, passing a Bounds as a body shape would throw at runtime. Fix: inline the rectangle-from-AABB polygon construction in `body.js` (which already imports `polygonPool` and now `Vector2d`). Same geometry the old `Bounds.toPolygon()` produced; consumer-side, so bounds.ts → polygon.ts cycle stays broken. 2. **`getBodyAABB` / `getBodyShapes` violated the adapter contract** — the documented behaviour is "returns undefined / []" for an unregistered body, but the original implementation only checked `renderable.body !== undefined`. A dangling Body reference left on `renderable.body` after `removeBody` would silently return debug geometry. Gate both on `this.bodies.has(body)` (same predicate the `getVelocity` implementation already uses) so the debug surface matches the documented contract. 3. **Stale `./raycast.js` reference** in the `raycast` method comment — the shared implementation was renamed to `raycast.ts` in this PR. One-character fix. melonjs 3484 / 12 skipped, matter 173, planck 165 — all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/melonjs/src/physics/builtin/body.js | 19 ++++++++++++++-- .../src/physics/builtin/builtin-adapter.ts | 22 ++++++++++++------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/melonjs/src/physics/builtin/body.js b/packages/melonjs/src/physics/builtin/body.js index 5f2922766..9def384a0 100644 --- a/packages/melonjs/src/physics/builtin/body.js +++ b/packages/melonjs/src/physics/builtin/body.js @@ -4,7 +4,7 @@ import { Point, pointPool } from "../../geometries/point.ts"; import { Polygon, polygonPool } from "../../geometries/polygon.ts"; import { Rect } from "../../geometries/rectangle.ts"; import { clamp } from "../../math/math.ts"; -import { vector2dPool } from "../../math/vector2d.ts"; +import { Vector2d, vector2dPool } from "../../math/vector2d.ts"; import pool from "../../system/legacy_pool.js"; import timer from "../../system/timer.ts"; import { remove } from "../../utils/array.ts"; @@ -589,12 +589,27 @@ export default class Body { * this.body.addShape(me.loader.getJSON("shapesdef").banana); */ addShape(shape) { - if (shape instanceof Rect || shape instanceof Bounds) { + if (shape instanceof Rect) { const poly = shape.toPolygon(); this.shapes.push(poly); // update the body bounds this.bounds.add(poly.points); this.bounds.translate(poly.pos); + } else if (shape instanceof Bounds) { + // Bounds doesn't carry a `toPolygon` method — it's a primitive + // AABB that polygon.ts already depends on, so we keep the + // conversion in the consumer to avoid a cyclic import. Same + // rectangle-from-bounds geometry the old `Bounds.toPolygon` + // produced. + const poly = polygonPool.get(shape.x, shape.y, [ + new Vector2d(0, 0), + new Vector2d(shape.width, 0), + new Vector2d(shape.width, shape.height), + new Vector2d(0, shape.height), + ]); + this.shapes.push(poly); + this.bounds.add(poly.points); + this.bounds.translate(poly.pos); } else if (shape instanceof Ellipse) { if (!this.shapes.includes(shape)) { // see removeShape diff --git a/packages/melonjs/src/physics/builtin/builtin-adapter.ts b/packages/melonjs/src/physics/builtin/builtin-adapter.ts index 520b5f3b2..4b7c7efda 100644 --- a/packages/melonjs/src/physics/builtin/builtin-adapter.ts +++ b/packages/melonjs/src/physics/builtin/builtin-adapter.ts @@ -342,9 +342,13 @@ export default class BuiltinAdapter implements PhysicsAdapter { getBodyAABB(renderable: Renderable, out: Bounds): Bounds | undefined { // Adapter-side debug surface: the body's AABB in renderable-local // coordinates. Builtin Body already stores its bounds in local - // space, so we just copy into the caller's `out`. + // space, so we just copy into the caller's `out`. Gated on + // `this.bodies.has(body)` (same predicate as `getVelocity`) so a + // dangling Body reference left on `renderable.body` after + // `removeBody` matches the adapter contract — returns + // `undefined` for an unregistered body. const body = renderable.body as Body | undefined; - if (body === undefined) { + if (!body || !this.bodies.has(body)) { return undefined; } const b = body.bounds; @@ -355,13 +359,15 @@ export default class BuiltinAdapter implements PhysicsAdapter { getBodyShapes(renderable: Renderable): readonly BodyShape[] { // Adapter-side debug surface: live `shapes` list in renderable- // local coordinates. Read-only — callers must not mutate. + // Gated on `this.bodies.has(body)` to match the adapter contract: + // an unregistered body (or no body) returns an empty array. // `body.shapes` is typed in body.js as a union that includes a // scalar `Point` variant (legacy, never produced at runtime). - return ( - ((renderable.body as Body | undefined)?.shapes as - | BodyShape[] - | undefined) ?? [] - ); + const body = renderable.body as Body | undefined; + if (!body || !this.bodies.has(body)) { + return []; + } + return body.shapes as BodyShape[]; } isGrounded(renderable: Renderable): boolean { @@ -374,7 +380,7 @@ export default class BuiltinAdapter implements PhysicsAdapter { raycast(from: Vector2d, to: Vector2d): RaycastHit | null { // Goes through the same SAT-based broadphase walk as the legacy - // `Detector.rayCast` (both share `raycastQuery` in `./raycast.js`), + // `Detector.rayCast` (both share `raycastQuery` in `./raycast.ts`), // and returns the portable `RaycastHit` shape (`renderable`, // `point`, `normal`, `fraction`) for parity with the matter / // planck adapters. `point` is the precise parametric entry point From e6eec81e43e0470b2ca0e6b901d0136654d512f7 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 21 May 2026 12:33:22 +0800 Subject: [PATCH 06/11] test(physics): cover every shape type accepted by Body.addShape / Body constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Body.addShape(bounds)` regression that Copilot caught on #1457 slipped past CI because nothing in the test suite ever called addShape with a Bounds. body.js's `addShape` is documented to accept seven distinct shape types — `Rect | Ellipse | Polygon | Line | Point | Bounds | JSON object` — but only three of those branches (Rect / Ellipse / Polygon) had behavioural coverage. Adds explicit per-branch tests: - `addShape(Bounds)` — pins the bug fix from 27af71d98 (regression guard for the inlined AABB-to-polygon conversion that replaced the removed `Bounds.toPolygon` method). - `addShape(Line)` — `Line extends Polygon`, so it hits the Polygon branch; pin that the inheritance path stays intact. - `addShape(Point)` — single Point as a degenerate shape. - `addShape(JSON object)` — PhysicEditor-style `{ shape: [x0,y0, ...] }` array routed through `fromJSON` (catch-all `else` branch). - `new Body(r, Bounds)` — constructor path mirror of the Bounds case. - `new Body(r, mixed-array)` — compound body with one of every shape type at once; catches branch-ordering bugs. Every conditional in `body.js#addShape` is now exercised by at least one test. body.spec.js: 81 → 89 tests, full melonjs suite: 3484 → 3490. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/melonjs/tests/body.spec.js | 87 +++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/packages/melonjs/tests/body.spec.js b/packages/melonjs/tests/body.spec.js index 4d3fffa49..145a49983 100644 --- a/packages/melonjs/tests/body.spec.js +++ b/packages/melonjs/tests/body.spec.js @@ -1,8 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest"; import { Body, + Bounds, collision, Ellipse, + Line, + Point, Polygon, Rect, Renderable, @@ -128,6 +131,90 @@ describe("Physics : Body", () => { expect(body.shapes.length).toEqual(3); }); + // The next block targets every shape type the JSDoc on + // `Body.addShape` and `Body` constructor advertise. Each branch + // of the conditional in `body.js#addShape` should be hit at least + // once — a regression on any branch would otherwise slip past CI. + it("addShape accepts a Bounds — converts to polygon via inline AABB", () => { + // Regression: `Body.addShape(bounds)` used to call + // `bounds.toPolygon()`. `Bounds.toPolygon` was removed in 19.5; + // body.js inlines the conversion now to avoid the + // bounds.ts ↔ polygon.ts cycle. Pin that the path still works. + const b = new Bounds(); + b.setMinMax(0, 0, 24, 16); + const result = body.addShape(b); + expect(result).toEqual(1); + expect(body.shapes.length).toEqual(1); + expect(body.shapes[0]).toBeInstanceOf(Polygon); + // Body bounds should match the source AABB. + const out = body.getBounds(); + expect(out.width).toEqual(24); + expect(out.height).toEqual(16); + }); + + it("addShape accepts a Line — Line extends Polygon so hits the Polygon branch", () => { + const line = new Line(0, 0, [ + { x: 0, y: 0 }, + { x: 32, y: 16 }, + ]); + const result = body.addShape(line); + expect(result).toEqual(1); + expect(body.shapes[0]).toBe(line); + }); + + it("addShape accepts a Point", () => { + const p = new Point(8, 12); + const result = body.addShape(p); + expect(result).toEqual(1); + expect(body.shapes[0]).toBe(p); + }); + + it("addShape accepts a PhysicEditor-style JSON shape", () => { + // `addShape` routes objects without a known prototype to + // `fromJSON`, which expects an array of `{ shape: [x0,y0, x1,y1, …] }` + // entries (the PhysicEditor export format). One triangle: + const json = [ + { + shape: [0, 0, 32, 0, 16, 32], + }, + ]; + const result = body.addShape(json); + expect(result).toBeGreaterThanOrEqual(1); + expect(body.shapes.length).toBeGreaterThanOrEqual(1); + }); + + it("Body constructor accepts a Bounds shape", () => { + // Mirror the addShape Bounds test on the constructor path — + // the bug Copilot caught originated in `new Body(r, bounds)` + // just as much as `body.addShape(bounds)`. + const b = new Bounds(); + b.setMinMax(0, 0, 32, 32); + const r = new Renderable(0, 0, 32, 32); + const built = new Body(r, b); + expect(built.shapes.length).toEqual(1); + expect(built.shapes[0]).toBeInstanceOf(Polygon); + }); + + it("Body constructor accepts a mixed array of shape types", () => { + // Multi-shape compound body with one of every supported type + // in a single array — the path most likely to surface a + // branch-ordering bug in `addShape`. + const b = new Bounds(); + b.setMinMax(0, 0, 8, 8); + const r = new Renderable(0, 0, 64, 64); + const built = new Body(r, [ + new Rect(0, 0, 16, 16), + new Ellipse(8, 8, 16, 16), + new Polygon(0, 0, [ + { x: 0, y: 0 }, + { x: 16, y: 0 }, + { x: 8, y: 16 }, + ]), + b, + ]); + expect(built.shapes.length).toEqual(4); + }); + it("getShape should return the shape at the given index", () => { const poly = new Polygon(0, 0, [ { x: 0, y: 0 }, From e3065ff5f42c0b8eb1af475e58f570df7fd1c4d6 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 21 May 2026 12:37:37 +0800 Subject: [PATCH 07/11] test(adapters): pin dangling-body contract for getBodyAABB / getBodyShapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The debug-surface contract bug Copilot caught on #1457 (BuiltinAdapter returning AABB/shapes for a body unregistered via `removeBody`) slipped past CI because the existing parity tests only exercised the "no body at all" path. They never constructed a dangling-body state — the production scenario where `renderable.body` still points at an orphaned Body instance after `adapter.removeBody(r)` cleared the adapter's bookkeeping. Adds two regression tests per parity spec, exercised against every adapter pair (Builtin + Matter, Builtin + Planck) via the factory: - `getBodyAABB returns undefined for a body removed via adapter.removeBody` - `getBodyShapes returns [] for a body removed via adapter.removeBody` Each test adds a body to the world, calls `adapter.removeBody(r)`, and asserts the debug surface returns the contracted "unregistered" values — NOT the dangling Body's geometry. matter / planck already behaved correctly here (their `bodyMap.get(r)` returns undefined for the orphan); BuiltinAdapter now does too, gated on `this.bodies.has(body)` in 27af71d98. matter parity 114 → 118, planck parity 112 → 116. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/matter-adapter/tests/parity.spec.ts | 31 ++++++++++++++++++++ packages/planck-adapter/tests/parity.spec.ts | 29 ++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/matter-adapter/tests/parity.spec.ts b/packages/matter-adapter/tests/parity.spec.ts index 8c31962d5..35f94debf 100644 --- a/packages/matter-adapter/tests/parity.spec.ts +++ b/packages/matter-adapter/tests/parity.spec.ts @@ -413,6 +413,37 @@ for (const { name, make, rayPrecision, expectedCapabilities } of factories) { expect(shapes).toBeDefined(); expect(shapes.length).toEqual(0); }); + + // Dangling-body case: `adapter.removeBody(r)` clears the + // adapter's bookkeeping but doesn't reset `renderable.body` — + // the field still points at the now-orphaned Body instance. + // Before the contract fix in 27af71d98, BuiltinAdapter checked + // only `body !== undefined` and happily returned debug geometry + // for the dangling body, in violation of the adapter contract. + // Pin the contract on every adapter so the regression can't + // come back silently. + it("getBodyAABB returns undefined for a body removed via adapter.removeBody", () => { + const r = addToWorld(new Renderable(0, 0, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + adapter.removeBody(r); + // renderable.body still points at the orphaned Body — the + // adapter must NOT return debug geometry for it. + const out = new Bounds(); + expect(adapter.getBodyAABB?.(r, out)).toBeUndefined(); + }); + + it("getBodyShapes returns [] for a body removed via adapter.removeBody", () => { + const r = addToWorld(new Renderable(0, 0, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + adapter.removeBody(r); + const shapes = adapter.getBodyShapes?.(r); + expect(shapes).toBeDefined(); + expect(shapes!.length).toEqual(0); + }); }); describe("debug API: coordinate-space adversarial", () => { diff --git a/packages/planck-adapter/tests/parity.spec.ts b/packages/planck-adapter/tests/parity.spec.ts index 5f266c68b..542b97646 100644 --- a/packages/planck-adapter/tests/parity.spec.ts +++ b/packages/planck-adapter/tests/parity.spec.ts @@ -409,6 +409,35 @@ for (const { name, make, aabbPrecision, expectedCapabilities } of factories) { expect(shapes).toBeDefined(); expect(shapes.length).toEqual(0); }); + + // Dangling-body case: `adapter.removeBody(r)` clears the + // adapter's bookkeeping but doesn't reset `renderable.body` — + // the field still points at the now-orphaned Body instance. + // Before the contract fix in 27af71d98, BuiltinAdapter checked + // only `body !== undefined` and happily returned debug geometry + // for the dangling body, in violation of the adapter contract. + // Pin the contract on every adapter so the regression can't + // come back silently. + it("getBodyAABB returns undefined for a body removed via adapter.removeBody", () => { + const r = addToWorld(new Renderable(0, 0, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + adapter.removeBody(r); + const out = new Bounds(); + expect(adapter.getBodyAABB?.(r, out)).toBeUndefined(); + }); + + it("getBodyShapes returns [] for a body removed via adapter.removeBody", () => { + const r = addToWorld(new Renderable(0, 0, 32, 32), { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }); + adapter.removeBody(r); + const shapes = adapter.getBodyShapes?.(r); + expect(shapes).toBeDefined(); + expect(shapes!.length).toEqual(0); + }); }); describe("debug API: coordinate-space adversarial", () => { From 771fd8acc1a04a314b9845d5402f3718f9272c14 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 21 May 2026 13:00:05 +0800 Subject: [PATCH 08/11] fix(adapters): lint errors in tests + matter-adapter source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR's apparent green check status was masking a real CI lint failure. GitHub Actions' path-filter strategy for the docs-only shim (main-docs.yml) operates on the whole PR diff, not the synchronize delta — so a mixed PR (some .md + some code) triggers both main.yml and main-docs.yml, and the faster shim wins the race via the shared concurrency group. The real lint job from main.yml was being cancelled silently while the shim reported success. The last actually-real lint run (26205443092 at commit 27af71d98) caught 9 errors all from this PR: - matter-adapter/src/index.ts:675 — `Missing JSDoc @param "body"` on the new `invalidateContactsFor` method. Resolved by dropping the JSDoc tag block; the prose is now a `//` comment inside the method body (matter-adapter convention for trivial private helpers). - matter-adapter / planck-adapter parity specs (4 errors) — the new adversarial pickup-pattern test captured the `this` reference inside `Coin.onCollisionStart` (`pickedUp = this`), tripping `@typescript-eslint/no-this-alias`. Refactored to a boolean flag + the existing outer-scope `coin` reference; functionally equivalent, no lint disable needed. - matter / planck parity specs (2 errors) — the new dangling-body test used `adapter.getBodyShapes?.(r)` + `shapes!.length`, both flagged as unnecessary by `no-unnecessary-type-assertion` (the method is required on the interface). Dropped the optional chain and the bang. - planck-adapter spec (4 errors) — the maxVelocity tests used `adapter.getMaxVelocity?.(r)` + `cap!.x`/`cap!.y`. `getMaxVelocity` is required on PlanckAdapter; dropped both the `?.` and `!`. Local repro: `pnpm lint --force` from repo root. Now clean across all packages. Test counts unchanged — matter 177, planck 169, melonjs 3490. (The underlying CI partition bug — main-docs.yml shim firing on mixed PRs — should be tracked as a separate 19.6 follow-up; this commit only fixes the lint errors it was hiding.) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/matter-adapter/src/index.ts | 10 ++++------ packages/matter-adapter/tests/parity.spec.ts | 14 ++++++-------- packages/planck-adapter/tests/parity.spec.ts | 14 ++++++-------- .../planck-adapter/tests/planck-adapter.spec.ts | 13 ++++++------- 4 files changed, 22 insertions(+), 29 deletions(-) diff --git a/packages/matter-adapter/src/index.ts b/packages/matter-adapter/src/index.ts index aa8f88249..22bce158f 100644 --- a/packages/matter-adapter/src/index.ts +++ b/packages/matter-adapter/src/index.ts @@ -672,13 +672,11 @@ export class MatterAdapter implements PhysicsAdapter { renderable.pos.y = p.y; } - /** - * Flush the per-body cached position-correction impulse so matter's - * position-warming mechanism doesn't reapply stale penetration data - * on the next step. Used by {@link setPosition} to keep a - * discontinuous teleport from being undone by the solver. - */ private invalidateContactsFor(body: Matter.Body): void { + // Flush the per-body cached position-correction impulse so matter's + // position-warming mechanism doesn't reapply stale penetration + // data on the next step. Used by setPosition to keep a + // discontinuous teleport from being undone by the solver. // Zero the per-body cached position-correction impulse. Matter's // `Resolver.postSolvePosition` applies `body.positionImpulse` to // the body every step via the position-warming mechanism (for diff --git a/packages/matter-adapter/tests/parity.spec.ts b/packages/matter-adapter/tests/parity.spec.ts index 35f94debf..42d7cce24 100644 --- a/packages/matter-adapter/tests/parity.spec.ts +++ b/packages/matter-adapter/tests/parity.spec.ts @@ -440,9 +440,7 @@ for (const { name, make, rayPrecision, expectedCapabilities } of factories) { shapes: [new Rect(0, 0, 32, 32)], }); adapter.removeBody(r); - const shapes = adapter.getBodyShapes?.(r); - expect(shapes).toBeDefined(); - expect(shapes!.length).toEqual(0); + expect(adapter.getBodyShapes(r).length).toEqual(0); }); }); @@ -1056,12 +1054,12 @@ for (const { name, make, rayPrecision, expectedCapabilities } of factories) { // Quirks #6 ("defer destructive ops in collision callbacks"). // Pins that: (a) the contact fires exactly once, (b) the // post-removal queryAABB no longer returns the coin. - let pickedUp: Renderable | undefined; + let pickedUp = false; const events: string[] = []; class Coin extends Renderable { onCollisionStart() { events.push("pickup"); - pickedUp = this; + pickedUp = true; } } const player = new Renderable(100, 100, 32, 32); @@ -1082,17 +1080,17 @@ for (const { name, make, rayPrecision, expectedCapabilities } of factories) { world.addChild(coin); world.update(16); - expect(pickedUp).toBeDefined(); + expect(pickedUp).toEqual(true); expect(events.length).toEqual(1); // Deferred removal — safe on every adapter. - pickedUp!.ancestor.removeChildNow(pickedUp!); + coin.ancestor.removeChildNow(coin); world.update(16); adapter.syncFromPhysics(); // Coin must not show up in subsequent spatial queries. const hits = adapter.queryAABB(new Rect(100, 95, 30, 30)); - expect(hits).not.toContain(pickedUp); + expect(hits).not.toContain(coin); // And no second pickup fired. expect(events.length).toEqual(1); }); diff --git a/packages/planck-adapter/tests/parity.spec.ts b/packages/planck-adapter/tests/parity.spec.ts index 542b97646..fc6bae3f3 100644 --- a/packages/planck-adapter/tests/parity.spec.ts +++ b/packages/planck-adapter/tests/parity.spec.ts @@ -434,9 +434,7 @@ for (const { name, make, aabbPrecision, expectedCapabilities } of factories) { shapes: [new Rect(0, 0, 32, 32)], }); adapter.removeBody(r); - const shapes = adapter.getBodyShapes?.(r); - expect(shapes).toBeDefined(); - expect(shapes!.length).toEqual(0); + expect(adapter.getBodyShapes(r).length).toEqual(0); }); }); @@ -938,12 +936,12 @@ for (const { name, make, aabbPrecision, expectedCapabilities } of factories) { // for removal, the actual removeChildNow happens AFTER the // world step. Matches the recommendation in BuiltinAdapter // Quirks #6 ("defer destructive ops in collision callbacks"). - let pickedUp: Renderable | undefined; + let pickedUp = false; const events: string[] = []; class Coin extends Renderable { onCollisionStart() { events.push("pickup"); - pickedUp = this; + pickedUp = true; } } const player = new Renderable(100, 100, 32, 32); @@ -964,15 +962,15 @@ for (const { name, make, aabbPrecision, expectedCapabilities } of factories) { world.addChild(coin); world.update(16); - expect(pickedUp).toBeDefined(); + expect(pickedUp).toEqual(true); expect(events.length).toEqual(1); - pickedUp!.ancestor.removeChildNow(pickedUp!); + coin.ancestor.removeChildNow(coin); world.update(16); adapter.syncFromPhysics(); const hits = adapter.queryAABB(new Rect(100, 95, 30, 30)); - expect(hits).not.toContain(pickedUp); + expect(hits).not.toContain(coin); expect(events.length).toEqual(1); }); diff --git a/packages/planck-adapter/tests/planck-adapter.spec.ts b/packages/planck-adapter/tests/planck-adapter.spec.ts index 5f636516c..1d5e3de36 100644 --- a/packages/planck-adapter/tests/planck-adapter.spec.ts +++ b/packages/planck-adapter/tests/planck-adapter.spec.ts @@ -627,10 +627,9 @@ describe("PlanckAdapter — feature parity with BuiltinAdapter", () => { shapes: [new Rect(0, 0, 32, 32)], maxVelocity: { x: 4, y: 9 }, }); - const cap = adapter.getMaxVelocity?.(r); - expect(cap).not.toBeUndefined(); - expect(cap!.x).toEqual(4); - expect(cap!.y).toEqual(9); + const cap = adapter.getMaxVelocity(r); + expect(cap.x).toEqual(4); + expect(cap.y).toEqual(9); }); it("setMaxVelocity overrides the bodyDef value", () => { @@ -641,9 +640,9 @@ describe("PlanckAdapter — feature parity with BuiltinAdapter", () => { maxVelocity: { x: 4, y: 9 }, }); adapter.setMaxVelocity(r, { x: 7, y: 12 }); - const cap = adapter.getMaxVelocity?.(r); - expect(cap!.x).toEqual(7); - expect(cap!.y).toEqual(12); + const cap = adapter.getMaxVelocity(r); + expect(cap.x).toEqual(7); + expect(cap.y).toEqual(12); }); it("|vel.x| is clamped to maxVelocity under sustained force", () => { From 40c818b05c8ab9b1ea3319ed16557da6cb0aefe0 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 21 May 2026 13:02:01 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix(ci):=20drop=20the=20docs-only=20shim?= =?UTF-8?q?=20partition=20=E2=80=94=20race=20condition=20on=20mixed=20PRs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-workflow partition (main.yml runs lint+test for code changes, main-docs.yml emits matching success checks for docs-only PRs) was racy on mixed PRs. GitHub Actions evaluates `paths` / `paths-ignore` against the **whole PR diff**, not the synchronize delta. A PR that started with a README.md tweak and later added .ts changes triggers BOTH workflows on every subsequent push: - main.yml fires (paths-ignore for *.md matches → still has non-md files → triggers) - main-docs.yml fires (paths *.md matches → still has md files → triggers) Both share `concurrency.group = ci-${{ github.ref }}` with `cancel-in-progress: true`, so the faster shim (~13s) cancelled the real lint+test (~1-2 min) and reported success. Branch protection saw a green "lint" / "test" check name and let the PR pass, even though real lint had 9 errors silently dropped on the floor. #1457 (this PR) was the first PR where the bug surfaced because the real lint actually failed — caught by a Copilot review that pointed me at the cancelled run log. Fix: drop main-docs.yml entirely. main.yml runs unconditionally on every push/PR. Docs-only PRs pay ~2 minutes of CI cost as the trade-off; cheaper than silently merging a lint failure. If the docs-only fast-path becomes painful, the right replacement is a single workflow with an internal docs-only detection step (e.g. `dorny/paths-filter` action) that gates the heavy work — same spirit, but only one workflow per push so no race is possible. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/main-docs.yml | 41 --------------------------------- .github/workflows/main.yml | 21 +++++++---------- 2 files changed, 8 insertions(+), 54 deletions(-) delete mode 100644 .github/workflows/main-docs.yml diff --git a/.github/workflows/main-docs.yml b/.github/workflows/main-docs.yml deleted file mode 100644 index d653bb12d..000000000 --- a/.github/workflows/main-docs.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Build & Test -# Status-check shim for docs-only PRs. `main.yml` skips docs/media paths via -# `paths-ignore`, which leaves required `lint`/`test` checks unsatisfiable on -# such PRs and BLOCKED by branch protection. This workflow is the mirror -# image: triggers ONLY on those paths and emits matching job names that -# succeed immediately. Together with `main.yml` the two form a partition — -# exactly one runs per push/PR. -# -# Job names (`lint`, `test`) MUST match the names in `main.yml` and the -# contexts required by the `master` ruleset. -on: - push: - branches: [master] - paths: - - '*.md' - - 'media/**' - - 'packages/melonjs/DOC_README.md' - pull_request: - types: [opened, synchronize] - paths: - - '*.md' - - 'media/**' - - 'packages/melonjs/DOC_README.md' - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - run: echo "docs-only change — skipping real lint" - - test: - runs-on: ubuntu-latest - steps: - - run: echo "docs-only change — skipping real test" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 17196c30c..3b37c93a3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,22 +1,17 @@ name: Build & Test -# Docs-only PRs (markdown / media changes) skip these jobs via `paths-ignore`. -# Required `lint`/`test` checks are satisfied for those PRs by the shim in -# `main-docs.yml`, which mirrors this file's path filters and emits matching -# job names that succeed immediately. Together the two form a partition — -# exactly one runs per push/PR. +# Runs lint + test on every push/PR — no path-filtered shim partition. +# The previous two-workflow setup (`main.yml` + `main-docs.yml`) was +# racy on mixed PRs (PRs touching both `.md` and code): GitHub Actions +# evaluates `paths` / `paths-ignore` against the full PR diff, so a +# mixed PR triggered BOTH workflows, they shared `concurrency.group`, +# and the faster docs-shim cancelled the real CI run while still +# reporting green checks. Docs-only PRs pay ~2 minutes of CI cost as +# the trade-off; cheaper than silently merging a lint failure. on: push: branches: [master] - paths-ignore: - - '*.md' - - 'media/**' - - 'packages/melonjs/DOC_README.md' pull_request: types: [opened, synchronize] - paths-ignore: - - '*.md' - - 'media/**' - - 'packages/melonjs/DOC_README.md' concurrency: group: ci-${{ github.ref }} From 15d36b8d3ccff68439bad5c0c9ae66e1a28aaee8 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 21 May 2026 13:23:12 +0800 Subject: [PATCH 10/11] docs(physics): update stale ./raycast.js comment ref in detector.js Same as the earlier fix in builtin-adapter.ts (27af71d98), but in the detector's own rayCast JSDoc. Compile-time imports were already correct (line 3: import from './raycast.ts'); the stale string only existed in the prose comment above rayCast(), which the runtime / TS never read. --- packages/melonjs/src/physics/builtin/detector.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/melonjs/src/physics/builtin/detector.js b/packages/melonjs/src/physics/builtin/detector.js index 835f0b68f..1d86e3564 100644 --- a/packages/melonjs/src/physics/builtin/detector.js +++ b/packages/melonjs/src/physics/builtin/detector.js @@ -488,7 +488,7 @@ class Detector { * } */ rayCast(line, result = []) { - // Thin wrapper over the shared `raycastQuery` (in `./raycast.js`), + // Thin wrapper over the shared `raycastQuery` (in `./raycast.ts`), // which is also used by `BuiltinAdapter.raycast` to expose the // portable adapter API. We drop the per-hit `{ point, normal, // fraction }` info and keep just the renderable array, preserving From b5accdf88477719242aed74f065ae26767e7b273 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 22 May 2026 07:39:45 +0800 Subject: [PATCH 11/11] chore(release): stamp all three 19.5 CHANGELOGs with the 2026-05-22 release date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - melonjs 19.5.0: _2026-05-20_ → _2026-05-22_ (pushed back from the original planck-adapter merge date because the release-prep PR surfaced + fixed real bugs along the way — see #1457) - @melonjs/matter-adapter 1.0.0: _unreleased_ → _2026-05-22_ - @melonjs/planck-adapter 1.0.0: _2026-05-22_ (initial release) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/matter-adapter/CHANGELOG.md | 2 +- packages/melonjs/CHANGELOG.md | 2 +- packages/planck-adapter/CHANGELOG.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/matter-adapter/CHANGELOG.md b/packages/matter-adapter/CHANGELOG.md index f70d2d12e..44dce9355 100644 --- a/packages/matter-adapter/CHANGELOG.md +++ b/packages/matter-adapter/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.0 - _unreleased_ +## 1.0.0 - _2026-05-22_ ### Added - **`MatterAdapter.Body`** — published type for `renderable.body` under this adapter. Namespace-merged with the class, defined as `ReturnType & PhysicsBody`. Lets user code reach matter-native fields (`frictionAir`, `angle`, `angularVelocity`, `torque`, …) via `(this.body as MatterAdapter.Body).frictionAir = 0.02` without importing `matter-js` directly — the matter dependency stays behind the adapter boundary. diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 11c39d707..a014812fe 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [19.5.0] (melonJS 2) - _2026-05-20_ +## [19.5.0] (melonJS 2) - _2026-05-22_ **Highlights:** physics-focused release. The headline is the new `PhysicsAdapter` abstraction — the same game code can now run on either the engine's built-in SAT physics (default, unchanged behavior) or on a third-party rigid-body engine via the `physic` Application setting. Two official adapters ship alongside the engine: `@melonjs/matter-adapter` (wraps matter-js — constraints, sleeping bodies, continuous collision detection, raycasts, and matter's solver) and `@melonjs/planck-adapter` (wraps planck.js — a faithful Box2D 2.3.0 port with the same feature set plus Box2D-native joints, CCD bullet flag, and per-body gravity scale). New examples demonstrate the abstraction — `Matter Platformer` (the canonical platformer ported to matter) and `Pool (Matter)` (top-down 8-ball pool, drag-to-aim cue with physics-driven break shots and pocket sensors). Collision dispatch also gains a proper lifecycle (`onCollisionStart` / `onCollisionActive` / `onCollisionEnd`) on every `Renderable`, dispatched consistently by all three adapters with a **receiver-symmetric contract** (`response.a === this`, `response.b === other`, `response.normal` is the MTV of the receiver). The legacy `onCollision` handler is preserved unchanged for backward compatibility, but is **superseded by `onCollisionActive` on a per-renderable basis** so users who migrate get a single, clean every-frame contract. Push-out semantics on the built-in adapter are now matter-aligned: dynamic non-sensor bodies separate by default whether or not `onCollision` is defined. diff --git a/packages/planck-adapter/CHANGELOG.md b/packages/planck-adapter/CHANGELOG.md index 2dada2780..c07ca6d71 100644 --- a/packages/planck-adapter/CHANGELOG.md +++ b/packages/planck-adapter/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.0 - _unreleased_ +## 1.0.0 - _2026-05-22_ Initial release of `@melonjs/planck-adapter` — a [planck.js](https://piqnt.com/planck.js/) (Box2D 2.3.0 port) physics adapter for melonJS.