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 }} diff --git a/README.md b/README.md index 42db72d6c..9e79cd0b0 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,12 @@ 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. + +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/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/matter-adapter/src/index.ts b/packages/matter-adapter/src/index.ts index 1a4fc03fb..22bce158f 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,50 @@ 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; } + 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 + // 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 +1096,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 bf378f4cb..42d7cce24 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, @@ -32,11 +33,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 +73,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 +99,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; @@ -378,6 +413,35 @@ for (const { name, make } 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); + expect(adapter.getBodyShapes(r).length).toEqual(0); + }); }); describe("debug API: coordinate-space adversarial", () => { @@ -700,5 +764,540 @@ 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); + }); + }); + + 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 = false; + const events: string[] = []; + class Coin extends Renderable { + onCollisionStart() { + events.push("pickup"); + pickedUp = true; + } + } + 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).toEqual(true); + expect(events.length).toEqual(1); + + // Deferred removal — safe on every adapter. + 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(coin); + // 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/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 7d7f7d10f..a014812fe 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-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/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/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.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..4b7c7efda --- /dev/null +++ b/packages/melonjs/src/physics/builtin/builtin-adapter.ts @@ -0,0 +1,433 @@ +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`. 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 || !this.bodies.has(body)) { + 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. + // 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). + const body = renderable.body as Body | undefined; + if (!body || !this.bodies.has(body)) { + return []; + } + return body.shapes as BodyShape[]; + } + + 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.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 + // 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..1d86e3564 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, @@ -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 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/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 }, 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. 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 new file mode 100644 index 000000000..fc6bae3f3 --- /dev/null +++ b/packages/planck-adapter/tests/parity.spec.ts @@ -0,0 +1,1153 @@ +/** + * 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, + Container, + 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; + /** 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), + }); + const world = new World(0, 0, 800, 600, adapter); + return { adapter, world }; + }, + }, + { + 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 + // 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, expectedCapabilities } 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); + }); + + // 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); + expect(adapter.getBodyShapes(r).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); + }); + }); + + // ---------------------------------------------------------- + // 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); + }); + }); + + 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 = false; + const events: string[] = []; + class Coin extends Renderable { + onCollisionStart() { + events.push("pickup"); + pickedUp = true; + } + } + 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).toEqual(true); + expect(events.length).toEqual(1); + + coin.ancestor.removeChildNow(coin); + world.update(16); + adapter.syncFromPhysics(); + + const hits = adapter.queryAABB(new Rect(100, 95, 30, 30)); + expect(hits).not.toContain(coin); + 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-stress.spec.ts b/packages/planck-adapter/tests/planck-adapter-stress.spec.ts new file mode 100644 index 000000000..5916b76d7 --- /dev/null +++ b/packages/planck-adapter/tests/planck-adapter-stress.spec.ts @@ -0,0 +1,193 @@ +/** + * 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 — direct +// passthrough to planck's native `getBodyCount()`. +const planckBodyCount = (adapter: PlanckAdapter): number => + adapter.world.getBodyCount(); + +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); + }); +}); diff --git a/packages/planck-adapter/tests/planck-adapter.spec.ts b/packages/planck-adapter/tests/planck-adapter.spec.ts index 799a73f98..1d5e3de36 100644 --- a/packages/planck-adapter/tests/planck-adapter.spec.ts +++ b/packages/planck-adapter/tests/planck-adapter.spec.ts @@ -614,6 +614,54 @@ 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.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", () => {