From c738df9e359d7c97fb7e170f8ebaed09acf87e4f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 22 Sep 2026 10:42:20 +0800 Subject: [PATCH 1/6] Physics: collide where the renderable draws `anchorPoint` says where in its own bounds a renderable sits on its `pos`: `updateBounds()` shifts its bounds by `-size * anchorPoint` and `preDraw` shifts its pixels by the same amount. Collision shapes were measured from `pos` regardless, so anything not anchored at its top-left corner collided where it was not drawn, by half a body at the default centred anchor and by a full body height at the bottom anchor a platformer actor uses. `Entity` sets its own anchor to (0, 0), as Tiled objects do, which is why the legacy routes never saw this and only the `bodyDef` path is affected. The physics was never wrong in itself, which is what made it slippery: a body rested exactly on the floor at either anchor, and collisions fired normally. Only the hitbox sat somewhere the artwork was not, and anything drawn from `draw()` shifted with the sprite and hid the gap. Applied at the one place local shape coordinates become world positions: the SAT for the builtin solver, and the body origin in each adapter. Authored shapes keep their own coordinates, so the rotation pivot, `applyForce`'s lever arm and the shape pools all still read what the caller wrote. The readback follows the same frame, or the debug overlay would draw a body half its size away from where it collides. The planck offset that maps the body back onto `renderable.pos` keeps referencing the real `pos` rather than the shifted origin; feeding the anchor back in twice made a static body appear to jump on its first step. Also fixes matter placing a polygon by the arithmetic mean of its vertices where `Bodies.fromVertices` puts the area centroid, which shifted every polygon by the difference. The two coincide for a rectangle and for any triangle, so no simple fixture could see it; a traced outline from a shape editor drifted 39px on a 200px blob. Tests pin the contract as behaviour rather than as coordinates: a body comes to rest with its drawn bottom edge on the floor whatever the anchor, across the builtin, matter and planck adapters, with anchor 0 as a passing control. Fixtures that did their arithmetic in `pos + size` terms now state the corner anchor they always assumed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/matter-adapter/CHANGELOG.md | 6 + packages/matter-adapter/package.json | 2 +- packages/matter-adapter/src/index.ts | 66 ++++++-- .../tests/matter-adapter.spec.ts | 2 + packages/matter-adapter/tests/parity.spec.ts | 149 ++++++++++++++++++ .../tests/polygon-centroid.spec.ts | 130 +++++++++++++++ .../tests/rotated-body-shapes.spec.ts | 6 + packages/melonjs/CHANGELOG.md | 1 + .../melonjs/skills/melonjs-physics/SKILL.md | 35 ++++ .../src/physics/builtin/builtin-adapter.ts | 38 ++++- packages/melonjs/src/physics/builtin/sat.js | 44 ++++++ packages/melonjs/src/physics/builtin/sat3d.js | 12 +- packages/melonjs/src/renderable/renderable.js | 8 + .../tests/builtin-adapter-adversarial.spec.js | 7 + .../tests/builtin-adapter-body.spec.js | 42 +++++ .../melonjs/tests/builtin-resting.spec.js | 65 ++++++++ packages/planck-adapter/CHANGELOG.md | 5 + packages/planck-adapter/package.json | 2 +- packages/planck-adapter/src/index.ts | 28 +++- packages/planck-adapter/tests/parity.spec.ts | 149 ++++++++++++++++++ .../tests/planck-adapter.spec.ts | 10 ++ .../tests/rotated-body-shapes.spec.ts | 6 + 22 files changed, 790 insertions(+), 23 deletions(-) create mode 100644 packages/matter-adapter/tests/polygon-centroid.spec.ts diff --git a/packages/matter-adapter/CHANGELOG.md b/packages/matter-adapter/CHANGELOG.md index 5c90bb820..85901f485 100644 --- a/packages/matter-adapter/CHANGELOG.md +++ b/packages/matter-adapter/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.5.0 - _unreleased_ + +### Fixed +- A body is built in the frame its renderable draws in. The body origin was taken from `renderable.pos` with no regard for `anchorPoint`, which shifts where a renderable draws by `-size * anchorPoint`, so a sprite anchored anywhere other than its corner collided where it was not drawn: half its size away at the default centred anchor. A shape's own offset is untouched, so a hitbox deliberately placed inside the frame still lands where it was put +- Polygon bodies are placed where they were authored. `Bodies.fromVertices` puts a polygon's area centroid at the position it is handed, and the adapter handed it the arithmetic mean of the vertices instead, so every polygon was shifted by the difference between those two points. The two coincide for a rectangle and for any triangle, which is why simple shapes never showed this; the gap grows with how unevenly the vertices are spread, making a traced outline from a shape editor the worst case, far enough off to sit visibly clear of the artwork it was drawn on + ## 1.4.0 - _2026-09-21_ ### Fixed diff --git a/packages/matter-adapter/package.json b/packages/matter-adapter/package.json index ee95d1f7b..2c341ac00 100644 --- a/packages/matter-adapter/package.json +++ b/packages/matter-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@melonjs/matter-adapter", - "version": "1.4.0", + "version": "1.5.0", "description": "melonJS physics adapter for matter-js", "homepage": "https://www.npmjs.com/package/@melonjs/matter-adapter", "type": "module", diff --git a/packages/matter-adapter/src/index.ts b/packages/matter-adapter/src/index.ts index 05fdd437a..ce83492b7 100644 --- a/packages/matter-adapter/src/index.ts +++ b/packages/matter-adapter/src/index.ts @@ -379,8 +379,22 @@ export class MatterAdapter implements PhysicsAdapter { addBody(renderable: Renderable, def: BodyDefinition): MatterAdapter.Body { // translate shapes into matter bodies. Multi-shape defs become a // matter compound body (Matter.Body.create with parts). - const baseX = renderable.pos.x; - const baseY = renderable.pos.y; + // The frame the renderable DRAWS in. `anchorPoint` shifts a + // renderable's bounds by `-size * anchorPoint` and `preDraw` shifts + // its pixels by the same amount, and collision shapes are authored in + // that same frame, so the body is built there rather than on `pos`. + // Zero for an anchor of (0, 0) — what `Entity` and Tiled objects set — + // so those paths are unchanged. Guarded on `Number.isFinite` as + // `preDraw` is: a `Container`'s default size is `Infinity`, and + // `Infinity * 0` is `NaN`. + const anchorX = Number.isFinite(renderable.width) + ? renderable.width * renderable.anchorPoint.x + : 0; + const anchorY = Number.isFinite(renderable.height) + ? renderable.height * renderable.anchorPoint.y + : 0; + const baseX = renderable.pos.x - anchorX; + const baseY = renderable.pos.y - anchorY; // `isActive === false` keeps a shape out of the simulation without // removing it from the definition — the portable flag the builtin and // the planck adapter both honour. Skipped here rather than created and @@ -499,9 +513,14 @@ export class MatterAdapter implements PhysicsAdapter { // Track the offset between renderable.pos (top-left) and the // matter body's centroid so syncFromPhysics places the sprite // correctly. + // Deliberately `renderable.pos`, not `baseX`/`baseY`: this offset + // puts the SPRITE back where it belongs on the way out of the + // simulation, and the sprite is placed from `pos`. Using the + // anchor-shifted origin here would feed the offset back in twice and + // walk the renderable away from the body every step. this.posOffsets.set(renderable, { - x: baseX - body.position.x, - y: baseY - body.position.y, + x: renderable.pos.x - body.position.x, + y: renderable.pos.y - body.position.y, }); // Debug-plugin compatibility is provided via the adapter-side // `getBodyAABB` / `getBodyShapes` methods (see below). No @@ -1365,19 +1384,38 @@ export class MatterAdapter implements PhysicsAdapter { x: baseX + shape.pos.x + p.x, y: baseY + shape.pos.y + p.y, })); - // average the points to get an initial center for Bodies.fromVertices - const cx = - points.reduce((s, p) => s + p.x, 0) / Math.max(1, points.length); - const cy = - points.reduce((s, p) => s + p.y, 0) / Math.max(1, points.length); - const body = Matter.Bodies.fromVertices(cx, cy, [points]); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: matter-js types claim non-null, but fromVertices returns undefined for degenerate polygons (collinear / zero-area) + // `fromVertices` places the polygon's AREA CENTROID at the + // position it is given: it translates the vertex list by + // `-Vertices.centre(...)` and then moves it to that position. + // So the position handed to it has to be that same centroid for + // the vertices to land where they were authored. The arithmetic + // mean of the points is a different point entirely, and using it + // shifted every polygon by `mean - centroid`. + // + // That difference is zero exactly when the vertices are evenly + // distributed — a rectangle, and any triangle — which is why + // simple test shapes never showed it, and it grows with how + // lopsided the distribution is. A traced outline out of a shape + // editor is the worst case, since such a tool puts vertices + // densely along curves and sparsely along straights: measured at + // ~39px of drift on a 200px blob, with the collision geometry + // visibly off the artwork it was drawn on. + const centre = Matter.Vertices.centre(points); + // `Vertices.centre` divides by the polygon area, so a degenerate + // (collinear / zero-area) outline yields a non-finite centre + // rather than throwing. + // The annotation is load-bearing: matter-js types `fromVertices` + // as non-null, but it returns undefined for a degenerate polygon. + const body: Matter.Body | undefined = + Number.isFinite(centre.x) && Number.isFinite(centre.y) + ? Matter.Bodies.fromVertices(centre.x, centre.y, [points]) + : undefined; if (body) { return body; } - // Bodies.fromVertices returns undefined when the vertices form - // a degenerate (collinear, zero-area, etc.) polygon. Fall back - // to an axis-aligned bounding box so the body still exists in + // Degenerate polygon: no usable centroid, and `fromVertices` + // returns undefined for one anyway. Fall back to an + // axis-aligned bounding box so the body still exists in // the simulation rather than disappearing silently. let minX = Number.POSITIVE_INFINITY; let minY = Number.POSITIVE_INFINITY; diff --git a/packages/matter-adapter/tests/matter-adapter.spec.ts b/packages/matter-adapter/tests/matter-adapter.spec.ts index 27697a129..835224b7e 100644 --- a/packages/matter-adapter/tests/matter-adapter.spec.ts +++ b/packages/matter-adapter/tests/matter-adapter.spec.ts @@ -873,6 +873,8 @@ describe("MatterAdapter — feature parity with BuiltinAdapter", () => { describe("raycast / queryAABB", () => { it("raycast hits a body in the ray's path", () => { const target = new Renderable(200, 100, 32, 32); + // corner-anchored: the ray is aimed at `pos + size` coordinates + target.anchorPoint.set(0, 0); adapter.addBody(target, { type: "static", shapes: [new Rect(0, 0, 32, 32)], diff --git a/packages/matter-adapter/tests/parity.spec.ts b/packages/matter-adapter/tests/parity.spec.ts index b1f35afba..609e0205f 100644 --- a/packages/matter-adapter/tests/parity.spec.ts +++ b/packages/matter-adapter/tests/parity.spec.ts @@ -19,6 +19,7 @@ import { boot, Container, collision, + Polygon, Rect, Renderable, Vector2d, @@ -123,11 +124,153 @@ for (const { name, make, rayPrecision, expectedCapabilities } of factories) { def: Parameters[1], ) => { r.alwaysUpdate = true; + // Every fixture below does its arithmetic in `pos + size` terms, + // which is the shape frame only for a renderable anchored at its + // corner. `Renderable` defaults to (0.5, 0.5), and shapes now + // follow the anchor as the drawing does, so the corner anchor is + // stated here rather than assumed. The `anchorPoint and collision + // alignment` cases deliberately bypass this helper. + r.anchorPoint.set(0, 0); r.bodyDef = def; world.addChild(r); return r; }; + describe("anchorPoint and collision alignment", () => { + // `anchorPoint` moves where a renderable DRAWS: `updateBounds()` + // shifts its bounds by `-size * anchorPoint`, and `preDraw` shifts + // every pixel it puts on screen by the same amount. A body's + // collision shapes have to end up in that same frame, or the + // hitbox sits where the artwork is not. + // + // `Entity` hid this for years by forcing its own anchor to (0, 0), + // as Tiled objects do, so only the `bodyDef`-on-a-Renderable path + // is exposed, and there the default anchor is (0.5, 0.5). + // + // Pinned as observable behaviour rather than as shape + // coordinates, so it constrains the contract and not the + // implementation: drop a body on a floor, and the bottom edge of + // what is DRAWN must come to rest on the floor top, whatever the + // anchor is. + it("reports its geometry in the frame it collides in", () => { + // The readback has to agree with the narrowphase. The builtin + // applies the anchor offset in the SAT and leaves the stored + // shapes as authored, so reporting those raw would put the + // debug overlay half a body from where the collision is. + const box = new Renderable(300, 300, 40, 40); + box.alwaysUpdate = true; + box.anchorPoint.set(0.5, 0.5); + box.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 40, 40)], + }; + world.addChild(box); + + // the drawn frame, in renderable-local terms, is -20..20 + const aabb = adapter.getBodyAABB?.(box, new Bounds()); + expect(aabb).toBeDefined(); + expect(aabb!.left).toBeCloseTo(-20, 0); + expect(aabb!.top).toBeCloseTo(-20, 0); + expect(aabb!.right).toBeCloseTo(20, 0); + expect(aabb!.bottom).toBeCloseTo(20, 0); + + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + for (const shape of adapter.getBodyShapes(box)) { + const poly = shape as Polygon; + for (const p of poly.points) { + minX = Math.min(minX, p.x + poly.pos.x); + maxX = Math.max(maxX, p.x + poly.pos.x); + } + } + expect(minX).toBeCloseTo(-20, 0); + expect(maxX).toBeCloseTo(20, 0); + }); + + for (const anchor of [0, 0.5, 1]) { + it(`rests where it draws with anchorPoint ${anchor}`, () => { + const floorY = 200; + const floor = new Renderable(0, floorY, 800, 20); + floor.alwaysUpdate = true; + floor.anchorPoint.set(0, 0); + floor.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 800, 20)], + }; + world.addChild(floor); + + const box = new Renderable(100, 120, 32, 32); + box.alwaysUpdate = true; + box.anchorPoint.set(anchor, anchor); + box.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + world.addChild(box); + + for (let i = 0; i < 180; i++) { + world.update(16); + } + + // the bottom of the drawn frame: `pos` plus whatever part + // of the height sits below it for this anchor + const drawnBottom = box.pos.y + box.height * (1 - box.anchorPoint.y); + expect(Math.abs(drawnBottom - floorY)).toBeLessThan(2); + // and the renderable's own bounds agree with that frame + expect(box.getBounds().bottom).toBeCloseTo(drawnBottom, 1); + }); + } + }); + + describe("polygon placement", () => { + /** + * A lopsided convex quad. The arithmetic mean of its vertices and + * its area centroid sit about 8px apart, and an adapter that + * confuses the two shifts the whole outline by that difference. + * + * Deliberately neither a rectangle nor a triangle: for both of + * those the mean IS the centroid, so they cannot tell a correct + * implementation from a broken one. Every simple fixture in these + * suites was one or the other, which is how matter shipped this + * drifting for real (`@melonjs/matter-adapter` 1.4.1), visible + * only once a body had artwork behind it to be measured against. + * + * Whatever each engine uses internally as the body anchor, the + * geometry it reports has to be the geometry that was authored. + */ + it("reports an irregular polygon on its authored vertices", () => { + const points: [Vector2d, Vector2d, Vector2d, ...Vector2d[]] = [ + new Vector2d(10, 0), + new Vector2d(190, 40), + new Vector2d(150, 260), + new Vector2d(40, 200), + ]; + const r = addToWorld(new Renderable(100, 100, 220, 220), { + type: "dynamic", + shapes: [new Polygon(0, 0, points)], + }); + + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const shape of adapter.getBodyShapes(r)) { + const poly = shape as Polygon; + for (const p of poly.points) { + minX = Math.min(minX, p.x + poly.pos.x); + minY = Math.min(minY, p.y + poly.pos.y); + maxX = Math.max(maxX, p.x + poly.pos.x); + maxY = Math.max(maxY, p.y + poly.pos.y); + } + } + + expect(minX).toBeCloseTo(10, 1); + expect(minY).toBeCloseTo(0, 1); + expect(maxX).toBeCloseTo(190, 1); + expect(maxY).toBeCloseTo(260, 1); + }); + }); + describe("velocity API", () => { it("velocity round-trips through set/get", () => { const r = addToWorld(new Renderable(100, 100, 32, 32), { @@ -793,6 +936,9 @@ for (const { name, make, rayPrecision, expectedCapabilities } of factories) { // the right face at x≈240. Ray well above the box must miss. const placeBox = () => { const wall = new Renderable(200, 200, 40, 40); + // corner-anchored: the ray coordinates below are the wall's + // edges in `pos + size` terms + wall.anchorPoint.set(0, 0); wall.alwaysUpdate = true; wall.bodyDef = { type: "static", @@ -844,6 +990,7 @@ for (const { name, make, rayPrecision, expectedCapabilities } of factories) { describe("queryAABB — portable region query", () => { const placeBox = (x: number, y: number) => { const r = new Renderable(x, y, 40, 40); + r.anchorPoint.set(0, 0); r.alwaysUpdate = true; r.bodyDef = { type: "static", @@ -1005,6 +1152,8 @@ for (const { name, make, rayPrecision, expectedCapabilities } of factories) { collisionMask: number, ) => { const wall = new Renderable(x, 200, 40, 40); + // corner-anchored: the ray below is aimed in `pos + size` terms + wall.anchorPoint.set(0, 0); wall.alwaysUpdate = true; wall.bodyDef = { type: "static", diff --git a/packages/matter-adapter/tests/polygon-centroid.spec.ts b/packages/matter-adapter/tests/polygon-centroid.spec.ts new file mode 100644 index 000000000..8b83fef98 --- /dev/null +++ b/packages/matter-adapter/tests/polygon-centroid.spec.ts @@ -0,0 +1,130 @@ +/** + * Where a polygon body actually ends up. + * + * `Matter.Bodies.fromVertices` places the polygon's AREA CENTROID at the + * position it is handed, so that position has to be the centroid for the + * vertices to land where they were authored. The adapter used to hand it the + * arithmetic mean of the points instead, which shifted every polygon by + * `mean - centroid`. + * + * The difference is exactly zero for a rectangle and for any triangle, so + * symmetric fixtures cannot see this at all. It grows with how unevenly the + * vertices are spread, which makes a traced outline from a shape editor the + * worst case: such a tool puts vertices densely along curves and sparsely + * along straights. The visible symptom is collision geometry sitting off the + * artwork it was drawn on. + */ + +import { + Application, + boot, + Polygon, + Renderable, + Vector2d, + video, + World, +} from "melonjs"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { MatterAdapter } from "../src/index"; + +/** + * `Polygon` takes a tuple of at least three points. The engine's own + * `PolygonVertices` is not exported from the package root, so it is spelled + * out here rather than asserted away with `any`. + */ +type PolygonPoints = [Vector2d, Vector2d, Vector2d, ...Vector2d[]]; + +describe("MatterAdapter — a polygon lands where it was authored", () => { + let world: World; + let adapter: MatterAdapter; + + beforeAll(async () => { + boot(); + const app = new Application(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + beforeEach(() => { + adapter = new MatterAdapter({ gravity: { x: 0, y: 0 } }); + world = new World(0, 0, 800, 600, adapter); + }); + + /** + * @param points - the polygon outline, in renderable-local coordinates + * @returns the renderable carrying the body + */ + const polygonBody = (points: PolygonPoints) => { + const r = new Renderable(100, 100, 220, 220); + r.anchorPoint.set(0, 0); + world.addChild(r); + adapter.addBody(r, { + type: "dynamic", + shapes: [new Polygon(0, 0, points)], + }); + return r; + }; + + /** + * @param r - the renderable to read back + * @returns the axis-aligned extent of every shape the adapter reports + */ + const extent = (r: Renderable) => { + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const shape of adapter.getBodyShapes(r)) { + for (const p of (shape as Polygon).points) { + const x = p.x + (shape as Polygon).pos.x; + const y = p.y + (shape as Polygon).pos.y; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + return { minX, minY, maxX, maxY }; + }; + + it("keeps an irregular convex polygon on its authored vertices", () => { + // a lopsided quad: mean and centroid are ~8px apart + const points: PolygonPoints = [ + new Vector2d(10, 0), + new Vector2d(190, 40), + new Vector2d(150, 260), + new Vector2d(40, 200), + ]; + const r = polygonBody(points); + const got = extent(r); + + expect(got.minX).toBeCloseTo(10, 6); + expect(got.minY).toBeCloseTo(0, 6); + expect(got.maxX).toBeCloseTo(190, 6); + expect(got.maxY).toBeCloseTo(260, 6); + }); + + it("keeps a traced outline on its authored vertices", () => { + // the shape-editor case: vertices crowded along a curve, then one + // lone point far away. Mean and centroid are ~39px apart here. + const arc: Vector2d[] = []; + for (let a = 0; a <= Math.PI; a += Math.PI / 24) { + arc.push(new Vector2d(100 + 100 * Math.cos(a), 100 - 60 * Math.sin(a))); + } + arc.push(new Vector2d(-60, 190)); + const points = arc as PolygonPoints; + + const r = polygonBody(points); + const got = extent(r); + + // a concave outline is reported as its hull, which has the same + // axis-aligned extent as the outline itself + expect(got.minX).toBeCloseTo(-60, 6); + expect(got.minY).toBeCloseTo(40, 6); + expect(got.maxX).toBeCloseTo(200, 6); + expect(got.maxY).toBeCloseTo(190, 6); + }); +}); diff --git a/packages/matter-adapter/tests/rotated-body-shapes.spec.ts b/packages/matter-adapter/tests/rotated-body-shapes.spec.ts index 431a33a91..1f2bc66cf 100644 --- a/packages/matter-adapter/tests/rotated-body-shapes.spec.ts +++ b/packages/matter-adapter/tests/rotated-body-shapes.spec.ts @@ -53,6 +53,9 @@ describe("MatterAdapter — getBodyShapes() follows the body's rotation", () => */ const square = (angle: number) => { const r = new Renderable(100, 100, 40, 40); + // corner-anchored: this spec reasons about shape coordinates + // relative to `pos`, and shapes now follow the anchor + r.anchorPoint.set(0, 0); world.addChild(r); adapter.addBody(r, { type: "dynamic", @@ -182,6 +185,9 @@ describe("MatterAdapter — getBodyShapes() follows the body's rotation", () => const bodyWith = (shapes: Polygon[]) => { const r = new Renderable(200, 200, 100, 240); + // corner-anchored: this spec reasons about shape coordinates + // relative to `pos`, and shapes now follow the anchor + r.anchorPoint.set(0, 0); world.addChild(r); adapter.addBody(r, { type: "static", shapes }); return r; diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index fd2f31765..8dcb213a2 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -20,6 +20,7 @@ - `Polygon#rotate(angle, pivot)` turns the whole shape, not only its points: a polygon carrying its offset in `pos` spun about its own origin and landed elsewhere. A polygon at the origin, which is every caller in the engine until now, is unchanged - Body: rotation pivoted about the wrong point for any renderable away from the world origin, since `body.bounds` is already renderable-local and the pivot subtracted `renderable.pos` from it a second time - `Body.rotate()` no longer throws on a `Box3d` or `Point` shape, neither of which can rotate; such a shape keeps its orientation and still contributes its bounds +- Physics: on the builtin solver, a body collides where its renderable draws. `anchorPoint` shifts a renderable's bounds and its rendering by `-size * anchorPoint`, but collision shapes were measured from `pos` regardless, so a renderable anchored anywhere other than its top-left corner had a hitbox sitting where it was not drawn: half a body off at the default centred anchor, and a full body height at the bottom anchor a platformer actor uses. **A game that compensated for this by offsetting its shapes by hand should remove those offsets.** `Entity` and Tiled objects set their own anchor to (0, 0), so anything built either of those ways is unchanged - Physics: on the builtin solver, a body held against static geometry by another dynamic body is no longer pushed into it. A crate shoved against a wall now stops at the wall instead of creeping inside it or crossing thin geometry entirely; the interior of a stacked pile of dynamic bodies still settles with a small overlap, which is what the matter and planck adapters are for - Physics: on the builtin solver, two overlapping dynamic bodies now separate instead of being shifted the same way together: SAT reports one minimum translation vector per pair, oriented for one side of it, and the other side was applying it unmirrored. **A game that leaned on a colliding dynamic pair drifting along in convoy will see them push apart instead**, while contacts against static geometry are unchanged, since the dynamic body is always the side the vector is oriented for - Mesh: `lit: true` under a `Camera2d` warns once and degrades to unlit instead of silently doing nothing, naming `Camera3d` as the way to light it ([#1576](https://github.com/melonjs/melonJS/issues/1576)) diff --git a/packages/melonjs/skills/melonjs-physics/SKILL.md b/packages/melonjs/skills/melonjs-physics/SKILL.md index aa12f0274..d25ce8d71 100644 --- a/packages/melonjs/skills/melonjs-physics/SKILL.md +++ b/packages/melonjs/skills/melonjs-physics/SKILL.md @@ -250,6 +250,39 @@ resolved from it mints its own shapes. `Body#fromJSON()` and passing an exported list to `addShape()` are the old, built-in-only spelling of this and are deprecated since 20.7.0. +### `anchorPoint` moves the collision shapes with the drawing + +`anchorPoint` says where in its own bounds a renderable sits on its `pos`: +`updateBounds()` translates its bounds by `-width * anchorPoint.x, -height * +anchorPoint.y`, and `preDraw` shifts its rendering by the same amount. A +body's collision shapes are measured from that same frame, so a shape of +`new Rect(0, 0, width, height)` covers the sprite whatever the anchor is, and +a shape's own `pos` still offsets it inside that frame (a small hitbox at the +feet, a hurtbox at the head). + +This was broken until 20.7: shapes were measured from `pos` regardless of the +anchor, so anything not anchored at its top-left corner collided where it was +not drawn, by half a body at the default centred anchor and by a full body +height at the bottom anchor a platformer actor uses. If you compensated by +offsetting your shapes by hand, remove those offsets. `Entity` and Tiled +objects set their own anchor to `(0, 0)`, so nothing built either of those +ways is affected. + +The debug panel is the quickest check: green is the renderable's bounds, red +is what actually collides, and the two now agree. + +### `autoTransform: false` opts out of rotated bounds + +`updateBounds()` only applies `currentTransform` when `autoTransform` is +`true`. Turn it off and the renderable's bounds stay the UNROTATED frame +while the sprite turns inside them, which quietly feeds a wrong box to +frustum culling and to the broadphase. Measured on a 60x20 box at 90 +degrees: `20x60` with `autoTransform` on, `60x20` with it off. + +That flag means "I place myself", so it is a fair contract, but reach for it +only when you really are drawing in your own frame, and remember the bounds +are yours to keep honest then. + ## Move with forces, not by assigning position ```js @@ -583,6 +616,8 @@ use them. `adapter.capabilities` (`constraints`, | `response.depth` / `response.normal` are `undefined` | reading a legacy `onCollision` response — it carries `overlap` / `overlapN` | | `onCollisionEnd` handler throws on `response` | built-in dispatches it with `undefined` | | off-screen bodies stop simulating | built-in gating on `inViewport`; set `alwaysUpdate` | +| the sprite is drawn offset from its hitbox | pre-20.7 `anchorPoint` moved the drawing but not the collision shapes; upgrade, and drop any offsets you added to compensate | +| a rotated renderable reports an unrotated bounding box | `autoTransform: false` opts `updateBounds()` out of the transform | | a body in a pile sinks into the floor (built-in) | fixed in 20.7 for anything with an immovable side; bodies pinned only by other DYNAMIC bodies still overlap by a pixel or two, which is what planck/matter are for | | forces do nothing after switching adapter | magnitude units differ — re-tune, don't reuse numbers | | `body.position` disagrees with `renderable.pos` on matter | matter stores the centroid, melonJS the top-left — the adapter offsets between them | diff --git a/packages/melonjs/src/physics/builtin/builtin-adapter.ts b/packages/melonjs/src/physics/builtin/builtin-adapter.ts index 5e7c1fbcc..924d2f6f1 100644 --- a/packages/melonjs/src/physics/builtin/builtin-adapter.ts +++ b/packages/melonjs/src/physics/builtin/builtin-adapter.ts @@ -572,7 +572,15 @@ export default class BuiltinAdapter implements PhysicsAdapter { return undefined; } const b = body.bounds; - out.setMinMax(b.min.x, b.min.y, b.max.x, b.max.y); + // same frame as `getBodyShapes`: where the body collides, which is + // the renderable's drawn frame rather than raw `pos` + const ax = Number.isFinite(renderable.width) + ? renderable.width * renderable.anchorPoint.x + : 0; + const ay = Number.isFinite(renderable.height) + ? renderable.height * renderable.anchorPoint.y + : 0; + out.setMinMax(b.min.x - ax, b.min.y - ay, b.max.x - ax, b.max.y - ay); return out; } @@ -587,7 +595,33 @@ export default class BuiltinAdapter implements PhysicsAdapter { if (!body || !this.bodies.has(body)) { return []; } - return body.shapes as BodyShape[]; + const src = body.shapes as BodyShape[]; + // Reported in the frame the body actually collides in. The stored + // shapes stay exactly as authored — the anchor offset is applied by + // the narrowphase, not baked into them, so that the rotation pivot, + // `applyForce`'s lever arm and the shape pools all keep reading the + // coordinates the caller wrote. Reporting those raw would put the + // debug overlay half a body away from where the collision happens. + const ax = Number.isFinite(renderable.width) + ? renderable.width * renderable.anchorPoint.x + : 0; + const ay = Number.isFinite(renderable.height) + ? renderable.height * renderable.anchorPoint.y + : 0; + if (ax === 0 && ay === 0) { + // the overwhelmingly common case, and the one `Entity` and Tiled + // objects always take: hand back the live list, allocating nothing + return src; + } + // Anything else needs shifted copies, since the live shapes must not + // be mutated. Only debug consumers read this, and only for a body + // whose renderable is not corner-anchored. + const shifted = src.map((shape) => { + const copy = shape.clone(); + copy.pos.set(shape.pos.x - ax, shape.pos.y - ay); + return copy; + }); + return shifted; } isGrounded(renderable: Renderable): boolean { diff --git a/packages/melonjs/src/physics/builtin/sat.js b/packages/melonjs/src/physics/builtin/sat.js index d5155f495..6cf4f5839 100644 --- a/packages/melonjs/src/physics/builtin/sat.js +++ b/packages/melonjs/src/physics/builtin/sat.js @@ -217,6 +217,38 @@ function vornoiRegion(line, point) { } } +/** + * How far a renderable's drawn frame sits from its `pos`, per axis. + * + * `anchorPoint` moves where a renderable DRAWS: `updateBounds()` shifts its + * bounds by `-size * anchorPoint` and `preDraw` shifts its pixels by the same + * amount. Collision shapes have to be measured from that same corner, or the + * hitbox ends up somewhere the artwork is not. + * + * Zero whenever the anchor is (0, 0) — which is what `Entity` and Tiled + * objects set — so every legacy path is bit-for-bit unchanged. + * + * Guarded on `Number.isFinite` exactly as `preDraw` is: a `Container`'s + * default size is `Infinity`, and `Infinity * 0` is `NaN`, which would poison + * every position derived from it. + * @param {Renderable|Container|Entity|Sprite|NineSliceSprite} r - the renderable + * @returns {number} the x offset to subtract + * @ignore + */ +function anchorOffsetX(r) { + return Number.isFinite(r.width) ? r.width * r.anchorPoint.x : 0; +} + +/** + * The y half of {@link anchorOffsetX}. + * @param {Renderable|Container|Entity|Sprite|NineSliceSprite} r - the renderable + * @returns {number} the y offset to subtract + * @ignore + */ +function anchorOffsetY(r) { + return Number.isFinite(r.height) ? r.height * r.anchorPoint.y : 0; +} + /** * Checks whether polygons collide. * @ignore @@ -245,6 +277,11 @@ export function testPolygonPolygon(a, polyA, b, polyB, response) { .copy(b.pos) .add(b.ancestor.getAbsolutePosition()) .add(polyB.pos); + // measured from each renderable's drawn frame, not its `pos` + posA.x -= anchorOffsetX(a); + posA.y -= anchorOffsetY(a); + posB.x -= anchorOffsetX(b); + posB.y -= anchorOffsetY(b); // If any of the edge normals of A is a separating axis, no intersection. for (let i = 0; i < aLen; i++) { @@ -326,6 +363,10 @@ export function testEllipseEllipse(a, ellipseA, b, ellipseB, response) { .sub(a.pos) .sub(a.ancestor.getAbsolutePosition()) .sub(ellipseA.pos); + // B's drawn-frame offset comes off, A's goes back on: this vector is + // B relative to A, so the two corrections have opposite signs + differenceV.x -= anchorOffsetX(b) - anchorOffsetX(a); + differenceV.y -= anchorOffsetY(b) - anchorOffsetY(a); const radiusA = ellipseA.radius; const radiusB = ellipseB.radius; const totalRadius = radiusA + radiusB; @@ -383,6 +424,9 @@ export function testPolygonEllipse(a, polyA, b, ellipseB, response) { .sub(a.pos) .sub(a.ancestor.getAbsolutePosition()) .sub(polyA.pos); + // as above: relative vector, so the two offsets subtract + circlePos.x -= anchorOffsetX(b) - anchorOffsetX(a); + circlePos.y -= anchorOffsetY(b) - anchorOffsetY(a); const radius = ellipseB.radius; const radius2 = radius * radius; const points = polyA.points; diff --git a/packages/melonjs/src/physics/builtin/sat3d.js b/packages/melonjs/src/physics/builtin/sat3d.js index 95b607193..9f31f89f8 100644 --- a/packages/melonjs/src/physics/builtin/sat3d.js +++ b/packages/melonjs/src/physics/builtin/sat3d.js @@ -23,8 +23,16 @@ import { */ function absCenter(renderable, box, out) { const anc = renderable.ancestor.getAbsolutePosition(); - out[0] = renderable.pos.x + anc.x + box.pos.x; - out[1] = renderable.pos.y + anc.y + box.pos.y; + // `anchorPoint` moves the drawn frame in XY; shapes are measured from + // it, so the same offset comes off here. It has no Z term. + const ax = Number.isFinite(renderable.width) + ? renderable.width * renderable.anchorPoint.x + : 0; + const ay = Number.isFinite(renderable.height) + ? renderable.height * renderable.anchorPoint.y + : 0; + out[0] = renderable.pos.x + anc.x + box.pos.x - ax; + out[1] = renderable.pos.y + anc.y + box.pos.y - ay; out[2] = renderable.pos.z + anc.z + box.pos.z; return out; } diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index a9c8436cb..92f3457db 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -80,6 +80,14 @@ export default class Renderable extends Rect { * `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, * `"bottom-left"`, `"bottom-right"` on every renderable that consumes it * (Sprite, Entity, Collectable, ImageLayer, Text, BitmapText, Sprite3d and subclasses). + *
+ * Note: a body's collision shapes are measured from the same frame this + * places the renderable in, so a shape of `Rect(0, 0, width, height)` covers the + * renderable whatever the anchor is (since 20.7; before that shapes were measured + * from `pos` regardless, and a non-corner anchor collided where it was not drawn). + * A shape's own `pos` still offsets it inside that frame. Note also that the + * adapters read the anchor when the body is created, so changing it afterwards + * moves the drawing but not an already-built body. * @type {ObservablePoint} * @default <0.5,0.5> */ diff --git a/packages/melonjs/tests/builtin-adapter-adversarial.spec.js b/packages/melonjs/tests/builtin-adapter-adversarial.spec.js index 439e96456..ac827f849 100644 --- a/packages/melonjs/tests/builtin-adapter-adversarial.spec.js +++ b/packages/melonjs/tests/builtin-adapter-adversarial.spec.js @@ -708,6 +708,12 @@ describe("Physics : BuiltinAdapter (adversarial)", () => { // uses). const addDynamic = (x, y, def) => { const r = new Renderable(x, y, 32, 32); + // These cases do their arithmetic in `pos + size` terms, which is + // the shape frame only for a renderable anchored at its corner. + // `Renderable` defaults to (0.5, 0.5), and shapes now follow the + // anchor as the drawing does, so the corner anchor is stated + // rather than assumed. + r.anchorPoint.set(0, 0); r.alwaysUpdate = true; r.bodyDef = { type: "dynamic", shapes: [new Rect(0, 0, 32, 32)], ...def }; world.addChild(r); @@ -715,6 +721,7 @@ describe("Physics : BuiltinAdapter (adversarial)", () => { }; const addStatic = (x, y, w, h, def) => { const r = new Renderable(x, y, w, h); + r.anchorPoint.set(0, 0); r.alwaysUpdate = true; r.bodyDef = { type: "static", shapes: [new Rect(0, 0, w, h)], ...def }; world.addChild(r); diff --git a/packages/melonjs/tests/builtin-adapter-body.spec.js b/packages/melonjs/tests/builtin-adapter-body.spec.js index 420976207..4c978edf8 100644 --- a/packages/melonjs/tests/builtin-adapter-body.spec.js +++ b/packages/melonjs/tests/builtin-adapter-body.spec.js @@ -87,6 +87,48 @@ describe("Physics : BuiltinAdapter (Body parity with body.spec.js)", () => { expect(body.shapes.length).toEqual(1); }); + // A lopsided convex quad, whose vertex average and area centroid are + // about 8px apart. The builtin solver uses the shape as given and so + // has no anchor to get wrong, but that is worth pinning rather than + // assuming: an adapter that re-centres a polygon on the wrong one of + // those two points shifts the whole outline by the difference, which + // is exactly what matter did (`@melonjs/matter-adapter` 1.4.1). + // + // A rectangle or a triangle cannot catch that, since for both the + // average IS the centroid. Hence the deliberately irregular outline. + it("keeps an irregular Polygon on its authored vertices", () => { + const r = new Renderable(100, 100, 220, 220); + r.anchorPoint.set(0, 0); + const points = [ + new Vector2d(10, 0), + new Vector2d(190, 40), + new Vector2d(150, 260), + new Vector2d(40, 200), + ]; + adapter.addBody(r, { + type: "dynamic", + shapes: [new Polygon(0, 0, points)], + }); + + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const shape of adapter.getBodyShapes(r)) { + for (const p of shape.points) { + minX = Math.min(minX, p.x + shape.pos.x); + minY = Math.min(minY, p.y + shape.pos.y); + maxX = Math.max(maxX, p.x + shape.pos.x); + maxY = Math.max(maxY, p.y + shape.pos.y); + } + } + + expect(minX).toBeCloseTo(10, 1); + expect(minY).toBeCloseTo(0, 1); + expect(maxX).toBeCloseTo(190, 1); + expect(maxY).toBeCloseTo(260, 1); + }); + it("creates a body with multiple shapes (compound body)", () => { const r = new Renderable(0, 0, 64, 64); const body = adapter.addBody(r, { diff --git a/packages/melonjs/tests/builtin-resting.spec.js b/packages/melonjs/tests/builtin-resting.spec.js index 62bc620f4..a4a55536d 100644 --- a/packages/melonjs/tests/builtin-resting.spec.js +++ b/packages/melonjs/tests/builtin-resting.spec.js @@ -206,6 +206,71 @@ describe("built-in solver resting behaviour", () => { * still has the normalized anchor offset applied underneath it, and renders * half its own size away from where it is. Nothing throws. */ +describe("a body whose renderable is not anchored at its corner", () => { + let app; + + beforeAll(async () => { + boot(); + app = new Application(400, 400, { + parent: "screen", + renderer: video.CANVAS, + }); + await app.init(); + }); + + afterEach(() => { + for (const c of app.world.getChildren().slice()) { + app.world.removeChildNow(c); + } + }); + + /** + * `anchorPoint` moves where a renderable DRAWS: `updateBounds()` shifts + * its bounds by `-size * anchorPoint` and `preDraw` shifts its pixels by + * the same amount. The collision shapes have to land in that frame too, + * or the hitbox is somewhere the artwork is not. + * + * Only the `bodyDef` path is in scope. `Entity` sets its own anchor to + * (0, 0), as Tiled objects do, so the legacy route never saw this. + * + * Stated as behaviour, not as shape coordinates: the bottom edge of what + * is drawn comes to rest on the floor, whatever the anchor. + * @param {number} anchor - anchorPoint on both axes + */ + const restingDrawnBottom = (anchor) => { + const floor = new Renderable(0, 360, 400, 40); + floor.anchorPoint.set(0, 0); + floor.isKinematic = false; + floor.bodyDef = { type: "static", shapes: [new Rect(0, 0, 400, 40)] }; + app.world.addChild(floor); + + const crate = new Renderable(100, 100, 44, 44); + crate.anchorPoint.set(anchor, anchor); + crate.isKinematic = false; + crate.bodyDef = { type: "dynamic", shapes: [new Rect(0, 0, 44, 44)] }; + app.world.addChild(crate); + + for (let i = 0; i < 400; i++) { + app.world.update(16); + } + return crate.pos.y + crate.height * (1 - crate.anchorPoint.y); + }; + + it("rests on the floor when anchored at its corner", () => { + // the control: this is the case that has always worked + expect(restingDrawnBottom(0)).toBeCloseTo(360, 6); + }); + + it("rests on the floor when centred on its position", () => { + expect(restingDrawnBottom(0.5)).toBeCloseTo(360, 6); + }); + + it("rests on the floor when anchored at its bottom edge", () => { + // the platformer anchor: feet on the ground + expect(restingDrawnBottom(1)).toBeCloseTo(360, 6); + }); +}); + describe("preDraw anchor offset", () => { let app; diff --git a/packages/planck-adapter/CHANGELOG.md b/packages/planck-adapter/CHANGELOG.md index 71c7089ab..532bd7c07 100644 --- a/packages/planck-adapter/CHANGELOG.md +++ b/packages/planck-adapter/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.6.0 - _unreleased_ + +### Fixed +- A body is built in the frame its renderable draws in. The body origin was taken from `renderable.pos` with no regard for `anchorPoint`, which shifts where a renderable draws by `-size * anchorPoint`, so a sprite anchored anywhere other than its corner collided where it was not drawn: half its size away at the default centred anchor. A shape's own offset is untouched, so a hitbox deliberately placed inside the frame still lands where it was put + ## 1.5.0 - _2026-09-21_ ### Fixed diff --git a/packages/planck-adapter/package.json b/packages/planck-adapter/package.json index a17d0b9cb..060c3c7da 100644 --- a/packages/planck-adapter/package.json +++ b/packages/planck-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@melonjs/planck-adapter", - "version": "1.5.0", + "version": "1.6.0", "description": "melonJS physics adapter for planck.js (Box2D)", "homepage": "https://www.npmjs.com/package/@melonjs/planck-adapter", "type": "module", diff --git a/packages/planck-adapter/src/index.ts b/packages/planck-adapter/src/index.ts index ad5df8241..7e120089e 100644 --- a/packages/planck-adapter/src/index.ts +++ b/packages/planck-adapter/src/index.ts @@ -367,8 +367,22 @@ export class PlanckAdapter implements PhysicsAdapter { // ------------------------------------------------------------------- addBody(renderable: Renderable, def: BodyDefinition): PlanckAdapter.Body { - const baseX = renderable.pos.x; - const baseY = renderable.pos.y; + // The frame the renderable DRAWS in. `anchorPoint` shifts a + // renderable's bounds by `-size * anchorPoint` and `preDraw` shifts + // its pixels by the same amount, and collision shapes are authored in + // that same frame, so the body is built there rather than on `pos`. + // Zero for an anchor of (0, 0) — what `Entity` and Tiled objects set — + // so those paths are unchanged. Guarded on `Number.isFinite` as + // `preDraw` is: a `Container`'s default size is `Infinity`, and + // `Infinity * 0` is `NaN`. + const anchorX = Number.isFinite(renderable.width) + ? renderable.width * renderable.anchorPoint.x + : 0; + const anchorY = Number.isFinite(renderable.height) + ? renderable.height * renderable.anchorPoint.y + : 0; + const baseX = renderable.pos.x - anchorX; + const baseY = renderable.pos.y - anchorY; // Compute the shape centroid in renderable-local pixel space. We // register the body anchor at that centroid (in world meters), so @@ -466,7 +480,15 @@ export class PlanckAdapter implements PhysicsAdapter { this.bodyMap.set(renderable, body); this.renderableMap.set(body, renderable); this.defMap.set(renderable, def); - this.posOffsets.set(renderable, { x: -centroid.x, y: -centroid.y }); + // Maps the body origin back onto `renderable.pos` on the way out of + // the simulation. The body is anchored in the renderable's DRAWN + // frame (`pos - anchor`), so the anchor has to come back on here or + // every sync would pull the sprite to `pos - anchor` and a body that + // never moved would appear to jump on its first step. + this.posOffsets.set(renderable, { + x: anchorX - centroid.x, + y: anchorY - centroid.y, + }); // Helper methods spliced onto the planck body so user code can // write `renderable.body.setVelocity(x, y)` regardless of which diff --git a/packages/planck-adapter/tests/parity.spec.ts b/packages/planck-adapter/tests/parity.spec.ts index 90b1108fe..31b79f8b9 100644 --- a/packages/planck-adapter/tests/parity.spec.ts +++ b/packages/planck-adapter/tests/parity.spec.ts @@ -20,6 +20,7 @@ import { boot, Container, collision, + Polygon, Rect, Renderable, Vector2d, @@ -134,11 +135,153 @@ for (const { name, make, aabbPrecision, expectedCapabilities } of factories) { def: Parameters[1], ) => { r.alwaysUpdate = true; + // Every fixture below does its arithmetic in `pos + size` terms, + // which is the shape frame only for a renderable anchored at its + // corner. `Renderable` defaults to (0.5, 0.5), and shapes now + // follow the anchor as the drawing does, so the corner anchor is + // stated here rather than assumed. The `anchorPoint and collision + // alignment` cases deliberately bypass this helper. + r.anchorPoint.set(0, 0); r.bodyDef = def; world.addChild(r); return r; }; + describe("anchorPoint and collision alignment", () => { + // `anchorPoint` moves where a renderable DRAWS: `updateBounds()` + // shifts its bounds by `-size * anchorPoint`, and `preDraw` shifts + // every pixel it puts on screen by the same amount. A body's + // collision shapes have to end up in that same frame, or the + // hitbox sits where the artwork is not. + // + // `Entity` hid this for years by forcing its own anchor to (0, 0), + // as Tiled objects do, so only the `bodyDef`-on-a-Renderable path + // is exposed, and there the default anchor is (0.5, 0.5). + // + // Pinned as observable behaviour rather than as shape + // coordinates, so it constrains the contract and not the + // implementation: drop a body on a floor, and the bottom edge of + // what is DRAWN must come to rest on the floor top, whatever the + // anchor is. + it("reports its geometry in the frame it collides in", () => { + // The readback has to agree with the narrowphase. The builtin + // applies the anchor offset in the SAT and leaves the stored + // shapes as authored, so reporting those raw would put the + // debug overlay half a body from where the collision is. + const box = new Renderable(300, 300, 40, 40); + box.alwaysUpdate = true; + box.anchorPoint.set(0.5, 0.5); + box.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 40, 40)], + }; + world.addChild(box); + + // the drawn frame, in renderable-local terms, is -20..20 + const aabb = adapter.getBodyAABB?.(box, new Bounds()); + expect(aabb).toBeDefined(); + expect(aabb!.left).toBeCloseTo(-20, 0); + expect(aabb!.top).toBeCloseTo(-20, 0); + expect(aabb!.right).toBeCloseTo(20, 0); + expect(aabb!.bottom).toBeCloseTo(20, 0); + + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + for (const shape of adapter.getBodyShapes(box)) { + const poly = shape as Polygon; + for (const p of poly.points) { + minX = Math.min(minX, p.x + poly.pos.x); + maxX = Math.max(maxX, p.x + poly.pos.x); + } + } + expect(minX).toBeCloseTo(-20, 0); + expect(maxX).toBeCloseTo(20, 0); + }); + + for (const anchor of [0, 0.5, 1]) { + it(`rests where it draws with anchorPoint ${anchor}`, () => { + const floorY = 200; + const floor = new Renderable(0, floorY, 800, 20); + floor.alwaysUpdate = true; + floor.anchorPoint.set(0, 0); + floor.bodyDef = { + type: "static", + shapes: [new Rect(0, 0, 800, 20)], + }; + world.addChild(floor); + + const box = new Renderable(100, 120, 32, 32); + box.alwaysUpdate = true; + box.anchorPoint.set(anchor, anchor); + box.bodyDef = { + type: "dynamic", + shapes: [new Rect(0, 0, 32, 32)], + }; + world.addChild(box); + + for (let i = 0; i < 180; i++) { + world.update(16); + } + + // the bottom of the drawn frame: `pos` plus whatever part + // of the height sits below it for this anchor + const drawnBottom = box.pos.y + box.height * (1 - box.anchorPoint.y); + expect(Math.abs(drawnBottom - floorY)).toBeLessThan(2); + // and the renderable's own bounds agree with that frame + expect(box.getBounds().bottom).toBeCloseTo(drawnBottom, 1); + }); + } + }); + + describe("polygon placement", () => { + /** + * A lopsided convex quad. The arithmetic mean of its vertices and + * its area centroid sit about 8px apart, and an adapter that + * confuses the two shifts the whole outline by that difference. + * + * Deliberately neither a rectangle nor a triangle: for both of + * those the mean IS the centroid, so they cannot tell a correct + * implementation from a broken one. Every simple fixture in these + * suites was one or the other, which is how matter shipped this + * drifting for real (`@melonjs/matter-adapter` 1.4.1), visible + * only once a body had artwork behind it to be measured against. + * + * Whatever each engine uses internally as the body anchor, the + * geometry it reports has to be the geometry that was authored. + */ + it("reports an irregular polygon on its authored vertices", () => { + const points: [Vector2d, Vector2d, Vector2d, ...Vector2d[]] = [ + new Vector2d(10, 0), + new Vector2d(190, 40), + new Vector2d(150, 260), + new Vector2d(40, 200), + ]; + const r = addToWorld(new Renderable(100, 100, 220, 220), { + type: "dynamic", + shapes: [new Polygon(0, 0, points)], + }); + + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const shape of adapter.getBodyShapes(r)) { + const poly = shape as Polygon; + for (const p of poly.points) { + minX = Math.min(minX, p.x + poly.pos.x); + minY = Math.min(minY, p.y + poly.pos.y); + maxX = Math.max(maxX, p.x + poly.pos.x); + maxY = Math.max(maxY, p.y + poly.pos.y); + } + } + + expect(minX).toBeCloseTo(10, 1); + expect(minY).toBeCloseTo(0, 1); + expect(maxX).toBeCloseTo(190, 1); + expect(maxY).toBeCloseTo(260, 1); + }); + }); + describe("velocity API", () => { it("velocity round-trips through set/get", () => { const r = addToWorld(new Renderable(100, 100, 32, 32), { @@ -688,6 +831,9 @@ for (const { name, make, aabbPrecision, expectedCapabilities } of factories) { // the right face at x≈240. Ray well above the box must miss. const placeBox = () => { const wall = new Renderable(200, 200, 40, 40); + // corner-anchored: the ray coordinates below are the wall's + // edges in `pos + size` terms + wall.anchorPoint.set(0, 0); wall.alwaysUpdate = true; wall.bodyDef = { type: "static", @@ -739,6 +885,7 @@ for (const { name, make, aabbPrecision, expectedCapabilities } of factories) { describe("queryAABB — portable region query", () => { const placeBox = (x: number, y: number) => { const r = new Renderable(x, y, 40, 40); + r.anchorPoint.set(0, 0); r.alwaysUpdate = true; r.bodyDef = { type: "static", @@ -893,6 +1040,8 @@ for (const { name, make, aabbPrecision, expectedCapabilities } of factories) { collisionMask: number, ) => { const wall = new Renderable(x, 200, 40, 40); + // corner-anchored: the ray below is aimed in `pos + size` terms + wall.anchorPoint.set(0, 0); wall.alwaysUpdate = true; wall.bodyDef = { type: "static", diff --git a/packages/planck-adapter/tests/planck-adapter.spec.ts b/packages/planck-adapter/tests/planck-adapter.spec.ts index ddc91b2cf..982f17102 100644 --- a/packages/planck-adapter/tests/planck-adapter.spec.ts +++ b/packages/planck-adapter/tests/planck-adapter.spec.ts @@ -233,6 +233,8 @@ describe("PlanckAdapter — feature parity with BuiltinAdapter", () => { it("applyForce at off-centre point generates torque", () => { const r = new Renderable(100, 100, 64, 64); + // corner-anchored: this case works in `pos + size` terms + r.anchorPoint.set(0, 0); adapter.addBody(r, { type: "dynamic", shapes: [new Rect(0, 0, 64, 64)], @@ -349,6 +351,8 @@ describe("PlanckAdapter — feature parity with BuiltinAdapter", () => { it("body.applyForce(x, y, px, py) signature works", () => { const r = new Renderable(100, 100, 64, 64); + // corner-anchored: this case works in `pos + size` terms + r.anchorPoint.set(0, 0); const body = adapter.addBody(r, { type: "dynamic", shapes: [new Rect(0, 0, 64, 64)], @@ -632,6 +636,8 @@ describe("PlanckAdapter — feature parity with BuiltinAdapter", () => { describe("getBodyAABB / getBodyShapes", () => { it("returns a local-space AABB for the body", () => { const r = new Renderable(100, 100, 32, 32); + // corner-anchored: this case works in `pos + size` terms + r.anchorPoint.set(0, 0); adapter.addBody(r, { type: "dynamic", shapes: [new Rect(0, 0, 32, 32)], @@ -662,6 +668,8 @@ describe("PlanckAdapter — feature parity with BuiltinAdapter", () => { // The authored definitions remain available on `bodyDef.shapes`. const rect = new Rect(0, 0, 32, 32); const r = new Renderable(100, 100, 32, 32); + // corner-anchored: this case works in `pos + size` terms + r.anchorPoint.set(0, 0); adapter.addBody(r, { type: "dynamic", shapes: [rect] }); const shapes = adapter.getBodyShapes(r); expect(shapes.length).toEqual(1); @@ -762,6 +770,8 @@ describe("PlanckAdapter — unit conversion", () => { it("internal planck position is in meters", () => { const r = new Renderable(100, 100, 32, 32); + // corner-anchored: this case works in `pos + size` terms + r.anchorPoint.set(0, 0); const body = adapter.addBody(r, { type: "dynamic", shapes: [new Rect(0, 0, 32, 32)], diff --git a/packages/planck-adapter/tests/rotated-body-shapes.spec.ts b/packages/planck-adapter/tests/rotated-body-shapes.spec.ts index 33942bb33..75f0aa2f9 100644 --- a/packages/planck-adapter/tests/rotated-body-shapes.spec.ts +++ b/packages/planck-adapter/tests/rotated-body-shapes.spec.ts @@ -53,6 +53,9 @@ describe("PlanckAdapter — getBodyShapes() follows the body's rotation", () => */ const square = (angle: number) => { const r = new Renderable(100, 100, 40, 40); + // corner-anchored: this spec reasons about shape coordinates + // relative to `pos`, and shapes now follow the anchor + r.anchorPoint.set(0, 0); world.addChild(r); adapter.addBody(r, { type: "dynamic", @@ -182,6 +185,9 @@ describe("PlanckAdapter — getBodyShapes() follows the body's rotation", () => const bodyWith = (shapes: Polygon[]) => { const r = new Renderable(200, 200, 100, 240); + // corner-anchored: this spec reasons about shape coordinates + // relative to `pos`, and shapes now follow the anchor + r.anchorPoint.set(0, 0); world.addChild(r); adapter.addBody(r, { type: "static", shapes }); return r; From 4d5ea6baf8f977ad62e8bc6a5597070ac4bf6ec5 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 22 Sep 2026 10:42:37 +0800 Subject: [PATCH 2/6] Examples: give the physics shapes example its artwork The scene drew only the collision outlines each adapter reports, which looks correct by construction: the outline IS the visual, so a body placed wrong draws an outline that is wrong in exactly the same way and nothing seems amiss. That is how the matter polygon placement bug survived in a scene built to show off shape loading. The sprites are generated FROM the shape file by `scripts/generate-physics-shape-sprites.mjs` (`npm run sprites`), in the same coordinate space, so artwork and collision geometry share one source of truth and any gap between them on screen is a real bug. The generator rasterises the outlines itself and encodes the PNGs through `node:zlib`, so it pulls in no new dependency and is byte-for-byte reproducible. `autoTransform` is on now. It was off because the reported shapes come back already posed and `preDraw` would have turned them twice, but with a sprite in the scene that flag also left `updateBounds()` reporting the UNROTATED frame, so the debug panel's green box sat square around a tilted body. The sprite rides `preDraw` like any ordinary renderable and the shapes have the transform undone instead. On the builtin solver the example calls `body.rotate()` rather than `setAngle`, since the SAT never reads `body.angle`: `setAngle` would have turned the artwork alone and left every hitbox upright under it. Also registers the debug panel (S), which is what makes all of this checkable, and matches gravity across the three backends: measured in this scene the defaults spread thirteenfold (builtin ~4450 px/s2, matter ~900, planck ~330), so the piles were never comparable. Matter is the reference. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/examples/package.json | 1 + .../public/assets/physicsShapes/cog.png | Bin 0 -> 527 bytes .../public/assets/physicsShapes/crate.png | Bin 0 -> 343 bytes .../public/assets/physicsShapes/hook.png | Bin 0 -> 533 bytes .../public/assets/physicsShapes/star.png | Bin 0 -> 973 bytes .../generate-physics-shape-sprites.mjs | 278 ++++++++++++++++++ .../physicsShapes/ExamplePhysicsShapes.tsx | 144 +++++++-- 7 files changed, 403 insertions(+), 20 deletions(-) create mode 100644 packages/examples/public/assets/physicsShapes/cog.png create mode 100644 packages/examples/public/assets/physicsShapes/crate.png create mode 100644 packages/examples/public/assets/physicsShapes/hook.png create mode 100644 packages/examples/public/assets/physicsShapes/star.png create mode 100644 packages/examples/scripts/generate-physics-shape-sprites.mjs diff --git a/packages/examples/package.json b/packages/examples/package.json index cc38b7ef7..009b70b8a 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "backends": "node scripts/backends.mjs", + "sprites": "node scripts/generate-physics-shape-sprites.mjs", "test:types": "tsc" }, "dependencies": { diff --git a/packages/examples/public/assets/physicsShapes/cog.png b/packages/examples/public/assets/physicsShapes/cog.png new file mode 100644 index 0000000000000000000000000000000000000000..8842939d0e275379a020f6ec5f3b979ffef46e8b GIT binary patch literal 527 zcmV+q0`UEbP)G}+=|5nzS<6~NmtE=m(ZUKhYxsBWodhR5`$hs2{*`$p!3j?nNpQkbMG~CwN- zc;cA^Cp`W{f)gHlB*6)fK9JyqNA5{*!oznYFyWQZEs1Hvt2eDwebl3aH(hDQ*RG+M zC0ea2)uRK~nl$5U*HUGV&i_5${wpEczR&aWNBb^?k~Y5icsHWG7rJPRS7qAB8t=5{ zTvu)Hydpz*WA}C!4AI){r5FlUTm?f38)9mTcAoWw(C|;kDKvig?g=!0_)Z=gKYaTb z8b5sN2pT`!o`c2@-^@behi_z{@x#~C(D>nNhtT-pt0`#QaJG_!CiEkPEhl=S=U->_ z_@#uQr1))3YY@Q}v-zk{rkbs@xrh+b zM|V0xVzXgQOml?P>ClkPgt}t7L&HvE>+*Dv1m=9aJQX0p37_02!3m%6li-Ap`$%xY z$Gjvs;iDcBobVAh2~PO1n*=9(XpaOYyfo+{!3iI5lHi2*J4mcQ+*#pb*DqT5b1hQr Rikkoc002ovPDHLkV1l&E`9}Z% literal 0 HcmV?d00001 diff --git a/packages/examples/public/assets/physicsShapes/crate.png b/packages/examples/public/assets/physicsShapes/crate.png new file mode 100644 index 0000000000000000000000000000000000000000..39c3c221c610b41e4d05dadf4bd7f7d3333c0654 GIT binary patch literal 343 zcmV-d0jU0oP)tkg@;( literal 0 HcmV?d00001 diff --git a/packages/examples/public/assets/physicsShapes/hook.png b/packages/examples/public/assets/physicsShapes/hook.png new file mode 100644 index 0000000000000000000000000000000000000000..dd604545eae2df22a3b3c636ffd1845818662c0f GIT binary patch literal 533 zcmeAS@N?(olHy`uVBq!ia0vp^UO;Td!3HD~4ERqoFfiWnba4!+xb^ndZ7tV8kz<8V zr>S^?ppvJMrxTFDIg!0M&3gXLoagB`o`1e~{@~RD~p@rbEmaW;uX)eMxER5Fo%2Vl=?Oc@hrvfJcW8!YTj`^idnJq zj`ES60h@2ScN8y7+r$sl;G512B2$?_WZFBR=2<81IR2~BI3M@xrNsFkKmjT5--RI*iK`Jq7pSfh)EX(?IG5=SN~ywD9Zhq3{kY@SU=Px zA#130#cu>4ihMURLll{~@h8O99{8^tm-CzFs=(d!pQ|VCx%E|YmEEggF}D@FI{YIg z7G^0|pE|@5Dtt2(DD>(?-zEd6(AM%Pt%9qJ_N>+E2@rj#o0{07#hT%25QbF(OJD^Vm!J|jwCku2w`` zIF}O6)ryFlg3qB%LO?isEtY@wcLNsA*2<&{w?e}G*?@&xX=MaW!9V-GL7Rk_aL2T0 zoOs7tp?J|u5^c+03{=#aTB&WOu>2W|Hi(?#o$ZF>4y2x_}_nk-x&iS|hfEL>lk!s$mT z8f_B6qOC}{BPl51tmc}+;wTf1GzrEO7Ir2YX%fT~79Vm^8+fM;(Z~6 zpJ5n&JZ3%+7ET1ng8%Pbp=6y$%zEU75B|{0F#$or-j+()^IvO}6mhHV3x88<5(HbN zZgpJObekZQppdR`T^ILmkR0aEUpERa2O3$-!imIt)d(ojoV-pOU2D%@)|$ev=W*Uc z#gzGrT0)t?UeRYflbFWI^IAcffLYc7*$6+E>L4JsHTyehU<~U7NW5oCLWySY`nxnT zjgzNJQF)D)F^$<1B`D1VGUh$fdg{*}gM@C41=Em?#fuKhGYb^-p%!EpJ5xuP9fCla z`UEx&l=r5Gl|tpSZcBv+n+7VzOdnN3SjTxKpU@*>0)!F!^r!V5cZu zcO^xc$^A-DwwI1X+vHv)gyXB5-zIk}LCHMFLdvqhDz}OB% z;m}q2U_X|!+6~UNWh9U9K@% { + if (Array.isArray(node)) { + for (const item of node) collect(item, out); + return; + } + if (node === null || typeof node !== "object") return; + + // `shape`: a flat [x, y, x, y, ...] outline + if (Array.isArray(node.shape) && typeof node.shape[0] === "number") { + const pts = []; + for (let i = 0; i + 1 < node.shape.length; i += 2) { + pts.push({ x: node.shape[i], y: node.shape[i + 1] }); + } + if (pts.length >= 3) out.polys.push(pts); + } + // `circle`: {x, y, radius} + if (node.circle && typeof node.circle.radius === "number") { + out.circles.push({ + x: node.circle.x, + y: node.circle.y, + r: node.circle.radius, + }); + } + // `vertices`: an array of outlines, each an array of {x, y} + if (Array.isArray(node.vertices)) { + for (const ring of node.vertices) { + if ( + Array.isArray(ring) && + ring.length >= 3 && + typeof ring[0]?.x === "number" + ) { + out.polys.push(ring.map((p) => ({ x: p.x, y: p.y }))); + } + } + } + for (const [key, value] of Object.entries(node)) { + if (key !== "shape" && key !== "circle" && key !== "vertices") { + collect(value, out); + } + } +}; + +/** + * @param {{x: number, y: number}[]} poly - outline to test against + * @param {number} x - sample point + * @param {number} y - sample point + * @returns {boolean} true when the point is inside (even-odd rule) + */ +const inPoly = (poly, x, y) => { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = poly[i]; + const b = poly[j]; + if ( + a.y > y !== b.y > y && + x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x + ) { + inside = !inside; + } + } + return inside; +}; + +/** @param {string} hex - "#rrggbb" @returns {[number, number, number]} rgb */ +const rgb = (hex) => [ + Number.parseInt(hex.slice(1, 3), 16), + Number.parseInt(hex.slice(3, 5), 16), + Number.parseInt(hex.slice(5, 7), 16), +]; + +const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t)); + +const CRC_TABLE = (() => { + const t = new Int32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c; + } + return t; +})(); + +/** @param {Buffer} buf - bytes to sum @returns {number} CRC-32 */ +const crc32 = (buf) => { + let c = -1; + for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return (c ^ -1) >>> 0; +}; + +/** @param {string} type - chunk name @param {Buffer} data - payload @returns {Buffer} a PNG chunk */ +const chunk = (type, data) => { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +}; + +/** + * @param {number} w - width + * @param {number} h - height + * @param {Buffer} rgba - w*h*4 bytes + * @returns {Buffer} a complete PNG file + */ +const encodePng = (w, h, rgba) => { + const raw = Buffer.alloc((w * 4 + 1) * h); + for (let y = 0; y < h; y++) { + raw[y * (w * 4 + 1)] = 0; // filter: none + rgba.copy(raw, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(w, 0); + ihdr.writeUInt32BE(h, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // colour type: RGBA + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +}; + +const shapes = JSON.parse(readFileSync(join(ASSETS, "shapes.json"), "utf8")); + +for (const [name, colour] of Object.entries(PALETTE)) { + const parts = { polys: [], circles: [] }; + collect(shapes[name], parts); + + // The image spans the body's own local space starting at the origin, so + // image pixel (x, y) IS local (x, y) and the example needs no offset. + let maxX = 0; + let maxY = 0; + for (const poly of parts.polys) { + for (const p of poly) { + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + } + for (const c of parts.circles) { + maxX = Math.max(maxX, c.x + c.r); + maxY = Math.max(maxY, c.y + c.r); + } + const w = Math.ceil(maxX); + const h = Math.ceil(maxY); + + // coverage, by union: a body is one silhouette, not a stack of parts, + // so overlapping pieces must not darken where they meet + const cov = new Float32Array(w * h); + const step = 1 / SS; + for (let py = 0; py < h; py++) { + for (let px = 0; px < w; px++) { + let hits = 0; + for (let sy = 0; sy < SS; sy++) { + for (let sx = 0; sx < SS; sx++) { + const x = px + (sx + 0.5) * step; + const y = py + (sy + 0.5) * step; + let inside = false; + for (const c of parts.circles) { + if ((x - c.x) ** 2 + (y - c.y) ** 2 <= c.r * c.r) { + inside = true; + break; + } + } + if (!inside) { + for (const poly of parts.polys) { + if (inPoly(poly, x, y)) { + inside = true; + break; + } + } + } + if (inside) hits++; + } + } + cov[py * w + px] = hits / (SS * SS); + } + } + + // the outline is eroded INWARD, so it can never be clipped by the edge + // of the image even where a shape runs flush to it (the crate does) + const solid = (x, y) => + x >= 0 && y >= 0 && x < w && y < h && cov[y * w + x] > 0.5; + const base = rgb(colour); + const dark = mix(base, [0, 0, 0], 0.55); + const light = mix(base, [255, 255, 255], 0.4); + + const rgba = Buffer.alloc(w * h * 4); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const a = cov[y * w + x]; + if (a <= 0) continue; + let edge = false; + for (let dy = -OUTLINE; dy <= OUTLINE && !edge; dy++) { + for (let dx = -OUTLINE; dx <= OUTLINE; dx++) { + if ( + dx * dx + dy * dy <= OUTLINE * OUTLINE && + !solid(x + dx, y + dy) + ) { + edge = true; + break; + } + } + } + // a soft vertical ramp so the flat fill reads as volume + const colourAt = edge + ? dark + : mix(light, base, Math.min(1, y / Math.max(1, h))); + const i = (y * w + x) * 4; + rgba[i] = colourAt[0]; + rgba[i + 1] = colourAt[1]; + rgba[i + 2] = colourAt[2]; + rgba[i + 3] = Math.round(a * 255); + } + } + + const png = encodePng(w, h, rgba); + const file = join(ASSETS, `${name}.png`); + writeFileSync(file, png); + console.log( + `${name.padEnd(6)} ${String(w).padStart(3)}x${String(h).padStart(3)} ` + + `${parts.polys.length} poly, ${parts.circles.length} circle ` + + `${String(png.length).padStart(5)} bytes ` + + `sha=${createHash("sha256").update(png).digest("hex").slice(0, 8)}`, + ); +} diff --git a/packages/examples/src/examples/physicsShapes/ExamplePhysicsShapes.tsx b/packages/examples/src/examples/physicsShapes/ExamplePhysicsShapes.tsx index 0fb9497ef..0a47e0044 100644 --- a/packages/examples/src/examples/physicsShapes/ExamplePhysicsShapes.tsx +++ b/packages/examples/src/examples/physicsShapes/ExamplePhysicsShapes.tsx @@ -10,6 +10,7 @@ * geometry (`world.adapter.getBodyShapes`) rather than from a sprite, so what * is on screen is what collides — including each solver's own approximations. */ +import { DebugPanelPlugin } from "@melonjs/debug-plugin"; import { MatterAdapter } from "@melonjs/matter-adapter"; import { PlanckAdapter } from "@melonjs/planck-adapter"; import { @@ -18,6 +19,8 @@ import { type CanvasRenderer, game, loader, + Matrix3d, + plugin, Rect, Renderable, RoundRect, @@ -54,7 +57,7 @@ const BACKENDS = [ { id: "builtin", label: "built-in", - note: "arcade SAT, one push-out per contact per frame: hitboxes stay upright and stacked bodies rest overlapping rather than settling. Use matter or planck for rigid-body stacking", + note: "arcade SAT. The outlines are tilted here because the example calls `body.rotate()`, which turns the shape points themselves: `setAngle` would turn the sprite alone, since the SAT never reads `body.angle`. So rotation is a one-time bake, not a tracked angle, and nothing tumbles on impact. Stacked bodies also rest overlapping. Use matter or planck to rotate and stack for real", }, { id: "matter", @@ -80,6 +83,27 @@ const BODIES = [ { id: "crate", colour: "#118ab2", note: "1 convex piece" }, ] as const; +/** + * The artwork is generated FROM the shape file, by + * `scripts/generate-physics-shape-sprites.mjs`, in the same coordinate + * space: image pixel (0, 0) is the body's local origin. So the sprite and + * the collision outline are drawn from one source of truth, and any gap + * between them on screen is a real bug rather than an authoring mismatch. + * + * That gap is the whole reason the art exists. Drawing only the outlines + * each adapter reports looks correct by construction, because the outline + * IS the visual: a body placed slightly wrong draws a slightly wrong + * outline and nothing looks amiss. The matter adapter shipped exactly that + * bug (polygon placement, fixed in 1.4.1) and this scene could not show it. + */ +const ART = "#e8eaf2"; + +/** + * Scratch for undoing a renderable's own transform, reused every frame so + * the draw path allocates nothing. + */ +const INVERSE = new Matrix3d(); + const INK = "#e8eaf2"; const DIM = "#7c84a3"; const PANEL = "#1b1f33"; @@ -98,19 +122,30 @@ const PANEL = "#1b1f33"; */ class ShapeBody extends Renderable { readonly colour: string; - - constructor( - x: number, - y: number, - size: number, - id: string, - colour: string, - still = false, - ) { - super(x, y, size, size); + readonly art: string; + + constructor(x: number, y: number, id: string, colour: string, still = false) { + // sized from the artwork, which is sized from the shape file, so the + // renderable's bounds agree with both rather than being a guess + const image = loader.getImage(id); + super(x, y, image?.width ?? 64, image?.height ?? 64); + this.art = id; this.colour = colour; + // (0, 0) is load-bearing here, not a default. `anchorPoint` shifts a + // renderable's bounds and its drawing, but NOT its collision shapes, + // which are measured from `pos`. Any other anchor therefore draws the + // body offset from the geometry it collides with: measured at anchor + // 0.5, a 44px crate rests with its shapes on the floor at 360 while + // its bounds stop at 338. The physics is identical either way. this.anchorPoint.set(0, 0); - this.autoTransform = false; + // ON, so `preDraw` turns the sprite and, just as importantly, + // `updateBounds()` reports the rotated extent. With it off the engine + // does not know the renderable is turned at all, and the debug + // panel's green bounds stay stuck on the unrotated frame while the + // artwork tilts inside them. The collision shapes below need the + // transform UNDONE rather than never applied, which is what the + // inverse in `draw()` is for. + this.autoTransform = true; this.isKinematic = false; // the shape file, named by the key it was preloaded under, and the // body to read out of it. Identical on all three backends. @@ -127,15 +162,32 @@ class ShapeBody extends Renderable { } override draw(renderer: WebGLRenderer | CanvasRenderer) { + // The sprite just draws at `pos`. `preDraw` has already applied this + // renderable's transform, which the adapter keeps in step with the + // body's angle, so this is exactly what any ordinary game sprite + // gets and it needs no special handling here. + const image = loader.getImage(this.art); + if (image !== null) { + renderer.drawImage(image, this.pos.x, this.pos.y); + } + + // The collision geometry is the odd one out: `getBodyShapes()` + // reports it ALREADY in its simulated pose, so `preDraw`'s transform + // has to be undone or every shape turns twice. + // + // `preDraw` maps a point v to `pos + T(v - pos - anchor)`, so placing + // an already-posed point q takes v = pos + anchor + T⁻¹(q). The + // anchor is (0, 0) here, which leaves the translate and the inverse + // below. const shapes = game.world.adapter.getBodyShapes(this); renderer.save(); renderer.translate(this.pos.x, this.pos.y); - renderer.setColor(`${this.colour}3d`); - for (const shape of shapes) { - renderer.stroke(shape, true); + if (!this.currentTransform.isIdentity()) { + INVERSE.copy(this.currentTransform).invert(); + renderer.transform(INVERSE); } - renderer.setColor(this.colour); - renderer.lineWidth = 2; + renderer.setColor(ART); + renderer.lineWidth = 1; for (const shape of shapes) { renderer.stroke(shape, false); } @@ -242,11 +294,36 @@ class PlayScreen extends Stage { const body = BODIES[this.spawned % BODIES.length]; const column = this.spawned % columns; const x = 230 + column * ((VIEWPORT_W - 560) / (columns - 1)); - const shape = new ShapeBody(x, 290, 80, body.id, body.colour); + const shape = new ShapeBody(x, 290, body.id, body.colour); game.world.addChild(shape, 20); // drop each one already tilted, so it lands off balance and has // somewhere to topple to - shape.body.setAngle?.(((this.spawned * 37) % 360) * (Math.PI / 180)); + const tilt = ((this.spawned * 37) % 360) * (Math.PI / 180); + if (BACKEND === "builtin") { + // `setAngle` on the builtin solver turns the SPRITE only: its + // SAT never reads `body.angle`, so the hitbox would stay + // upright under the artwork. `body.rotate()` is the supported + // way to get rotated collision there, and it genuinely turns + // the shape points. + // + // It bakes the rotation in rather than tracking an angle, so + // the pivot has to be captured BEFORE the call (rotating the + // shapes moves the bounds, and with it their centre), and the + // sprite is turned about that same point by hand. `angle` is + // left at 0 deliberately: non-zero makes the body re-sync its + // own transform every step, from the NEW bounds centre, which + // would pull the sprite off the shapes it just matched. + const pivot = shape.body.getBounds().center; + const px = pivot.x; + const py = pivot.y; + shape.body.rotate(tilt); + shape.currentTransform + .translate(px, py) + .rotate(tilt) + .translate(-px, -py); + } else { + shape.body.setAngle?.(tilt); + } this.spawned++; }, 460); } @@ -269,6 +346,18 @@ class PlayScreen extends Stage { ); }); + // what the two layers on every body mean. Worth stating outright: + // the sprite is generated from the shape file, so wherever the + // outline leaves the artwork, the body is not where it looks. + const legend = new Text(40, VIEWPORT_H - 26, { + font: "monospace", + size: 12, + fillStyle: DIM, + text: "sprite = artwork from the shape file outline = the geometry the adapter reports as colliding [S] debug panel", + }); + legend.isKinematic = true; + game.world.addChild(legend, 100); + const note = new Text(408, 50, { font: "monospace", size: 12, @@ -285,7 +374,7 @@ class PlayScreen extends Stage { BODIES.forEach((body, i) => { const x = 84 + i * 214; game.world.addChild( - new ShapeBody(x, 130, 80, body.id, body.colour, true), + new ShapeBody(x, 130, body.id, body.colour, true), 20, ); const label = new Text(x, 200, { @@ -330,9 +419,24 @@ const createGame = async () => { }); await app.init(); + // The debug panel reads its hitboxes from `adapter.getBodyShapes()`, the + // same call this example draws its outlines with, so the two overlays + // agree by construction. Press S to open it. + plugin.register(DebugPanelPlugin, "debugPanel"); + await loader.preload([ // the shape editor's export, preloaded like any other JSON { name: "shapes", type: "json", src: "assets/physicsShapes/shapes.json" }, + // and the artwork generated from it, one image per body, each keyed + // by the body name so a sprite and its shapes cannot be wired to + // different bodies by mistake + ...BODIES.map((body) => { + return { + name: body.id, + type: "image" as const, + src: `assets/physicsShapes/${body.id}.png`, + }; + }), ]); state.set(state.PLAY, new PlayScreen()); From 407010182a3e1f63caebcbc1f2f1a2cb8d85b548 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 22 Sep 2026 15:08:05 +0800 Subject: [PATCH 3/6] Physics: separate a body from every shape it overlaps, not just the first The narrowphase reports ONE contact per body pair: it returns at the first overlapping shape pair it finds. The extra separation pass for compound bodies re-ran that same first-hit-wins scan, so it kept re-resolving the pair it had already resolved, always with an overlap of zero, and never looked at the siblings. A crate resting across the junction of a Tiled polyline, which arrives as one body carrying one shape per segment, therefore rode the segment it was found against while sinking into its neighbour unmeasured: 0.9px per frame with no vertical velocity, depth growing 3, 5, 7, 10. When the first pair finally separated the neighbour was reached at 18px deep, and a zero-area segment's shortest exit from that far below points through the line, so the crate was ejected downward and fell out of the world. The pass now enumerates the shape pairs itself and corrects each overlapping solid pair, measuring every pair against the position the previous corrections left, and exits on the first clean sweep. `collides()` keeps its semantics exactly: it is public, the adapters and the quadtree spec use it, and making an overlap of zero stop counting as a contact there would have broken `onCollisionActive` and the falling flags for every resting body. Counted on a scene of 20 crates over a 60-segment ground, this costs 2070 SAT tests per step against the old 2760: the early exit more than pays for enumerating, because each of the three old re-scans walked most of the shape list before reaching the resting contact. The detector's private members lose their underscore prefix to match the rest of the API. `_contactId` keeps its own, deliberately: it is not a Detector field but a marker `Body#addShape` stamps onto shape objects. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/CHANGELOG.md | 1 + .../melonjs/src/physics/builtin/detector.js | 462 +++++++++++------- ...uiltin-adapter-collision-contracts.spec.js | 4 +- .../tests/builtin-adapter-stress.spec.js | 14 +- .../tests/builtin-line-collision.spec.js | 206 ++++++++ .../melonjs/tests/detector-end-frame.spec.js | 38 +- .../melonjs/tests/guid-pair-identity.spec.js | 2 +- .../tests/shape-collision-events.spec.js | 14 +- 8 files changed, 526 insertions(+), 215 deletions(-) create mode 100644 packages/melonjs/tests/builtin-line-collision.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 8dcb213a2..b2aa20549 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -20,6 +20,7 @@ - `Polygon#rotate(angle, pivot)` turns the whole shape, not only its points: a polygon carrying its offset in `pos` spun about its own origin and landed elsewhere. A polygon at the origin, which is every caller in the engine until now, is unchanged - Body: rotation pivoted about the wrong point for any renderable away from the world origin, since `body.bounds` is already renderable-local and the pivot subtracted `renderable.pos` from it a second time - `Body.rotate()` no longer throws on a `Box3d` or `Point` shape, neither of which can rotate; such a shape keeps its orientation and still contributes its bounds +- Physics: on the builtin solver, a body is pushed out of every shape of a multi-shape body it overlaps, not only the first one found. A crate resting across the junction of a Tiled polyline, which arrives as one body carrying one shape per segment, sank a little further into the neighbouring segment every frame and eventually fell through the ground: the narrowphase reports the first overlapping pair it finds, and the extra separation passes kept re-resolving that same already-resolved pair while the sibling went unmeasured. Every pair is enumerated once per pass now, which costs fewer narrowphase tests than the re-scan it replaces - Physics: on the builtin solver, a body collides where its renderable draws. `anchorPoint` shifts a renderable's bounds and its rendering by `-size * anchorPoint`, but collision shapes were measured from `pos` regardless, so a renderable anchored anywhere other than its top-left corner had a hitbox sitting where it was not drawn: half a body off at the default centred anchor, and a full body height at the bottom anchor a platformer actor uses. **A game that compensated for this by offsetting its shapes by hand should remove those offsets.** `Entity` and Tiled objects set their own anchor to (0, 0), so anything built either of those ways is unchanged - Physics: on the builtin solver, a body held against static geometry by another dynamic body is no longer pushed into it. A crate shoved against a wall now stops at the wall instead of creeping inside it or crossing thin geometry entirely; the interior of a stacked pile of dynamic bodies still settles with a small overlap, which is what the matter and planck adapters are for - Physics: on the builtin solver, two overlapping dynamic bodies now separate instead of being shifted the same way together: SAT reports one minimum translation vector per pair, oriented for one side of it, and the other side was applying it unmirrored. **A game that leaned on a colliding dynamic pair drifting along in convoy will see them push apart instead**, while contacts against static geometry are unchanged, since the dynamic body is always the side the vector is oriented for diff --git a/packages/melonjs/src/physics/builtin/detector.js b/packages/melonjs/src/physics/builtin/detector.js index 8005dbea9..9fc21c174 100644 --- a/packages/melonjs/src/physics/builtin/detector.js +++ b/packages/melonjs/src/physics/builtin/detector.js @@ -59,6 +59,19 @@ const SAT_LOOKUP = { */ const reportedMissingPairs = new Set(); +/** + * How many separation sweeps a pair of bodies gets when at least one of them + * carries several collision shapes. + * + * One sweep already resolves EVERY overlapping shape pair once, so a second is + * only needed where correcting one contact reveals another — a body wedged + * into the junction between two shapes. The loop stops as soon as a sweep + * finds nothing left to push out of, so this is a ceiling, not a cost. + * @ignore + * @internal + */ +const COMPOUND_SEPARATION_PASSES = 3; + /** * @import Entity from "../../renderable/entity/entity.js"; * @import Container from "../../renderable/container.js"; @@ -90,21 +103,32 @@ class Detector { */ this.response = new ResponseObject(); + /** + * Scratch response for the compound-body separation sweep. Its own + * object rather than `this.response`, because by the time the sweep + * runs `this.response` is the contact that was already reported to the + * collision handlers, and the sweep visits every OTHER overlapping + * shape pair of the same body pair. + * @ignore + * @internal + */ + this.separationResponse = new ResponseObject(); + /** * Pairs (key → [renderableA, renderableB]) that were colliding in - * the previous step. Diffed against `_frameSeen` at end of step + * the previous step. Diffed against `frameSeen` at end of step * to fire `onCollisionEnd` for pairs that just separated. * @ignore * @internal */ - this._activePairs = new Map(); + this.activePairs = new Map(); /** * Pairs seen during the current step. Built up as the per-object * `collisions()` calls run; consumed by `endFrame()`. * @ignore * @internal */ - this._frameSeen = new Map(); + this.frameSeen = new Map(); /** * Two-slot pool of "symmetric view" objects passed to the new * collision lifecycle handlers (`onCollisionStart` / @@ -121,7 +145,7 @@ class Detector { * @ignore * @internal */ - this._symViews = [ + this.symViews = [ { a: null, b: null, @@ -163,28 +187,28 @@ class Detector { /** * Shape-pair contacts that were overlapping in the previous step (#1596). - * Keyed by `_shapePairKey`, diffed against `_frameShapeSeen` in + * Keyed by `shapePairKey`, diffed against `frameShapeSeen` in * `endFrame()` to fire `onShapeCollisionEnd`. Populated only when someone * subscribes, so a game that does not use the feature keeps these empty. * @ignore * @internal */ - this._activeShapePairs = new Map(); + this.activeShapePairs = new Map(); /** * @ignore * @internal */ - this._frameShapeSeen = new Map(); + this.frameShapeSeen = new Map(); /** * Two-slot pool of receiver-symmetric shape-contact views, mirroring - * `_symViews`: `a` is the receiver, `b` the partner, and `shapeA` / + * `symViews`: `a` is the receiver, `b` the partner, and `shapeA` / * `indexShapeA` are always the RECEIVER's shape. Two slots because both * sides may be live at once when a handler mutates the world. * @ignore * @internal */ - this._shapeViews = [ + this.shapeViews = [ { a: null, b: null, @@ -228,25 +252,18 @@ class Detector { * @ignore * @internal */ - this._contactObjA = null; + this.contactObjA = null; /** * @ignore * @internal */ - this._contactObjB = null; + this.contactObjB = null; /** * @ignore * @internal */ - this._onShapeContact = (shapeA, indexA, shapeB, indexB, isTrigger, res) => { - this._dispatchShapeContact( - shapeA, - indexA, - shapeB, - indexB, - isTrigger, - res, - ); + this.onShapeContact = (shapeA, indexA, shapeB, indexB, isTrigger, res) => { + this.dispatchShapeContact(shapeA, indexA, shapeB, indexB, isTrigger, res); }; } @@ -260,8 +277,8 @@ class Detector { * @ignore * @internal */ - _fillSymView(slot, satResponse, flip) { - const view = this._symViews[slot]; + fillSymView(slot, satResponse, flip) { + const view = this.symViews[slot]; const oN = satResponse.overlapN; const oV = satResponse.overlapV; const oNZ = satResponse.overlapNZ; @@ -312,8 +329,8 @@ class Detector { * @internal */ beginFrame() { - this._frameSeen.clear(); - this._frameShapeSeen.clear(); + this.frameSeen.clear(); + this.frameShapeSeen.clear(); } /** @@ -325,8 +342,8 @@ class Detector { * @internal */ endFrame() { - for (const [key, pair] of this._activePairs) { - if (this._frameSeen.has(key)) { + for (const [key, pair] of this.activePairs) { + if (this.frameSeen.has(key)) { continue; } const [a, b] = pair; @@ -354,8 +371,8 @@ class Detector { // per-renderable diff above, including the detached-object rule: a // contact whose objects have both left the world is dropped silently // rather than dispatched into torn-down handlers. - for (const [key, entry] of this._activeShapePairs) { - if (this._frameShapeSeen.has(key)) { + for (const [key, entry] of this.activeShapePairs) { + if (this.frameShapeSeen.has(key)) { continue; } const [a, b, shapeA, shapeB] = entry; @@ -368,20 +385,20 @@ class Detector { // truthful to measure. The view carries identity only, which is // why `onCollisionEnd` passes `undefined` for its response too. if (aAttached && typeof a.onShapeCollisionEnd === "function") { - a.onShapeCollisionEnd(this._fillEndedView(0, a, b, shapeA, shapeB), b); + a.onShapeCollisionEnd(this.fillEndedView(0, a, b, shapeA, shapeB), b); } if (bAttached && typeof b.onShapeCollisionEnd === "function") { - b.onShapeCollisionEnd(this._fillEndedView(1, b, a, shapeB, shapeA), a); + b.onShapeCollisionEnd(this.fillEndedView(1, b, a, shapeB, shapeA), a); } } - const prev = this._activePairs; - this._activePairs = this._frameSeen; - this._frameSeen = prev; - const prevShapes = this._activeShapePairs; - this._activeShapePairs = this._frameShapeSeen; - this._frameShapeSeen = prevShapes; - this._frameShapeSeen.clear(); + const prev = this.activePairs; + this.activePairs = this.frameSeen; + this.frameSeen = prev; + const prevShapes = this.activeShapePairs; + this.activeShapePairs = this.frameShapeSeen; + this.frameShapeSeen = prevShapes; + this.frameShapeSeen.clear(); } /** @@ -391,8 +408,8 @@ class Detector { * @ignore * @internal */ - _fillEndedView(slot, receiver, partner, ownShape, otherShape) { - const view = this._shapeViews[slot]; + fillEndedView(slot, receiver, partner, ownShape, otherShape) { + const view = this.shapeViews[slot]; view.a = receiver; view.b = partner; view.shapeA = ownShape; @@ -422,7 +439,7 @@ class Detector { * @ignore * @internal */ - _pairKey(a, b) { + pairKey(a, b) { const ga = a.GUID; const gb = b.GUID; if (ga === undefined || gb === undefined) { @@ -446,7 +463,7 @@ class Detector { * @internal * @returns {boolean} true when the pair was re-measured */ - _retest(bodyA, bodyB, shapeA, shapeB, response) { + retest(bodyA, bodyB, shapeA, shapeB, response) { const indexA = bodyA.shapes.indexOf(shapeA); const indexB = bodyB.shapes.indexOf(shapeB); if (indexA < 0 || indexB < 0) { @@ -472,7 +489,7 @@ class Detector { /** * Stable, order-independent key for one SHAPE pair (#1596). * - * The renderable GUIDs order the pair, exactly as `_pairKey` does, and the + * The renderable GUIDs order the pair, exactly as `pairKey` does, and the * two shape ids MUST swap alongside them: keying `guidA|guidB|idA|idB` * without that swap gives the same physical contact two different keys * depending on which object the outer loop visited first, and the contact @@ -484,7 +501,7 @@ class Detector { * @ignore * @internal */ - _shapePairKey(a, b, shapeA, shapeB) { + shapePairKey(a, b, shapeA, shapeB) { const ga = a.GUID; const gb = b.GUID; const sa = shapeA?._contactId; @@ -517,7 +534,7 @@ class Detector { * @ignore * @internal */ - _wantsShapeContacts(obj) { + wantsShapeContacts(obj) { return ( typeof obj.onShapeCollisionStart === "function" || typeof obj.onShapeCollisionActive === "function" || @@ -527,13 +544,13 @@ class Detector { /** * Populate a pooled shape-contact view, same flip convention as - * `_fillSymView`: `flip=false` builds the view for `response.a`'s side, + * `fillSymView`: `flip=false` builds the view for `response.a`'s side, * `flip=true` for `response.b`'s, so `shapeA` is always the receiver's. * @ignore * @internal */ - _fillShapeView(slot, satResponse, flip, shapeA, shapeB, isTrigger) { - const view = this._shapeViews[slot]; + fillShapeView(slot, satResponse, flip, shapeA, shapeB, isTrigger) { + const view = this.shapeViews[slot]; const oN = satResponse.overlapN; const oV = satResponse.overlapV; const oNZ = satResponse.overlapNZ; @@ -583,18 +600,18 @@ class Detector { * @ignore * @internal */ - _dispatchShapeContact(shapeA, shapeB, isTrigger, response) { - const objA = this._contactObjA; - const objB = this._contactObjB; - const key = this._shapePairKey(objA, objB, shapeA, shapeB); - if (key === undefined || this._frameShapeSeen.has(key)) { + dispatchShapeContact(shapeA, shapeB, isTrigger, response) { + const objA = this.contactObjA; + const objB = this.contactObjB; + const key = this.shapePairKey(objA, objB, shapeA, shapeB); + if (key === undefined || this.frameShapeSeen.has(key)) { // a dynamic-dynamic pair is visited twice per step (once per outer // loop object); the second visit must not re-fire return; } - this._frameShapeSeen.set(key, [objA, objB, shapeA, shapeB]); - const isEntry = !this._activeShapePairs.has(key); - const viewA = this._fillShapeView( + this.frameShapeSeen.set(key, [objA, objB, shapeA, shapeB]); + const isEntry = !this.activeShapePairs.has(key); + const viewA = this.fillShapeView( 0, response, false, @@ -602,7 +619,7 @@ class Detector { shapeB, isTrigger, ); - const viewB = this._fillShapeView( + const viewB = this.fillShapeView( 1, response, true, @@ -636,9 +653,190 @@ class Detector { * @ignore * @internal */ - _clearContactPair() { - this._contactObjA = null; - this._contactObjB = null; + clearContactPair() { + this.contactObjA = null; + this.contactObjB = null; + } + + /** + * Per-shape half of {@link Detector#shouldCollide} (#1590). + * + * A body's `collisionType` / `collisionMask` decide whether a pair of + * bodies reaches the narrowphase at all; these refine that per shape, so a + * shape can narrow what its body allows but never widen it. + * + * `isActive === false` removes a shape from the simulation entirely — no + * test, no contact, no events — without the cost of removing and re-adding + * it. + * @ignore + * @internal + * @returns {boolean} true when this shape pair may produce a contact + */ + shapesShouldCollide(bodyA, shapeA, bodyB, shapeB) { + if (shapeA.isActive === false || shapeB.isActive === false) { + return false; + } + // `??` and not `||`: 0 is a legitimate collision type, so an unset + // field must fall through to the body while a deliberate zero must not. + const typeA = shapeA.collisionType ?? bodyA.collisionType; + const maskA = shapeA.collisionMask ?? bodyA.collisionMask; + const typeB = shapeB.collisionType ?? bodyB.collisionType; + const maskB = shapeB.collisionMask ?? bodyB.collisionMask; + return (maskA & typeB) !== 0 && (typeA & maskB) !== 0; + } + + /** + * Push a pair of bodies apart along EVERY overlapping shape pair, for the + * case where at least one of them carries more than one collision shape. + * + * `collides()` reports ONE contact per body pair: it returns at the first + * overlapping shape pair it finds, and the solver resolves that one. For a + * single-shape body that is the whole truth, but a compound body can be + * penetrated on several of its shapes at once, and the siblings of the + * reported pair are never even tested. + * + * This used to be handled by re-running `collides()` up to three times and + * applying whatever it reported, which cannot work: the re-run scans in the + * same order and returns the SAME pair every time. Once that pair has been + * resolved it is exactly touching, which still counts as a contact, so the + * loop spent all three passes re-resolving a zero overlap while a sibling + * shape was penetrated without limit. A body resting across the junction of + * a polyline drifted into the neighbouring segment at the full rate of its + * horizontal motion, and dropped out of the world when the segment it was + * standing on finally stopped overlapping and the accumulated penetration + * was resolved the short way — straight through. + * + * So the sweep enumerates the shape pairs itself and corrects each one, + * measuring every pair against the position the previous corrections left + * behind. Nothing is accumulated and re-applied, so a pair whose overlap an + * earlier correction already removed contributes nothing, and no contact + * can be counted twice. + * @ignore + * @internal + * @param {Renderable|Container|Entity|Sprite|NineSliceSprite} objA - object A + * @param {Renderable|Container|Entity|Sprite|NineSliceSprite} objB - object B + */ + separateCompound(objA, objB) { + const bodyA = objA.body; + const bodyB = objB.body; + const aIsDynamic = bodyA.isStatic === false; + const bIsDynamic = bodyB.isStatic === false; + if (aIsDynamic === false && bIsDynamic === false) { + return; + } + + // mass ratio for proportional response, constant across the sweep + const bothDynamic = aIsDynamic && bIsDynamic; + const totalMass = bothDynamic ? bodyA.mass + bodyB.mass : 0; + const ratioA = bothDynamic + ? totalMass > 0 + ? bodyB.mass / totalMass + : 0.5 + : 1; + const ratioB = bothDynamic + ? totalMass > 0 + ? bodyA.mass / totalMass + : 0.5 + : 1; + + const response = this.separationResponse; + let passes = COMPOUND_SEPARATION_PASSES; + while (passes-- > 0) { + let separated = false; + for ( + let indexA = bodyA.shapes.length, shapeA; + indexA--, (shapeA = bodyA.shapes[indexA]); + ) { + for ( + let indexB = bodyB.shapes.length, shapeB; + indexB--, (shapeB = bodyB.shapes[indexB]); + ) { + // A trigger shape reports a contact but is never pushed out + // of, and it must not suppress a solid sibling either — so + // it is skipped here rather than ending the sweep. + if (shapeA.isTrigger === true || shapeB.isTrigger === true) { + continue; + } + if (!this.shapesShouldCollide(bodyA, shapeA, bodyB, shapeB)) { + continue; + } + const test = SAT_LOOKUP[shapeA.type + shapeB.type]; + if (test === undefined) { + // already warned about by `collides` + continue; + } + if ( + test.call( + this, + bodyA.ancestor, + shapeA, + bodyB.ancestor, + shapeB, + response.clear(), + ) !== true + ) { + continue; + } + // Exactly touching is a contact but not a penetration, and + // it is what every resolved contact looks like. Correcting + // it would be a no-op write; skipping it is what lets the + // sweep below detect that there is nothing left to do. + if (response.overlap <= 0) { + continue; + } + separated = true; + + const overlap = response.overlapV; + const overlapN = response.overlapN; + // Z half of the same two vectors. Both are 0 for every + // planar shape pair, so the arithmetic below is bit-for-bit + // inert for a 2D body — no branch needed. + const overlapZ = response.overlapZ; + const overlapNZ = response.overlapNZ; + + if (aIsDynamic) { + bodyA.ancestor.pos.set( + bodyA.ancestor.pos.x - overlap.x * ratioA, + bodyA.ancestor.pos.y - overlap.y * ratioA, + bodyA.ancestor.pos.z - overlapZ * ratioA, + ); + // cancel velocity into this surface (no bounce) + const projVel = + bodyA.vel.x * overlapN.x + + bodyA.vel.y * overlapN.y + + bodyA.velZ * overlapNZ; + if (projVel > 0) { + bodyA.vel.x -= projVel * ratioA * overlapN.x; + bodyA.vel.y -= projVel * ratioA * overlapN.y; + bodyA.velZ -= projVel * ratioA * overlapNZ; + } + } + if (bIsDynamic) { + bodyB.ancestor.pos.set( + bodyB.ancestor.pos.x + overlap.x * ratioB, + bodyB.ancestor.pos.y + overlap.y * ratioB, + bodyB.ancestor.pos.z + overlapZ * ratioB, + ); + const projVel = + bodyB.vel.x * overlapN.x + + bodyB.vel.y * overlapN.y + + bodyB.velZ * overlapNZ; + if (projVel > 0) { + bodyB.vel.x -= projVel * ratioB * overlapN.x; + bodyB.vel.y -= projVel * ratioB * overlapN.y; + bodyB.velZ -= projVel * ratioB * overlapNZ; + } + } + } + } + if (separated === false) { + // every shape pair is either clear or exactly touching + break; + } + // update the cached bounds after the positions changed + boundsA.addBounds(objA.getBounds(), true); + boundsA.addBounds(bodyA.getBounds()); + } } /** @@ -705,25 +903,10 @@ class Detector { let indexB = bodyB.shapes.length, shapeB; indexB--, (shapeB = bodyB.shapes[indexB]); ) { - // Per-shape gate (#1590), before any geometry work. A body's - // `collisionType`/`collisionMask` still decide whether the pair - // reaches this loop at all; these refine it per shape, so a - // shape can narrow what its body allows but never widen it. - // - // `isActive === false` removes a shape from the simulation - // entirely — no test, no contact, no events — without the cost - // of removing and re-adding it. - if (shapeA.isActive === false || shapeB.isActive === false) { - continue; - } - // `??` and not `||`: 0 is a legitimate collision type, so an - // unset field must fall through to the body while a deliberate - // zero must not. - const typeA = shapeA.collisionType ?? bodyA.collisionType; - const maskA = shapeA.collisionMask ?? bodyA.collisionMask; - const typeB = shapeB.collisionType ?? bodyB.collisionType; - const maskB = shapeB.collisionMask ?? bodyB.collisionMask; - if ((maskA & typeB) === 0 || (typeA & maskB) === 0) { + // Per-shape gate (#1590), before any geometry work. Shared with + // the compound separation sweep so the two can never disagree + // about which shape pairs exist. + if (!this.shapesShouldCollide(bodyA, shapeA, bodyB, shapeB)) { continue; } @@ -804,7 +987,7 @@ class Detector { if ( solidShapeA !== null && - this._retest(bodyA, bodyB, solidShapeA, solidShapeB, response) + this.retest(bodyA, bodyB, solidShapeA, solidShapeB, response) ) { return true; } @@ -814,7 +997,7 @@ class Detector { // Re-run the remembered pair to repopulate the response: the loop // above cleared it on every subsequent test, and the handlers still // need a truthful overlap to read. - if (this._retest(bodyA, bodyB, triggerShapeA, triggerShapeB, response)) { + if (this.retest(bodyA, bodyB, triggerShapeA, triggerShapeB, response)) { // consumed at the push-out sites — the contact reports normally // and simply contributes no position correction response.isTriggerContact = true; @@ -853,12 +1036,12 @@ class Detector { // callback stays undefined so `collides()` takes exactly the // path it took before this feature existed. const wantsContacts = - this._wantsShapeContacts(objA) || this._wantsShapeContacts(objB); + this.wantsShapeContacts(objA) || this.wantsShapeContacts(objB); let onContact; if (wantsContacts === true) { - this._contactObjA = objA; - this._contactObjB = objB; - onContact = this._onShapeContact; + this.contactObjA = objA; + this.contactObjB = objB; + onContact = this.onShapeContact; } const didCollide = this.collides( objA.body, @@ -867,7 +1050,7 @@ class Detector { onContact, ); if (wantsContacts === true) { - this._clearContactPair(); + this.clearContactPair(); } if (didCollide) { // we touched something ! @@ -879,8 +1062,8 @@ class Detector { // once per frame to these handlers (regardless of // the SAT detector visiting it twice across the two // outer iterations — once with objA as outer, once - // with objB as outer). `_frameSeen` is the per-frame - // dedup; `_activePairs` carries pair state across + // with objB as outer). `frameSeen` is the per-frame + // dedup; `activePairs` carries pair state across // frames so we can fire onCollisionStart on entry and // onCollisionEnd on separation. // @@ -890,14 +1073,14 @@ class Detector { // of `this`). The legacy `onCollision` dispatch // below uses the unmodified SAT response (fixed // a/b, fixed sign) for 19.4 backward compatibility. - const pairKey = this._pairKey(objA, objB); + const pairKey = this.pairKey(objA, objB); const firstVisitThisFrame = - pairKey !== undefined && !this._frameSeen.has(pairKey); + pairKey !== undefined && !this.frameSeen.has(pairKey); if (firstVisitThisFrame) { - this._frameSeen.set(pairKey, [objA, objB]); - const isEntry = !this._activePairs.has(pairKey); - const viewA = this._fillSymView(0, this.response, false); - const viewB = this._fillSymView(1, this.response, true); + this.frameSeen.set(pairKey, [objA, objB]); + const isEntry = !this.activePairs.has(pairKey); + const viewA = this.fillSymView(0, this.response, false); + const viewB = this.fillSymView(1, this.response, true); if (isEntry) { if (typeof objA.onCollisionStart === "function") { objA.onCollisionStart(viewA, objB); @@ -995,11 +1178,13 @@ class Detector { objB.body.respondToCollision.call(objB.body, this.response); } - // for multi-shape bodies (e.g. polylines), resolve remaining - // overlaps at segment junctions. + // For multi-shape bodies (e.g. polylines), push out of every + // OTHER overlapping shape pair too: `collides` above reported + // only the first one it found, and the siblings it stopped + // short of are the junctions a body falls through. // - // `!eitherSensor` matters as much here as it does above: this - // loop writes positions DIRECTLY (`ancestor.pos.set(...)`) + // `!eitherSensor` matters as much here as it does above: the + // sweep writes positions DIRECTLY (`ancestor.pos.set(...)`) // rather than going through `respondToCollision`, and it used // to gate only on `isStatic`. A sensor with a single shape was // therefore held in place correctly, and the same sensor with @@ -1009,88 +1194,7 @@ class Detector { !eitherSensor && (objA.body.shapes.length > 1 || objB.body.shapes.length > 1) ) { - let extraPasses = 3; - while (extraPasses-- > 0 && this.collides(objA.body, objB.body)) { - // Defence in depth. The `!eitherSensor` gate on this - // loop already covers the common case, since it is - // computed from the first reported pair. But - // `collides` runs again each iteration and may report - // a DIFFERENT pair — one involving a trigger — after - // an earlier pass moved things. Cheap to re-check, - // and the alternative is a trigger being repositioned - // by a later pass having been correctly skipped by - // the first. - const passShapeA = objA.body.shapes[this.response.indexShapeA]; - const passShapeB = objB.body.shapes[this.response.indexShapeB]; - if ( - passShapeA?.isTrigger === true || - passShapeB?.isTrigger === true - ) { - break; - } - const overlap = this.response.overlapV; - const overlapN = this.response.overlapN; - // Z half of the same two vectors. Both are 0 for - // every planar shape pair, so the arithmetic below - // is bit-for-bit inert for a 2D body — no branch - // needed to keep the legacy path unchanged. - const overlapZ = this.response.overlapZ; - const overlapNZ = this.response.overlapNZ; - - // mass ratio for proportional response - const bothDynamic = !objA.body.isStatic && !objB.body.isStatic; - const totalMass = bothDynamic - ? objA.body.mass + objB.body.mass - : 0; - const ratioA = bothDynamic - ? totalMass > 0 - ? objB.body.mass / totalMass - : 0.5 - : 1; - const ratioB = bothDynamic - ? totalMass > 0 - ? objA.body.mass / totalMass - : 0.5 - : 1; - - // correct position - if (objA.body.isStatic === false) { - objA.body.ancestor.pos.set( - objA.body.ancestor.pos.x - overlap.x * ratioA, - objA.body.ancestor.pos.y - overlap.y * ratioA, - objA.body.ancestor.pos.z - overlapZ * ratioA, - ); - // cancel velocity into this surface (no bounce) - const projVel = - objA.body.vel.x * overlapN.x + - objA.body.vel.y * overlapN.y + - objA.body.velZ * overlapNZ; - if (projVel > 0) { - objA.body.vel.x -= projVel * ratioA * overlapN.x; - objA.body.vel.y -= projVel * ratioA * overlapN.y; - objA.body.velZ -= projVel * ratioA * overlapNZ; - } - } - if (objB.body.isStatic === false) { - objB.body.ancestor.pos.set( - objB.body.ancestor.pos.x + overlap.x * ratioB, - objB.body.ancestor.pos.y + overlap.y * ratioB, - objB.body.ancestor.pos.z + overlapZ * ratioB, - ); - const projVel = - objB.body.vel.x * overlapN.x + - objB.body.vel.y * overlapN.y + - objB.body.velZ * overlapNZ; - if (projVel > 0) { - objB.body.vel.x -= projVel * ratioB * overlapN.x; - objB.body.vel.y -= projVel * ratioB * overlapN.y; - objB.body.velZ -= projVel * ratioB * overlapNZ; - } - } - // update bounds after position changed - boundsA.addBounds(objA.getBounds(), true); - boundsA.addBounds(objA.body.getBounds()); - } + this.separateCompound(objA, objB); } } } diff --git a/packages/melonjs/tests/builtin-adapter-collision-contracts.spec.js b/packages/melonjs/tests/builtin-adapter-collision-contracts.spec.js index 34b2da21e..628e59d71 100644 --- a/packages/melonjs/tests/builtin-adapter-collision-contracts.spec.js +++ b/packages/melonjs/tests/builtin-adapter-collision-contracts.spec.js @@ -14,7 +14,7 @@ * response where `response.a === this` and `response.b === other`, * with `response.normal` pointing in the receiver's MTV direction. * Dispatched once per pair per side per frame (dedup'd via - * `_pairKey` / `_frameSeen`). Same contract under every adapter. + * `pairKey` / `frameSeen`). Same contract under every adapter. * * These tests pin both contracts so neither side regresses. */ @@ -339,7 +339,7 @@ describe("Physics : onCollisionActive new contract (19.5+, receiver-symmetric)", it("fires exactly once per pair per side per frame (dedup'd)", () => { world.update(16); // Unlike legacy `onCollision`, the new handler is dedup'd via - // `_pairKey` so the second outer iteration skips the dispatch. + // `pairKey` so the second outer iteration skips the dispatch. // Each side fires exactly once. expect(aCalls.length).toEqual(1); expect(bCalls.length).toEqual(1); diff --git a/packages/melonjs/tests/builtin-adapter-stress.spec.js b/packages/melonjs/tests/builtin-adapter-stress.spec.js index 529134b69..7c5b1b67d 100644 --- a/packages/melonjs/tests/builtin-adapter-stress.spec.js +++ b/packages/melonjs/tests/builtin-adapter-stress.spec.js @@ -26,8 +26,8 @@ const STRESS_CYCLES = 100; const snapshotAdapter = (adapter) => { return { bodies: adapter.bodies.size, - activePairs: adapter.detector._activePairs.size, - frameSeen: adapter.detector._frameSeen.size, + activePairs: adapter.detector.activePairs.size, + frameSeen: adapter.detector.frameSeen.size, }; }; @@ -123,7 +123,7 @@ describe("Physics : BuiltinAdapter (lifecycle leak stress)", () => { it("collision pair maps stay bounded across step()s with no contacts", () => { // Spawn N bodies far apart so they never touch, then step the world - // repeatedly. _activePairs and _frameSeen should stay at zero. + // repeatedly. activePairs and frameSeen should stay at zero. for (let i = 0; i < 20; i++) { const r = new Renderable(i * 200, i * 200, 16, 16); r.alwaysUpdate = true; @@ -136,8 +136,8 @@ describe("Physics : BuiltinAdapter (lifecycle leak stress)", () => { for (let step = 0; step < 20; step++) { adapter.step(16); } - expect(adapter.detector._activePairs.size).toEqual(0); - expect(adapter.detector._frameSeen.size).toEqual(0); + expect(adapter.detector.activePairs.size).toEqual(0); + expect(adapter.detector.frameSeen.size).toEqual(0); }); it("removeBody mid-contact: collision-pair maps drain over one step", () => { @@ -158,8 +158,8 @@ describe("Physics : BuiltinAdapter (lifecycle leak stress)", () => { world.removeChildNow(a, true); adapter.step(16); adapter.step(16); - expect(adapter.detector._activePairs.size).toBeLessThanOrEqual(0); - expect(adapter.detector._frameSeen.size).toEqual(0); + expect(adapter.detector.activePairs.size).toBeLessThanOrEqual(0); + expect(adapter.detector.frameSeen.size).toEqual(0); }); it("Container nested removal: direct children's bodies are cleaned", () => { diff --git a/packages/melonjs/tests/builtin-line-collision.spec.js b/packages/melonjs/tests/builtin-line-collision.spec.js new file mode 100644 index 000000000..65390210b --- /dev/null +++ b/packages/melonjs/tests/builtin-line-collision.spec.js @@ -0,0 +1,206 @@ +/** + * `Line` collision shapes on the built-in solver. + * + * Kept in their own file deliberately. These cases let a body fall a long way + * when the contact is missed, and the built-in world integrates gravity + * against the global `timer.tick`, so running them alongside other physics + * specs perturbs what those measure. + */ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + boot, + Line, + Rect, + Renderable, + Vector2d, + video, +} from "../src/index.js"; + +describe("Line collision shapes", () => { + let app; + + beforeAll(async () => { + boot(); + app = new Application(800, 600, { + parent: "screen", + renderer: video.CANVAS, + }); + await app.init(); + }); + + afterEach(() => { + for (const c of app.world.getChildren().slice()) { + app.world.removeChildNow(c); + } + }); + + /** + * A `Line` is two points and no area, and it is what Tiled emits for every + * polyline, so it is the natural way to author a slope or a strip of + * ground. The SAT resolves a contact against an axis-aligned segment but + * finds none at all against a slanted one, however slowly the body + * approaches it. + * + * The body starts just above the surface on purpose: a zero-thickness + * shape is trivially tunnelled through at speed, which is a separate + * question from whether the segment collides at all. + * @param {Vector2d[]} points - the segment, in ground-local coordinates + * @returns {number} the y the body's bottom edge came to rest at + */ + const settleOnto = (points) => { + const ground = new Renderable(0, 200, 400, 200); + ground.anchorPoint.set(0, 0); + ground.isKinematic = false; + ground.alwaysUpdate = true; + ground.bodyDef = { type: "static", shapes: [new Line(0, 0, points)] }; + app.world.addChild(ground); + + const box = new Renderable(190, 272, 20, 20); + box.anchorPoint.set(0, 0); + box.isKinematic = false; + box.alwaysUpdate = true; + box.bodyDef = { type: "dynamic", shapes: [new Rect(0, 0, 20, 20)] }; + app.world.addChild(box); + + for (let i = 0; i < 400; i++) { + app.world.update(16); + } + return box.pos.y + 20; + }; + + it("rests a body on a horizontal Line", () => { + // the case that already works: segment at local y=100, ground at 200 + expect( + settleOnto([new Vector2d(0, 100), new Vector2d(400, 100)]), + ).toBeCloseTo(300, 0); + }); + + it("rides a sloped Line rather than passing through it", () => { + // The solver resolves a sloped contact correctly, but has no surface + // friction, so the body slides DOWN the incline instead of resting on + // it. Asserting a resting height would therefore be wrong, and reading + // the final `y` would be worse: a body that slid off the end of the + // ramp and one that fell straight through both finish far below and + // are indistinguishable in that number. + // + // What is pinned instead is that it rides the surface: its distance to + // the slope stays small while its x advances. + const ground = new Renderable(0, 200, 400, 200); + ground.anchorPoint.set(0, 0); + ground.isKinematic = false; + ground.alwaysUpdate = true; + ground.bodyDef = { + type: "static", + shapes: [new Line(0, 0, [new Vector2d(0, 0), new Vector2d(400, 200)])], + }; + app.world.addChild(ground); + + const box = new Renderable(100, 180, 20, 20); + box.anchorPoint.set(0, 0); + box.isKinematic = false; + box.alwaysUpdate = true; + box.bodyDef = { type: "dynamic", shapes: [new Rect(0, 0, 20, 20)] }; + app.world.addChild(box); + + let startX = 0; + let worstDepth = 0; + for (let i = 0; i < 40; i++) { + app.world.update(16); + if (i === 10) { + startX = box.pos.x; + } + if (i >= 10) { + // the segment's height at the body's current x + const surfaceY = 200 + (box.pos.x + 10) * 0.5; + worstDepth = Math.max(worstDepth, box.pos.y + 20 - surfaceY); + } + } + // never sank below the surface, and travelled along it + expect(worstDepth).toBeLessThan(4); + expect(box.pos.x).toBeGreaterThan(startX + 50); + }); + + // Regression for the multi-shape push-out. The fault was never in `Line`: + // the same three segments as three separate static bodies held the crate + // perfectly. `collides()` reports ONE contact per body pair — it returns at + // the first overlapping shape pair it finds — and the detector's extra-pass + // loop re-ran exactly that same short-circuiting scan up to three times, so + // it kept re-resolving the pair it had already resolved (an exact touch, + // overlap 0.00) while the crate's other corner sank into the neighbouring + // segment unmeasured. + // + // Measured before the fix, with the siblings probed one at a time: the + // crate rested on the flat arm at overlap 0.00 while its overlap with the + // down-slope grew 0.22, 1.94, 7.70, 13.45, 18.68 over frames 197-227 — the + // full rate of its sideways drift, never corrected. At frame 228 the flat + // arm stopped overlapping, the down-slope was finally the first pair the + // scan reached, and 18.68px of accumulated penetration was resolved the + // short way: straight down through the segment, and the crate fell out. + it("does not sink through a segment it is resting on", () => { + // A polyline valley, as Tiled would give you: down-slope, flat, + // up-slope, all on one body. With no surface friction the crate + // swings from arm to arm, which is expected; what is NOT expected is + // that it sinks THROUGH an arm while barely moving. + const ground = new Renderable(0, 380, 180, 80); + ground.anchorPoint.set(0, 0); + ground.isKinematic = false; + ground.alwaysUpdate = true; + ground.bodyDef = { + type: "static", + shapes: [ + new Line(0, 0, [new Vector2d(0, 10), new Vector2d(60, 60)]), + new Line(0, 0, [new Vector2d(60, 60), new Vector2d(120, 60)]), + new Line(0, 0, [new Vector2d(120, 60), new Vector2d(180, 10)]), + ], + }; + app.world.addChild(ground); + + const crate = new Renderable(14, 120, 28, 28); + crate.anchorPoint.set(0, 0); + crate.isKinematic = false; + crate.alwaysUpdate = true; + crate.bodyDef = { type: "dynamic", shapes: [new Rect(0, 0, 28, 28)] }; + app.world.addChild(crate); + + // world height of the valley under a given x, or undefined off its span + const valleyY = (x) => { + if (x < 0 || x > 180) { + return undefined; + } + if (x < 60) { + return 390 + x * (50 / 60); + } + if (x <= 120) { + return 440; + } + return 440 - (x - 120) * (50 / 60); + }; + + let escapedAt = -1; + let worstDepth = -Infinity; + for (let i = 0; i < 300; i++) { + app.world.update(16); + // the valley floor is world y=440; well below it means it is out + if (escapedAt < 0 && crate.pos.y + 28 > 480) { + escapedAt = i; + } + // How far the crate's bottom edge is below the surface UNDER IT. + // Reading its `y` alone cannot tell riding from falling through: + // both end up far down the screen. Sampled across the footprint + // because the two ends sit over different arms at a junction. + if (i > 60) { + for (const x of [crate.pos.x, crate.pos.x + 14, crate.pos.x + 28]) { + const surfaceY = valleyY(x); + if (surfaceY !== undefined) { + worstDepth = Math.max(worstDepth, crate.pos.y + 28 - surfaceY); + } + } + } + } + expect(escapedAt).toBe(-1); + // it settled ON the geometry, not inside it + expect(worstDepth).toBeLessThan(1); + expect(crate.pos.y + 28).toBeCloseTo(440, 1); + }); +}); diff --git a/packages/melonjs/tests/detector-end-frame.spec.js b/packages/melonjs/tests/detector-end-frame.spec.js index 35bb33a98..467f0d86a 100644 --- a/packages/melonjs/tests/detector-end-frame.spec.js +++ b/packages/melonjs/tests/detector-end-frame.spec.js @@ -70,15 +70,15 @@ describe("Detector.endFrame — onCollisionEnd survivor dispatch", () => { bEndCount++; }; - // seed `_activePairs` directly (skip the SAT integration path) + // seed `activePairs` directly (skip the SAT integration path) const key = `${a.GUID}|${b.GUID}`; - detector._activePairs.set(key, [a, b]); + detector.activePairs.set(key, [a, b]); // frame N+1: pair not seen this frame, and `b` got detached // (level teardown / removeChild) b.ancestor = undefined; - detector._frameSeen.clear(); + detector.frameSeen.clear(); detector.endFrame(); expect(aEndCount).toEqual(1); @@ -99,12 +99,12 @@ describe("Detector.endFrame — onCollisionEnd survivor dispatch", () => { }; const key = `${a.GUID}|${b.GUID}`; - detector._activePairs.set(key, [a, b]); + detector.activePairs.set(key, [a, b]); a.ancestor = undefined; b.ancestor = undefined; - detector._frameSeen.clear(); + detector.frameSeen.clear(); detector.endFrame(); expect(aEndCount).toEqual(0); @@ -125,9 +125,9 @@ describe("Detector.endFrame — onCollisionEnd survivor dispatch", () => { }; const key = `${a.GUID}|${b.GUID}`; - detector._activePairs.set(key, [a, b]); + detector.activePairs.set(key, [a, b]); - detector._frameSeen.clear(); + detector.frameSeen.clear(); detector.endFrame(); expect(aEndCount).toEqual(1); @@ -144,8 +144,8 @@ describe("Detector.endFrame — onCollisionEnd survivor dispatch", () => { }; const key = `${a.GUID}|${b.GUID}`; - detector._activePairs.set(key, [a, b]); - detector._frameSeen.set(key, [a, b]); + detector.activePairs.set(key, [a, b]); + detector.frameSeen.set(key, [a, b]); detector.endFrame(); expect(aEndCount).toEqual(0); @@ -165,12 +165,12 @@ describe("Detector.endFrame — onCollisionEnd survivor dispatch", () => { }; const key = `${a.GUID}|${b.GUID}`; - detector._activePairs.set(key, [a, b]); + detector.activePairs.set(key, [a, b]); // some older code paths set ancestor to null instead of undefined b.ancestor = null; - detector._frameSeen.clear(); + detector.frameSeen.clear(); detector.endFrame(); expect(aEndCount).toEqual(1); @@ -182,20 +182,20 @@ describe("Detector.endFrame — onCollisionEnd survivor dispatch", () => { const b = makeRenderable(2); const key = `${a.GUID}|${b.GUID}`; - detector._activePairs.set(key, [a, b]); + detector.activePairs.set(key, [a, b]); // fresh frame: no pairs seen detector.beginFrame(); detector.endFrame(); - expect(detector._activePairs.size).toEqual(0); + expect(detector.activePairs.size).toEqual(0); // next frame: a different pair seen const c = makeRenderable(3); const key2 = `${a.GUID}|${c.GUID}`; detector.beginFrame(); - detector._frameSeen.set(key2, [a, c]); + detector.frameSeen.set(key2, [a, c]); detector.endFrame(); - expect(detector._activePairs.size).toEqual(1); - expect(detector._activePairs.has(key2)).toEqual(true); + expect(detector.activePairs.size).toEqual(1); + expect(detector.activePairs.has(key2)).toEqual(true); }); // Make sure direct collisions() path also exercises endFrame survivor dispatch @@ -221,14 +221,14 @@ describe("Detector.endFrame — onCollisionEnd survivor dispatch", () => { // no endFrame swap yet — sneak the pair manually as if seen // (the SAT path can be flaky in test envs); just verify the // endFrame path itself. - const key = detector._pairKey(a, b); + const key = detector.pairKey(a, b); if (key) { - detector._activePairs.set(key, [a, b]); + detector.activePairs.set(key, [a, b]); } // frame 2: detach b, run endFrame world.removeChild(b); - detector._frameSeen.clear(); + detector.frameSeen.clear(); detector.endFrame(); expect(aEndCount).toBeGreaterThanOrEqual(1); }); diff --git a/packages/melonjs/tests/guid-pair-identity.spec.js b/packages/melonjs/tests/guid-pair-identity.spec.js index 877890acb..4d0fc97bf 100644 --- a/packages/melonjs/tests/guid-pair-identity.spec.js +++ b/packages/melonjs/tests/guid-pair-identity.spec.js @@ -6,7 +6,7 @@ * call returned the literal string "-1" every time. Every renderable added to a * container shared one GUID. * - * GUID's only consumer is `Detector._pairKey`, so every colliding pair in the + * GUID's only consumer is `Detector.pairKey`, so every colliding pair in the * world collapsed onto one key: the second simultaneous collision was treated * as already-seen that frame and its `onCollisionStart` / `onCollisionActive` / * `onCollisionEnd` never fired. `onCollision` was unaffected, which is how it diff --git a/packages/melonjs/tests/shape-collision-events.spec.js b/packages/melonjs/tests/shape-collision-events.spec.js index 7688efbf4..1dda58be3 100644 --- a/packages/melonjs/tests/shape-collision-events.spec.js +++ b/packages/melonjs/tests/shape-collision-events.spec.js @@ -68,7 +68,7 @@ describe("Physics : shape-level collision events", () => { it("never engages the enumeration machinery when nobody subscribes", () => { // The only behavioural difference inside `collides()` is gated on the // `onContact` callback, and that callback is the sole writer of - // `_frameShapeSeen`. An empty map after a step with a real collision + // `frameShapeSeen`. An empty map after a step with a real collision // therefore proves the loop took the pre-feature path and early // returned on the first solid pair, rather than proving it merely // produced the same answer. @@ -86,8 +86,8 @@ describe("Physics : shape-level collision events", () => { // the collision genuinely happened... expect(resolved).toBeGreaterThan(0); // ...and cost nothing on the new path - expect(world.detector._frameShapeSeen.size).toBe(0); - expect(world.detector._activeShapePairs.size).toBe(0); + expect(world.detector.frameShapeSeen.size).toBe(0); + expect(world.detector.activeShapePairs.size).toBe(0); }); it("engages it as soon as one object subscribes", () => { @@ -99,7 +99,7 @@ describe("Physics : shape-level collision events", () => { }); a.onShapeCollisionActive = () => {}; world.update(16); - expect(world.detector._activeShapePairs.size).toBeGreaterThan(0); + expect(world.detector.activeShapePairs.size).toBeGreaterThan(0); }); it("still delivers the same resolved contact to onCollision", () => { @@ -383,7 +383,7 @@ describe("Physics : shape-level collision events", () => { it("delivers events to a subscriber on a STATIC body", () => { // a static body's own `collisions()` never runs, so its events can only - // arrive through the dynamic partner's visit. The `_wantsShapeContacts` + // arrive through the dynamic partner's visit. The `wantsShapeContacts` // gate checks BOTH objects for exactly this reason. const wall = add(108, [new Rect(0, 0, 32, 32)], { type: "static", @@ -401,8 +401,8 @@ describe("Physics : shape-level collision events", () => { }); it("keeps Z data sign-symmetric between the two receivers", () => { - // `_fillShapeView` negates the Z triple on the flipped side, exactly as - // `_fillSymView` does. A sign error there would ship silently because + // `fillShapeView` negates the Z triple on the flipped side, exactly as + // `fillSymView` does. A sign error there would ship silently because // planar pairs leave every Z field at 0. const a = add(100, [new Rect(0, 0, 32, 32)]); const b = add(108, [new Rect(0, 0, 32, 32)], { From 97deb8c86a2f967476680ffc7c1daad4fdbc6478 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 22 Sep 2026 15:08:26 +0800 Subject: [PATCH 4/6] Physics adapters: segments, rounded rects, and loud refusals Three shapes a game can author were being silently replaced by something else, each in its own way. A `Line` is two points and no area, and it is what Tiled emits for every polyline, so it is the natural way to draw a slope or a strip of ground. matter refuses a zero-area outline and the adapter fell back to the line's AXIS-ALIGNED BOUNDING BOX, turning a diagonal into a solid block. Box2D does not refuse it at all: `b2PolygonShape` substitutes `SetAsBox(1, 1)` for a polygon with fewer than three vertices, so the segment became a one-metre square, 64px at the default scale, sitting wherever the body was. Both now build a thin quad along the segment. It is built from explicit vertices rather than a rectangle with an angle, because an angle belongs to the BODY: `syncFromPhysics` mirrors it onto the renderable's transform, and a sprite would have been turned by the slope of its own ground, with the already-posed shapes drawn turned a second time. A `RoundRect` extends `Polygon`, not `Rect`, and carries 36 points for its corner arcs. Box2D caps a polygon's vertex count and truncates past it without a word: an 80x40 rounded rect was simulated as a 10x34 blob. The cap was measured against planck rather than taken from the header, and it is 12. An outline over it is now sampled down evenly, which keeps the extent and the silhouette, with the worst deviation from the authored outline at 0.7px on a 10px corner radius. One fixture rather than a decomposition on purpose: splitting a convex outline introduces interior seams for a body to catch on. `Point`, `Box3d` and `Sphere` now throw on planck as they already did on matter. Returning null left the body with no collision geometry at all and said nothing, so it simply never collided, and it did so on only one of the two backends. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/matter-adapter/CHANGELOG.md | 1 + packages/matter-adapter/README.md | 2 +- packages/matter-adapter/src/index.ts | 54 +++++++ .../tests/matter-adapter.spec.ts | 151 ++++++++++++++++++ packages/matter-adapter/tests/parity.spec.ts | 142 ++++++++++++++++ packages/planck-adapter/CHANGELOG.md | 3 + packages/planck-adapter/README.md | 6 +- packages/planck-adapter/src/index.ts | 86 +++++++++- packages/planck-adapter/tests/parity.spec.ts | 142 ++++++++++++++++ .../tests/planck-adapter.spec.ts | 151 ++++++++++++++++++ 10 files changed, 730 insertions(+), 8 deletions(-) diff --git a/packages/matter-adapter/CHANGELOG.md b/packages/matter-adapter/CHANGELOG.md index 85901f485..8876b6d74 100644 --- a/packages/matter-adapter/CHANGELOG.md +++ b/packages/matter-adapter/CHANGELOG.md @@ -3,6 +3,7 @@ ## 1.5.0 - _unreleased_ ### Fixed +- A `Line` collides as the segment it is. matter has no segment primitive and refuses a zero-area outline, so a line fell back to its AXIS-ALIGNED BOUNDING BOX: a diagonal became a solid block, and a body rested on the top of that rectangle rather than on the slope. It is simulated as a thin oriented rectangle following the segment now, which is also what `getBodyShapes()` reports. `Line` is what Tiled emits for a polyline, so this is the usual way to author a slope or a strip of ground - A body is built in the frame its renderable draws in. The body origin was taken from `renderable.pos` with no regard for `anchorPoint`, which shifts where a renderable draws by `-size * anchorPoint`, so a sprite anchored anywhere other than its corner collided where it was not drawn: half its size away at the default centred anchor. A shape's own offset is untouched, so a hitbox deliberately placed inside the frame still lands where it was put - Polygon bodies are placed where they were authored. `Bodies.fromVertices` puts a polygon's area centroid at the position it is handed, and the adapter handed it the arithmetic mean of the vertices instead, so every polygon was shifted by the difference between those two points. The two coincide for a rectangle and for any triangle, which is why simple shapes never showed this; the gap grows with how unevenly the vertices are spread, making a traced outline from a shape editor the worst case, far enough off to sit visibly clear of the artwork it was drawn on diff --git a/packages/matter-adapter/README.md b/packages/matter-adapter/README.md index 1b80af5b0..c3f9de711 100644 --- a/packages/matter-adapter/README.md +++ b/packages/matter-adapter/README.md @@ -338,7 +338,7 @@ melonJS body definitions (`BodyDefinition`) are mapped to matter bodies. The key ```ts this.bodyDef = { type: "dynamic" | "static", - shapes: BodyShape[], // Rect, Polygon, Ellipse, etc. + shapes: BodyShape[], // Rect, Polygon, RoundRect, Line, Ellipse (circle approximation) collisionType?: number, collisionMask?: number, maxVelocity?: { x, y }, diff --git a/packages/matter-adapter/src/index.ts b/packages/matter-adapter/src/index.ts index ce83492b7..de3426f4e 100644 --- a/packages/matter-adapter/src/index.ts +++ b/packages/matter-adapter/src/index.ts @@ -15,6 +15,7 @@ import { type BodyShape, type Bounds, Ellipse, + Line, version as melonjsVersion, type PhysicsAdapter, type PhysicsBody, @@ -63,6 +64,21 @@ export interface MatterAdapterOptions { matterEngineOptions?: Matter.IEngineDefinition; } +/** + * How thick a `Line` is simulated as, in pixels. + * + * matter has no segment primitive, so a zero-area outline cannot be built + * into a body at all: `Bodies.fromVertices` rejects the degenerate hull and + * the adapter used to fall back to the line's AXIS-ALIGNED BOUNDING BOX. For + * anything but a horizontal or vertical line that is a solid block rather + * than a surface, so a diagonal became a filled triangle-shaped wall and a + * body rested on the top of its bounding rectangle instead of on the slope. + * + * A thin oriented rectangle is the honest approximation: it follows the + * segment, it is convex, and matter simulates it exactly like any other box. + */ +const LINE_THICKNESS = 2; + /** * melonJS physics adapter wrapping matter-js (https://brm.io/matter-js/). * @@ -1354,6 +1370,44 @@ export class MatterAdapter implements PhysicsAdapter { baseX: number, baseY: number, ): Matter.Body { + // A `Line` is two points and no area. Checked BEFORE `Polygon`, + // which it extends, and before `Rect`, so it never reaches the + // degenerate-hull path. + if (shape instanceof Line) { + const [a, b] = shape.points; + const dx = b.x - a.x; + const dy = b.y - a.y; + const length = Math.hypot(dx, dy); + if (length > 0) { + // Built from explicit VERTICES rather than a rectangle with an + // `angle`: an angle on the body is a rotation of the BODY, and + // `syncFromPhysics` mirrors that onto the renderable's + // transform, so the sprite would be turned by the slope of its + // own ground and the already-rotated shapes would be drawn + // turned a second time. + const nx = (-dy / length) * (LINE_THICKNESS / 2); + const ny = (dx / length) * (LINE_THICKNESS / 2); + const ox = baseX + shape.pos.x; + const oy = baseY + shape.pos.y; + const quad = [ + { x: ox + a.x + nx, y: oy + a.y + ny }, + { x: ox + b.x + nx, y: oy + b.y + ny }, + { x: ox + b.x - nx, y: oy + b.y - ny }, + { x: ox + a.x - nx, y: oy + a.y - ny }, + ]; + const centre = Matter.Vertices.centre(quad); + const body: Matter.Body | undefined = Matter.Bodies.fromVertices( + centre.x, + centre.y, + [quad], + ); + if (body) { + return body; + } + } + // a zero-length segment has no orientation to follow; let it fall + // through to the degenerate handling below rather than inventing one + } if (shape instanceof Rect) { // melonJS Rect: pos is top-left in shape-local space. Matter // rectangles are centered, so we shift by half-width/half-height. diff --git a/packages/matter-adapter/tests/matter-adapter.spec.ts b/packages/matter-adapter/tests/matter-adapter.spec.ts index 835224b7e..378da4305 100644 --- a/packages/matter-adapter/tests/matter-adapter.spec.ts +++ b/packages/matter-adapter/tests/matter-adapter.spec.ts @@ -12,9 +12,14 @@ import * as Matter from "matter-js"; import { Application, + Box3d, boot, + Ellipse, + Line, + Point, Rect, Renderable, + Sphere, Vector2d, video, World, @@ -951,3 +956,149 @@ describe("MatterAdapter — feature parity with BuiltinAdapter", () => { }); }); }); + +describe("MatterAdapter — a Line is a real segment", () => { + let world: World; + let adapter: MatterAdapter; + + beforeAll(async () => { + boot(); + const app = new Application(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + beforeEach(() => { + adapter = new MatterAdapter({ gravity: { x: 0, y: 1 } }); + world = new World(0, 0, 800, 600, adapter); + }); + + it("lands a body on a sloped Line rather than passing through it", () => { + // The engine has no segment primitive, so a `Line` is simulated as a + // thin oriented quad. Before that it was a degenerate outline, and + // each engine substituted something of its own invention: the + // bounding box, or a one-metre square. + // + // A short run on purpose. This is a real rigid body on a 26 degree + // slope, so it lands and then SLIDES, which is correct; the surface + // height is therefore read at wherever the body has got to. + const ground = new Renderable(0, 200, 400, 200); + ground.anchorPoint.set(0, 0); + ground.alwaysUpdate = true; + ground.bodyDef = { + type: "static", + shapes: [new Line(0, 0, [new Vector2d(0, 0), new Vector2d(400, 200)])], + }; + world.addChild(ground); + + const box = new Renderable(190, 272, 20, 20); + box.anchorPoint.set(0, 0); + box.alwaysUpdate = true; + box.bodyDef = { type: "dynamic", shapes: [new Rect(0, 0, 20, 20)] }; + world.addChild(box); + + for (let i = 0; i < 60; i++) { + world.update(16); + } + + // The slope runs from (0,200) to (400,400) in world terms. The + // tolerance is loose on purpose: a SQUARE resting on a 26 degree + // slope touches it at a corner, so its bottom edge sits up to + // `halfWidth * sin(angle)` above the surface before any solver slop. + // What is being pinned is that it landed at all, against the 2000px + // and 70000px it fell when the segment was replaced by something + // else. + const surfaceY = 200 + (box.pos.x + 10) * 0.5; + expect(Math.abs(box.pos.y + 20 - surfaceY)).toBeLessThan(12); + }); +}); + +describe("MatterAdapter — unsupported shape types", () => { + let world: World; + let adapter: MatterAdapter; + + beforeAll(async () => { + boot(); + const app = new Application(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + beforeEach(() => { + adapter = new MatterAdapter({ gravity: { x: 0, y: 1 } }); + world = new World(0, 0, 800, 600, adapter); + }); + + // A 3D shape has no meaning to a 2D solver, and a `Point` has no area to + // build a fixture from. Refusing them LOUDLY is the contract: skipping + // them quietly left a body with no collision geometry at all, so it + // simply never collided, and it did so on only one of the two backends. + it("does not rotate the renderable to the slope of its own Line", () => { + // A segment is simulated as a thin quad. Built as a RECTANGLE WITH AN + // ANGLE, that angle belongs to the BODY, and `syncFromPhysics` mirrors + // a body's angle onto the renderable's transform: the sprite would + // silently be turned by the slope of the ground it is made of, and the + // already-posed shapes drawn turned a second time on top. Built from + // explicit vertices instead, the body stays unrotated. + const r = new Renderable(100, 100, 400, 200); + r.anchorPoint.set(0, 0); + r.alwaysUpdate = true; + r.bodyDef = { + type: "static", + shapes: [new Line(0, 0, [new Vector2d(0, 0), new Vector2d(400, 200)])], + }; + world.addChild(r); + world.update(16); + + expect(adapter.getAngle?.(r) ?? 0).toBeCloseTo(0, 6); + // and the transform the sprite is drawn through turns nothing: a unit + // vector comes back unrotated. Asserted on behaviour rather than with + // `isIdentity()`, which is stricter than the question being asked. + const probe = new Vector2d(1, 0); + r.currentTransform.apply(probe); + expect(probe.x).toBeCloseTo(1, 6); + expect(probe.y).toBeCloseTo(0, 6); + }); + + it("reports an Ellipse as the circle it is simulated as", () => { + // Neither engine has an ellipse primitive, so one is simulated as a + // circle of the average radius. A tall or narrow ellipse is therefore + // a poor fit, which is a documented approximation rather than a bug, + // and `getBodyShapes()` reports the circle so the debug overlay draws + // what actually collides. + const r = new Renderable(100, 100, 80, 40); + r.anchorPoint.set(0, 0); + r.alwaysUpdate = true; + r.bodyDef = { type: "static", shapes: [new Ellipse(40, 20, 80, 40)] }; + world.addChild(r); + + const shapes = adapter.getBodyShapes(r); + expect(shapes.length).toBe(1); + const reported = shapes[0] as Ellipse; + // a circle: both radii equal, and the average of the authored 40 / 20 + expect(reported.radiusV.x).toBeCloseTo(30, 0); + expect(reported.radiusV.y).toBeCloseTo(30, 0); + }); + + it("throws rather than silently building no geometry", () => { + for (const shape of [ + new Point(10, 10), + new Box3d(0, 0, 0, 80, 40, 10), + new Sphere(40, 20, 0, 20), + ]) { + const r = new Renderable(100, 100, 80, 40); + r.anchorPoint.set(0, 0); + r.alwaysUpdate = true; + expect(() => { + r.bodyDef = { type: "static", shapes: [shape] }; + world.addChild(r); + }).toThrow(/unsupported shape type/); + } + }); +}); diff --git a/packages/matter-adapter/tests/parity.spec.ts b/packages/matter-adapter/tests/parity.spec.ts index 609e0205f..0beb699e6 100644 --- a/packages/matter-adapter/tests/parity.spec.ts +++ b/packages/matter-adapter/tests/parity.spec.ts @@ -19,9 +19,11 @@ import { boot, Container, collision, + Line, Polygon, Rect, Renderable, + RoundRect, Vector2d, video, World, @@ -29,6 +31,12 @@ import { import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { MatterAdapter } from "../src/index"; +/** + * `Polygon` takes a tuple of at least three points; the engine's own + * `PolygonVertices` is not exported from the package root. + */ +type PolygonPoints = [Vector2d, Vector2d, Vector2d, ...Vector2d[]]; + interface AdapterFactory { name: string; make(): { @@ -136,6 +144,140 @@ for (const { name, make, rayPrecision, expectedCapabilities } of factories) { return r; }; + describe("shape type coverage", () => { + it("keeps the extent of an outline with many vertices", () => { + // Box2D caps a polygon's vertex count and truncates past it + // without a word. Measured before the fix: a 36-point outline + // collapsed from 70x36 to 47x18. matter has no such cap, so + // this passes there either way and guards the planck path. + const points: Vector2d[] = []; + for (let i = 0; i < 36; i++) { + const a = (i / 36) * Math.PI * 2; + points.push( + new Vector2d(40 + 35 * Math.cos(a), 20 + 18 * Math.sin(a)), + ); + } + const r = addToWorld(new Renderable(100, 100, 80, 40), { + type: "static", + shapes: [new Polygon(0, 0, points as unknown as PolygonPoints)], + }); + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const shape of adapter.getBodyShapes(r)) { + const poly = shape as Polygon; + for (const p of poly.points) { + minX = Math.min(minX, p.x + poly.pos.x); + minY = Math.min(minY, p.y + poly.pos.y); + maxX = Math.max(maxX, p.x + poly.pos.x); + maxY = Math.max(maxY, p.y + poly.pos.y); + } + } + // the authored outline spans 70 x 36 + expect(maxX - minX).toBeCloseTo(70, 0); + expect(maxY - minY).toBeCloseTo(36, 0); + }); + + it("keeps a RoundRect's authored extent", () => { + // `RoundRect` extends `Polygon`, not `Rect`, and carries 36 + // points (4 corners x 9 arc segments). Box2D caps a polygon's + // vertex count and truncates over it without a word: measured + // before the fix, an 80x40 rounded rect came back as 10x34. + const r = addToWorld(new Renderable(100, 100, 80, 40), { + type: "static", + shapes: [new RoundRect(0, 0, 80, 40, 10)], + }); + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const shape of adapter.getBodyShapes(r)) { + const poly = shape as Polygon; + for (const p of poly.points) { + minX = Math.min(minX, p.x + poly.pos.x); + minY = Math.min(minY, p.y + poly.pos.y); + maxX = Math.max(maxX, p.x + poly.pos.x); + maxY = Math.max(maxY, p.y + poly.pos.y); + } + } + expect(maxX - minX).toBeCloseTo(80, 0); + expect(maxY - minY).toBeCloseTo(40, 0); + }); + }); + + describe("Line collision shapes", () => { + // A `Line` is two points and no area, and it is what Tiled emits + // for every polyline, so it is the natural way to author a slope + // or a strip of ground. Every backend accepts one and every + // backend currently does something different and silent with it: + // the builtin finds no contact at all against a segment that is + // not axis aligned, matter substitutes the bounding box, and + // planck substitutes a one-metre square (Box2D's `SetAsBox(1, 1)` + // fallback for a polygon with fewer than three vertices). + // + // Each body starts just above its surface. A zero-thickness shape + // is trivially tunnelled through by a body moving fast enough, + // and no backend claims continuous detection here, so a long drop + // would test CCD rather than whether the segment collides at all. + const settleOnto = (shape: Line, startBottom: number) => { + const ground = new Renderable(0, 200, 400, 200); + ground.anchorPoint.set(0, 0); + ground.alwaysUpdate = true; + ground.bodyDef = { type: "static", shapes: [shape] }; + world.addChild(ground); + + const box = new Renderable(190, startBottom - 20, 20, 20); + box.anchorPoint.set(0, 0); + box.alwaysUpdate = true; + box.bodyDef = { type: "dynamic", shapes: [new Rect(0, 0, 20, 20)] }; + world.addChild(box); + + for (let i = 0; i < 400; i++) { + world.update(16); + } + return { restedAt: box.pos.y + 20, ground }; + }; + + it("rests a body on a horizontal Line", () => { + // segment at local y=100 on ground placed at y=200 + const r = settleOnto( + new Line(0, 0, [new Vector2d(0, 100), new Vector2d(400, 100)]), + 292, + ); + expect(Math.abs(r.restedAt - 300)).toBeLessThan(2); + }); + + it("reports a Line as the segment it was given", () => { + // Every reported vertex has to sit ON the authored segment. + // A bounding box has the same extent as a diagonal segment, + // so comparing extents would not tell the two apart; the + // corners are what give it away. + const r = settleOnto( + new Line(0, 0, [new Vector2d(0, 0), new Vector2d(400, 200)]), + 292, + ); + const distanceToSegment = (px: number, py: number) => { + const t = Math.max( + 0, + Math.min(1, (px * 400 + py * 200) / (400 * 400 + 200 * 200)), + ); + return Math.hypot(px - t * 400, py - t * 200); + }; + let worst = 0; + for (const shape of adapter.getBodyShapes(r.ground)) { + const poly = shape as Polygon; + for (const p of poly.points) { + worst = Math.max( + worst, + distanceToSegment(p.x + poly.pos.x, p.y + poly.pos.y), + ); + } + } + expect(worst).toBeLessThan(2); + }); + }); + describe("anchorPoint and collision alignment", () => { // `anchorPoint` moves where a renderable DRAWS: `updateBounds()` // shifts its bounds by `-size * anchorPoint`, and `preDraw` shifts diff --git a/packages/planck-adapter/CHANGELOG.md b/packages/planck-adapter/CHANGELOG.md index 532bd7c07..07cdbf6a5 100644 --- a/packages/planck-adapter/CHANGELOG.md +++ b/packages/planck-adapter/CHANGELOG.md @@ -3,6 +3,9 @@ ## 1.6.0 - _unreleased_ ### Fixed +- A `RoundRect` collides at the size it was given. It extends `Polygon`, not `Rect`, and carries 36 points for its corner arcs, while Box2D caps a polygon's vertex count and truncates past it without a word: an 80x40 rounded rect was simulated as a 10x34 blob. An outline over the cap is sampled down to it evenly now, keeping the extent and the silhouette, and `getBodyShapes()` reports the decimated outline so the debug overlay draws what is really simulated +- An unsupported shape type throws instead of being skipped in silence. A `Point`, `Box3d` or `Sphere` used to leave the body with no collision geometry at all and say nothing, so it simply never collided, while the same shape threw on the matter adapter. Both refuse the same way now +- A `Line` collides as the segment it is. Box2D polygons need at least three vertices and it does not reject fewer: `b2PolygonShape` silently falls back to `SetAsBox(1, 1)`, so a two-point line became a one-metre square, 64px at the default `pixelsPerMeter`, sitting wherever the body was, with the authored segment discarded and nothing logged. It is simulated as a thin quad along the segment now, which is also what `getBodyShapes()` reports. `Line` is what Tiled emits for a polyline, so this is the usual way to author a slope or a strip of ground - A body is built in the frame its renderable draws in. The body origin was taken from `renderable.pos` with no regard for `anchorPoint`, which shifts where a renderable draws by `-size * anchorPoint`, so a sprite anchored anywhere other than its corner collided where it was not drawn: half its size away at the default centred anchor. A shape's own offset is untouched, so a hitbox deliberately placed inside the frame still lands where it was put ## 1.5.0 - _2026-09-21_ diff --git a/packages/planck-adapter/README.md b/packages/planck-adapter/README.md index d20ad341d..2a20ef332 100644 --- a/packages/planck-adapter/README.md +++ b/packages/planck-adapter/README.md @@ -226,7 +226,7 @@ melonJS body definitions (`BodyDefinition`) are mapped to planck bodies + fixtur ```ts this.bodyDef = { type: "dynamic" | "static" | "kinematic", - shapes: BodyShape[], // Rect, Polygon, Ellipse (Ellipse → circle approximation) + shapes: BodyShape[], // Rect, Polygon, RoundRect, Line, Ellipse (circle approximation) collisionType?: number, collisionMask?: number, maxVelocity?: { x, y }, // emulated via afterStep clamp @@ -258,8 +258,10 @@ body.getFixtureList().setFilterMaskBits(collision.types.ENEMY_OBJECT); ## Behavioural notes when porting from the builtin adapter - **Rotation follows the engine.** `fixedRotation` defaults to `false`, as it does in planck itself, so a rigid body turns when something turns it. If your game code assumes axis-aligned bodies (it reads `pos` and expects an unrotated rect), pass `fixedRotation: true`. Before 1.5.0 this adapter locked rotation unless told otherwise, which inverted the engine it wraps. -- **Polylines (zero-thickness lines) don't translate.** planck — like Box2D — can't make a body from collinear vertices, and polygons must be convex with ≤8 vertices. Replace TMX polylines with thin rectangles at load time, or load and rewrite them post-load. +- **Segments are simulated as thin quads.** Box2D has no segment primitive and does not reject a degenerate polygon, it silently substitutes a one-metre box, so a `Line` (what Tiled emits for a polyline) used to collide as a 64px square wherever the body happened to be. Since 1.6.0 a `Line` is built as a thin quad following the segment, and that is what `getBodyShapes()` reports. - **Ellipses are approximated as circles** with the average radius. For tall/narrow ellipses this is a poor fit; a polygon hull is a better choice when accuracy matters. +- **A polygon is capped at 12 vertices.** Box2D truncates past its cap without a word, and far past it the shape collapses. A `RoundRect` carries 36 points (four corner arcs of nine segments), so since 1.6.0 an outline over the cap is sampled down to it, keeping the extent and the silhouette. `getBodyShapes()` reports the decimated outline, so the debug overlay draws what is really simulated. +- **A shape a 2D solver cannot express throws.** `Point`, `Box3d` and `Sphere` are refused with an error, as they are on the matter adapter. Before 1.6.0 they were skipped in silence, which left the body with no collision geometry at all. - **`maxVelocity` is emulated.** Box2D has no native velocity cap; the adapter clamps each body's velocity after every step. - **`isGrounded` is literal.** It returns `true` whenever any contact pair has the other body's center below this one's. Inside an `onCollisionStart` handler for a stomp, the enemy you just landed on already counts as "ground" — so don't use `!isGrounded` as a proxy for "I was airborne before this contact." Use the body's pre-contact velocity instead (`vel.y > 0` ⇒ falling at impact). - **Forces are real Newtons.** `applyForce(x, y)` is integrated as `force / mass * dt²`. Magnitudes feel ~100× smaller than the legacy SAT adapter — for jumps and dashes use `setVelocity` (immediate) or `applyImpulse` (`Δv = J / m`) instead. diff --git a/packages/planck-adapter/src/index.ts b/packages/planck-adapter/src/index.ts index 7e120089e..044e6efb3 100644 --- a/packages/planck-adapter/src/index.ts +++ b/packages/planck-adapter/src/index.ts @@ -4,6 +4,7 @@ import { type BodyShape, type Bounds, Ellipse, + Line, version as melonjsVersion, type PhysicsAdapter, type PhysicsBody, @@ -71,6 +72,28 @@ export interface PlanckAdapterOptions { positionIterations?: number; } +/** + * How thick a `Line` is simulated as, in pixels. + * + * Box2D polygons need at least three vertices, and it does NOT reject a + * degenerate set: `b2PolygonShape` silently falls back to `SetAsBox(1, 1)`, a + * one-metre square. A two-point `Line` therefore became a 64px box sitting + * wherever the body happened to be, at the default `pixelsPerMeter` of 32, + * with the authored segment discarded entirely and nothing logged. + * + * A thin quad along the segment is a real convex polygon, so it needs no + * special case anywhere else: the readback, the debug overlay and the + * simulation all treat it as the polygon it is. + */ +const LINE_THICKNESS = 2; + +/** + * The most vertices Box2D will keep on one polygon. Measured against planck + * rather than taken from the header: anything above this is truncated without + * a word, and far above it the shape collapses. + */ +const MAX_POLYGON_VERTICES = 12; + /** * melonJS physics adapter wrapping planck.js (https://piqnt.com/planck.js/). * @@ -1443,13 +1466,59 @@ export class PlanckAdapter implements PhysicsAdapter { this.px2m(radius), ); } + // A `Line` is two points and no area. Checked BEFORE `Polygon`, which + // it extends, so it never reaches the degenerate fallback above. + if (shape instanceof Line) { + const [a, b] = shape.points; + const dx = b.x - a.x; + const dy = b.y - a.y; + const length = Math.hypot(dx, dy); + if (length > 0) { + const nx = (-dy / length) * (LINE_THICKNESS / 2); + const ny = (dx / length) * (LINE_THICKNESS / 2); + const quad = [ + { x: a.x + nx, y: a.y + ny }, + { x: b.x + nx, y: b.y + ny }, + { x: b.x - nx, y: b.y - ny }, + { x: a.x - nx, y: a.y - ny }, + ]; + return new planck.Polygon( + quad.map((q) => { + return new planck.Vec2( + this.px2m(shape.pos.x + q.x - centroid.x), + this.px2m(shape.pos.y + q.y - centroid.y), + ); + }), + ); + } + // a zero-length segment has no orientation to follow + } if (shape instanceof Polygon) { // Box2D polygons must be convex with vertices in CCW order // and ≤ 8 vertices. The melonJS Polygon class doesn't enforce // these constraints; we let planck throw if the user passes // something invalid (same failure mode as matter's // `Bodies.fromVertices` on a degenerate hull). - const pts = shape.points.map( + // Box2D caps a polygon's vertex count, and it does NOT report going + // over: measured, a 16-point outline comes back with 12 vertices + // and a 36-point one collapses from 70x36 to 47x18, silently. A + // `RoundRect` is the common way to hit this, since it carries 36 + // points (4 corners x 9 arc segments). + // + // An outline over the cap is therefore sampled down to it, evenly, + // which keeps the extent and the overall silhouette. It is an + // approximation and it is reported as one: `getBodyShapes()` + // returns the decimated outline, so the debug overlay draws what + // is actually simulated. + const source = shape.points; + const step = source.length / MAX_POLYGON_VERTICES; + const kept = + source.length > MAX_POLYGON_VERTICES + ? Array.from({ length: MAX_POLYGON_VERTICES }, (_, i) => { + return source[Math.round(i * step) % source.length]; + }) + : source; + const pts = kept.map( (p) => new planck.Vec2( this.px2m(shape.pos.x + p.x - centroid.x), @@ -1458,10 +1527,17 @@ export class PlanckAdapter implements PhysicsAdapter { ); return new planck.Polygon(pts); } - // Unknown shape — skip silently rather than throw, matching the - // matter-adapter philosophy of "best effort" for compound bodies - // with mixed shape types. - return null; + // Unsupported shape type. Thrown rather than skipped: returning null + // left the body with no collision geometry at all and said nothing, + // so a `Point`, `Box3d` or `Sphere` simply never collided. The matter + // adapter throws for the same input, and a game that silently does + // not collide on one backend and throws on the other is worse than + // one that fails the same way on both. + throw new Error( + `PlanckAdapter: unsupported shape type ${ + (shape as { constructor: { name: string } }).constructor.name + }`, + ); } } diff --git a/packages/planck-adapter/tests/parity.spec.ts b/packages/planck-adapter/tests/parity.spec.ts index 31b79f8b9..d2572a481 100644 --- a/packages/planck-adapter/tests/parity.spec.ts +++ b/packages/planck-adapter/tests/parity.spec.ts @@ -20,9 +20,11 @@ import { boot, Container, collision, + Line, Polygon, Rect, Renderable, + RoundRect, Vector2d, video, World, @@ -30,6 +32,12 @@ import { import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { PlanckAdapter } from "../src/index"; +/** + * `Polygon` takes a tuple of at least three points; the engine's own + * `PolygonVertices` is not exported from the package root. + */ +type PolygonPoints = [Vector2d, Vector2d, Vector2d, ...Vector2d[]]; + interface AdapterFactory { name: string; make(): { @@ -147,6 +155,140 @@ for (const { name, make, aabbPrecision, expectedCapabilities } of factories) { return r; }; + describe("shape type coverage", () => { + it("keeps the extent of an outline with many vertices", () => { + // Box2D caps a polygon's vertex count and truncates past it + // without a word. Measured before the fix: a 36-point outline + // collapsed from 70x36 to 47x18. matter has no such cap, so + // this passes there either way and guards the planck path. + const points: Vector2d[] = []; + for (let i = 0; i < 36; i++) { + const a = (i / 36) * Math.PI * 2; + points.push( + new Vector2d(40 + 35 * Math.cos(a), 20 + 18 * Math.sin(a)), + ); + } + const r = addToWorld(new Renderable(100, 100, 80, 40), { + type: "static", + shapes: [new Polygon(0, 0, points as unknown as PolygonPoints)], + }); + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const shape of adapter.getBodyShapes(r)) { + const poly = shape as Polygon; + for (const p of poly.points) { + minX = Math.min(minX, p.x + poly.pos.x); + minY = Math.min(minY, p.y + poly.pos.y); + maxX = Math.max(maxX, p.x + poly.pos.x); + maxY = Math.max(maxY, p.y + poly.pos.y); + } + } + // the authored outline spans 70 x 36 + expect(maxX - minX).toBeCloseTo(70, 0); + expect(maxY - minY).toBeCloseTo(36, 0); + }); + + it("keeps a RoundRect's authored extent", () => { + // `RoundRect` extends `Polygon`, not `Rect`, and carries 36 + // points (4 corners x 9 arc segments). Box2D caps a polygon's + // vertex count and truncates over it without a word: measured + // before the fix, an 80x40 rounded rect came back as 10x34. + const r = addToWorld(new Renderable(100, 100, 80, 40), { + type: "static", + shapes: [new RoundRect(0, 0, 80, 40, 10)], + }); + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const shape of adapter.getBodyShapes(r)) { + const poly = shape as Polygon; + for (const p of poly.points) { + minX = Math.min(minX, p.x + poly.pos.x); + minY = Math.min(minY, p.y + poly.pos.y); + maxX = Math.max(maxX, p.x + poly.pos.x); + maxY = Math.max(maxY, p.y + poly.pos.y); + } + } + expect(maxX - minX).toBeCloseTo(80, 0); + expect(maxY - minY).toBeCloseTo(40, 0); + }); + }); + + describe("Line collision shapes", () => { + // A `Line` is two points and no area, and it is what Tiled emits + // for every polyline, so it is the natural way to author a slope + // or a strip of ground. Every backend accepts one and every + // backend currently does something different and silent with it: + // the builtin finds no contact at all against a segment that is + // not axis aligned, matter substitutes the bounding box, and + // planck substitutes a one-metre square (Box2D's `SetAsBox(1, 1)` + // fallback for a polygon with fewer than three vertices). + // + // Each body starts just above its surface. A zero-thickness shape + // is trivially tunnelled through by a body moving fast enough, + // and no backend claims continuous detection here, so a long drop + // would test CCD rather than whether the segment collides at all. + const settleOnto = (shape: Line, startBottom: number) => { + const ground = new Renderable(0, 200, 400, 200); + ground.anchorPoint.set(0, 0); + ground.alwaysUpdate = true; + ground.bodyDef = { type: "static", shapes: [shape] }; + world.addChild(ground); + + const box = new Renderable(190, startBottom - 20, 20, 20); + box.anchorPoint.set(0, 0); + box.alwaysUpdate = true; + box.bodyDef = { type: "dynamic", shapes: [new Rect(0, 0, 20, 20)] }; + world.addChild(box); + + for (let i = 0; i < 400; i++) { + world.update(16); + } + return { restedAt: box.pos.y + 20, ground }; + }; + + it("rests a body on a horizontal Line", () => { + // segment at local y=100 on ground placed at y=200 + const r = settleOnto( + new Line(0, 0, [new Vector2d(0, 100), new Vector2d(400, 100)]), + 292, + ); + expect(Math.abs(r.restedAt - 300)).toBeLessThan(2); + }); + + it("reports a Line as the segment it was given", () => { + // Every reported vertex has to sit ON the authored segment. + // A bounding box has the same extent as a diagonal segment, + // so comparing extents would not tell the two apart; the + // corners are what give it away. + const r = settleOnto( + new Line(0, 0, [new Vector2d(0, 0), new Vector2d(400, 200)]), + 292, + ); + const distanceToSegment = (px: number, py: number) => { + const t = Math.max( + 0, + Math.min(1, (px * 400 + py * 200) / (400 * 400 + 200 * 200)), + ); + return Math.hypot(px - t * 400, py - t * 200); + }; + let worst = 0; + for (const shape of adapter.getBodyShapes(r.ground)) { + const poly = shape as Polygon; + for (const p of poly.points) { + worst = Math.max( + worst, + distanceToSegment(p.x + poly.pos.x, p.y + poly.pos.y), + ); + } + } + expect(worst).toBeLessThan(2); + }); + }); + describe("anchorPoint and collision alignment", () => { // `anchorPoint` moves where a renderable DRAWS: `updateBounds()` // shifts its bounds by `-size * anchorPoint`, and `preDraw` shifts diff --git a/packages/planck-adapter/tests/planck-adapter.spec.ts b/packages/planck-adapter/tests/planck-adapter.spec.ts index 982f17102..c22e7f82f 100644 --- a/packages/planck-adapter/tests/planck-adapter.spec.ts +++ b/packages/planck-adapter/tests/planck-adapter.spec.ts @@ -11,11 +11,16 @@ import { Application, + Box3d, boot, collision, + Ellipse, + Line, + Point, Polygon, Rect, Renderable, + Sphere, Vector2d, video, World, @@ -794,3 +799,149 @@ describe("PlanckAdapter — unit conversion", () => { expect(v).toBeInstanceOf(planck.Vec2); }); }); + +describe("PlanckAdapter — a Line is a real segment", () => { + let world: World; + let adapter: PlanckAdapter; + + beforeAll(async () => { + boot(); + const app = new Application(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + beforeEach(() => { + adapter = new PlanckAdapter({ gravity: { x: 0, y: 320 } }); + world = new World(0, 0, 800, 600, adapter); + }); + + it("lands a body on a sloped Line rather than passing through it", () => { + // The engine has no segment primitive, so a `Line` is simulated as a + // thin oriented quad. Before that it was a degenerate outline, and + // each engine substituted something of its own invention: the + // bounding box, or a one-metre square. + // + // A short run on purpose. This is a real rigid body on a 26 degree + // slope, so it lands and then SLIDES, which is correct; the surface + // height is therefore read at wherever the body has got to. + const ground = new Renderable(0, 200, 400, 200); + ground.anchorPoint.set(0, 0); + ground.alwaysUpdate = true; + ground.bodyDef = { + type: "static", + shapes: [new Line(0, 0, [new Vector2d(0, 0), new Vector2d(400, 200)])], + }; + world.addChild(ground); + + const box = new Renderable(190, 272, 20, 20); + box.anchorPoint.set(0, 0); + box.alwaysUpdate = true; + box.bodyDef = { type: "dynamic", shapes: [new Rect(0, 0, 20, 20)] }; + world.addChild(box); + + for (let i = 0; i < 60; i++) { + world.update(16); + } + + // The slope runs from (0,200) to (400,400) in world terms. The + // tolerance is loose on purpose: a SQUARE resting on a 26 degree + // slope touches it at a corner, so its bottom edge sits up to + // `halfWidth * sin(angle)` above the surface before any solver slop. + // What is being pinned is that it landed at all, against the 2000px + // and 70000px it fell when the segment was replaced by something + // else. + const surfaceY = 200 + (box.pos.x + 10) * 0.5; + expect(Math.abs(box.pos.y + 20 - surfaceY)).toBeLessThan(12); + }); +}); + +describe("PlanckAdapter — unsupported shape types", () => { + let world: World; + let adapter: PlanckAdapter; + + beforeAll(async () => { + boot(); + const app = new Application(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + beforeEach(() => { + adapter = new PlanckAdapter({ gravity: { x: 0, y: 320 } }); + world = new World(0, 0, 800, 600, adapter); + }); + + // A 3D shape has no meaning to a 2D solver, and a `Point` has no area to + // build a fixture from. Refusing them LOUDLY is the contract: skipping + // them quietly left a body with no collision geometry at all, so it + // simply never collided, and it did so on only one of the two backends. + it("does not rotate the renderable to the slope of its own Line", () => { + // A segment is simulated as a thin quad. Built as a RECTANGLE WITH AN + // ANGLE, that angle belongs to the BODY, and `syncFromPhysics` mirrors + // a body's angle onto the renderable's transform: the sprite would + // silently be turned by the slope of the ground it is made of, and the + // already-posed shapes drawn turned a second time on top. Built from + // explicit vertices instead, the body stays unrotated. + const r = new Renderable(100, 100, 400, 200); + r.anchorPoint.set(0, 0); + r.alwaysUpdate = true; + r.bodyDef = { + type: "static", + shapes: [new Line(0, 0, [new Vector2d(0, 0), new Vector2d(400, 200)])], + }; + world.addChild(r); + world.update(16); + + expect(adapter.getAngle?.(r) ?? 0).toBeCloseTo(0, 6); + // and the transform the sprite is drawn through turns nothing: a unit + // vector comes back unrotated. Asserted on behaviour rather than with + // `isIdentity()`, which is stricter than the question being asked. + const probe = new Vector2d(1, 0); + r.currentTransform.apply(probe); + expect(probe.x).toBeCloseTo(1, 6); + expect(probe.y).toBeCloseTo(0, 6); + }); + + it("reports an Ellipse as the circle it is simulated as", () => { + // Neither engine has an ellipse primitive, so one is simulated as a + // circle of the average radius. A tall or narrow ellipse is therefore + // a poor fit, which is a documented approximation rather than a bug, + // and `getBodyShapes()` reports the circle so the debug overlay draws + // what actually collides. + const r = new Renderable(100, 100, 80, 40); + r.anchorPoint.set(0, 0); + r.alwaysUpdate = true; + r.bodyDef = { type: "static", shapes: [new Ellipse(40, 20, 80, 40)] }; + world.addChild(r); + + const shapes = adapter.getBodyShapes(r); + expect(shapes.length).toBe(1); + const reported = shapes[0] as Ellipse; + // a circle: both radii equal, and the average of the authored 40 / 20 + expect(reported.radiusV.x).toBeCloseTo(30, 0); + expect(reported.radiusV.y).toBeCloseTo(30, 0); + }); + + it("throws rather than silently building no geometry", () => { + for (const shape of [ + new Point(10, 10), + new Box3d(0, 0, 0, 80, 40, 10), + new Sphere(40, 20, 0, 20), + ]) { + const r = new Renderable(100, 100, 80, 40); + r.anchorPoint.set(0, 0); + r.alwaysUpdate = true; + expect(() => { + r.bodyDef = { type: "static", shapes: [shape] }; + world.addChild(r); + }).toThrow(/unsupported shape type/); + } + }); +}); From ebfb2e35c24f0981ef5046b3fbee79bb471d05d4 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 22 Sep 2026 15:08:27 +0800 Subject: [PATCH 5/6] Examples: a Line Collision example, and the physics skill brought up to date The example draws two layers on every piece of ground: the shape as AUTHORED, and the geometry the adapter reports as colliding. Four cases, a Rect control, a horizontal and a diagonal `Line`, and a polyline of three segments, on a backend switcher with the debug panel registered. The two layers are the point. An overlay drawn from the adapter alone always looks self-consistent, because the outline IS the visual, so a body placed wrong draws an outline that is wrong in exactly the same way. Every placement bug fixed in this line of work hid behind that. The skill gains a shape-support matrix across the three backends, the vertex cap, and a warning not to diagnose a physics failure by reading a body's final `y`: a body that slid off the end of a ramp and one that fell straight through it both finish far below and are indistinguishable in that number. Tracking against the surface at the body's current x is what separates them, and it is what localised two of these bugs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../lineCollision/ExampleLineCollision.tsx | 356 ++++++++++++++++++ packages/examples/src/main.tsx | 13 + .../melonjs/skills/melonjs-physics/SKILL.md | 71 ++++ 3 files changed, 440 insertions(+) create mode 100644 packages/examples/src/examples/lineCollision/ExampleLineCollision.tsx diff --git a/packages/examples/src/examples/lineCollision/ExampleLineCollision.tsx b/packages/examples/src/examples/lineCollision/ExampleLineCollision.tsx new file mode 100644 index 000000000..38a7ef7c2 --- /dev/null +++ b/packages/examples/src/examples/lineCollision/ExampleLineCollision.tsx @@ -0,0 +1,356 @@ +/** + * melonJS — what each physics backend does with a `Line`. + * + * A `Line` is two points and no area, and Tiled emits one for every polyline + * you draw, so it is the natural way to author a slope or a bit of ground. + * Every backend accepts one, and every backend does something different with + * it, none of which is announced. + * + * The scene authors four static pieces of geometry and drops a crate on each. + * Both layers are drawn: what was AUTHORED, dim and dashed, and what the + * adapter REPORTS as its collision geometry, bright. Where the bright shape + * leaves the dim one, the body is not the shape you asked for. + * + * That comparison is the whole point. An overlay drawn from the adapter alone + * always looks self-consistent, because the outline IS the visual; only a + * second, independent reference shows the substitution. + */ +import { DebugPanelPlugin } from "@melonjs/debug-plugin"; +import { MatterAdapter } from "@melonjs/matter-adapter"; +import { PlanckAdapter } from "@melonjs/planck-adapter"; +import { + Application, + BuiltinAdapter, + type CanvasRenderer, + game, + Line, + plugin, + Rect, + Renderable, + RoundRect, + Stage, + state, + Text, + UIBaseElement, + Vector2d, + video, + type WebGLRenderer, +} from "melonjs"; +import { createExampleComponent } from "../utils"; + +const VIEWPORT_W = 960; +const VIEWPORT_H = 640; + +/** Matched across backends, as the physics shapes example does. */ +const GRAVITY_PX_S2 = 900; + +const PARAMS = new URLSearchParams(globalThis.location.search); +const BACKEND = PARAMS.get("physics") ?? "builtin"; + +const BACKENDS = [ + { + id: "builtin", + label: "built-in", + note: "collides with every segment, sloped ones included, but has no SURFACE friction: nothing removes the pull of gravity along an incline, so the crate slides down the diagonal and off its end, and swings from arm to arm of the valley for a while before settling on the flat. matter and planck have real friction and settle sooner", + }, + { + id: "matter", + label: "matter", + note: "has no segment primitive, so a line is simulated as a thin quad following it: the crate lands on the slope and slides off the end, and settles in the valley of the polyline", + }, + { + id: "planck", + label: "planck", + note: "Box2D needs three vertices and silently substitutes a one-metre box for fewer, so a line is simulated as a thin quad following it instead: the crate lands on the slope and settles in the valley of the polyline", + }, +] as const; + +const ACTIVE = BACKENDS.find((b) => { + return b.id === BACKEND; +}); + +const INK = "#e8eaf2"; +const DIM = "#7c84a3"; +const PANEL = "#1b1f33"; +/** what you asked for */ +const AUTHORED = "#5f6b9a"; +/** what actually collides */ +const REPORTED = "#ff6b6b"; + +/** + * A static piece of ground, drawn twice: the shape it was built from, and the + * shape the adapter says it simulates. + */ +class Ground extends Renderable { + private readonly authored: Polygon[]; + readonly caption: string; + + constructor( + x: number, + y: number, + w: number, + h: number, + shapes: Polygon[], + caption: string, + ) { + super(x, y, w, h); + this.anchorPoint.set(0, 0); + this.isKinematic = false; + this.caption = caption; + this.authored = shapes.map((s) => { + return s.clone(); + }); + this.bodyDef = { type: "static", shapes }; + } + + override draw(renderer: WebGLRenderer | CanvasRenderer) { + renderer.save(); + renderer.translate(this.pos.x, this.pos.y); + + // what was authored + renderer.setColor(AUTHORED); + renderer.lineWidth = 6; + for (const shape of this.authored) { + renderer.stroke(shape, false); + } + + // what the adapter reports as colliding. These bodies are static and + // never rotated, so `currentTransform` is the identity and the + // reported geometry needs no transform undone (see the physics + // shapes example for the rotating case). + renderer.setColor(REPORTED); + renderer.lineWidth = 2; + for (const shape of game.world.adapter.getBodyShapes(this)) { + renderer.stroke(shape, false); + } + + renderer.restore(); + } +} + +/** A crate to drop on it. */ +class Crate extends Renderable { + constructor(x: number, y: number) { + super(x, y, 28, 28); + this.anchorPoint.set(0, 0); + this.isKinematic = false; + this.alwaysUpdate = true; + this.bodyDef = { type: "dynamic", shapes: [new Rect(0, 0, 28, 28)] }; + } + + override update() { + // let anything that falls through go + return this.pos.y < VIEWPORT_H + 600; + } + + override draw(renderer: WebGLRenderer | CanvasRenderer) { + renderer.setColor("#ffd166"); + renderer.fillRect(this.pos.x, this.pos.y, this.width, this.height); + } +} + +/** One backend button. */ +class BackendButton extends UIBaseElement { + private readonly active: boolean; + private readonly target: string; + + constructor(x: number, y: number, label: string, target: string) { + super(x, y, 108, 30); + this.target = target; + this.active = target === BACKEND; + this.anchorPoint.set(0, 0); + const text = new Text(54, 15, { + font: "monospace", + size: 14, + fillStyle: this.active ? "#10121c" : INK, + textAlign: "center", + textBaseline: "middle", + text: label, + }); + text.floating = false; + this.addChild(text); + } + + override onClick() { + globalThis.location.search = `?physics=${this.target}`; + return true; + } + + override draw(renderer: WebGLRenderer | CanvasRenderer) { + const pill = new RoundRect( + this.pos.x, + this.pos.y, + this.width, + this.height, + 8, + ); + renderer.setColor(this.active ? INK : PANEL); + renderer.stroke(pill, true); + renderer.setColor(this.active ? INK : "#39406b"); + renderer.lineWidth = 1; + renderer.stroke(pill, false); + super.draw(renderer); + } +} + +class PlayScreen extends Stage { + override onResetEvent() { + game.world.backgroundColor.parseCSS("#10121c"); + + const title = new Text(40, 16, { + font: "monospace", + size: 15, + fillStyle: INK, + text: "What each backend does with a Line", + }); + title.isKinematic = true; + game.world.addChild(title, 100); + + BACKENDS.forEach((b, i) => { + game.world.addChild( + new BackendButton(40 + i * 120, 46, b.label, b.id), + 100, + ); + }); + + const note = new Text(408, 50, { + font: "monospace", + size: 12, + fillStyle: DIM, + text: ACTIVE?.note ?? "", + wordWrapWidth: VIEWPORT_W - 448, + }); + note.isKinematic = true; + game.world.addChild(note, 100); + + const legend = new Text(40, VIEWPORT_H - 26, { + font: "monospace", + size: 12, + fillStyle: DIM, + text: "dim = the shape authored red = the geometry the adapter reports as colliding [S] debug panel", + }); + legend.isKinematic = true; + game.world.addChild(legend, 100); + + // four pieces of ground, 220px apart, each with a crate above it + const column = (i: number) => { + return 60 + i * 225; + }; + + // 0: a Rect, as a control. Every backend gets this right. + game.world.addChild( + new Ground( + column(0), + 420, + 180, + 20, + [new Rect(0, 0, 180, 20)], + "Rect (control)", + ), + 10, + ); + // 1: a horizontal Line + game.world.addChild( + new Ground( + column(1), + 420, + 180, + 20, + [new Line(0, 0, [new Vector2d(0, 0), new Vector2d(180, 0)])], + "Line, horizontal", + ), + 10, + ); + // 2: a diagonal Line, the slope case + game.world.addChild( + new Ground( + column(2), + 360, + 180, + 100, + [new Line(0, 0, [new Vector2d(0, 0), new Vector2d(180, 100)])], + "Line, diagonal", + ), + 10, + ); + // 3: a polyline, as Tiled actually emits one: a CHAIN OF SEGMENTS, + // not a closed polygon. Authoring the same points as a single + // `Polygon` would close the loop back to the start and, for points + // like these, self-intersect into a bow tie with no well defined + // inside, which is a different bug from anything the backends do. + game.world.addChild( + new Ground( + column(3), + 380, + 180, + 80, + [ + new Line(0, 0, [new Vector2d(0, 10), new Vector2d(60, 60)]), + new Line(0, 0, [new Vector2d(60, 60), new Vector2d(120, 60)]), + new Line(0, 0, [new Vector2d(120, 60), new Vector2d(180, 10)]), + ], + "polyline, 3 segments", + ), + 10, + ); + + for (const child of game.world.getChildren()) { + if (child instanceof Ground) { + const label = new Text(child.pos.x, 500, { + font: "monospace", + size: 12, + fillStyle: DIM, + text: child.caption, + wordWrapWidth: 200, + }); + label.isKinematic = true; + game.world.addChild(label, 100); + // The polyline gets its crate over the LEFT slope rather than + // the middle, so it slides down the incline and comes to rest + // on the flat segment: three segments acting as one surface is + // the thing worth seeing, not a single contact. + game.world.addChild( + new Crate( + child.pos.x + (child.caption.startsWith("polyline") ? 14 : 70), + 120, + ), + 20, + ); + } + } + } +} + +const createGame = async () => { + const scaleTarget = document.getElementById("screen") ?? undefined; + + // the same per-backend gravity units as the physics shapes example + const physic = + BACKEND === "planck" + ? new PlanckAdapter({ gravity: { x: 0, y: GRAVITY_PX_S2 } }) + : BACKEND === "matter" + ? new MatterAdapter() + : new BuiltinAdapter({ + gravity: new Vector2d(0, (GRAVITY_PX_S2 / 4453) * 0.98), + }); + + const app = new Application(VIEWPORT_W, VIEWPORT_H, { + parent: "screen", + scaleMethod: "fit", + scaleTarget, + renderer: video.AUTO, + antiAlias: true, + physic, + }); + await app.init(); + + // The panel's hitbox overlay reads the same `getBodyShapes()` this example + // strokes, so the two agree: it is the quickest way to check what a + // backend is really simulating. Press S. + plugin.register(DebugPanelPlugin, "debugPanel"); + + state.set(state.PLAY, new PlayScreen()); + state.change(state.PLAY); +}; + +export const ExampleLineCollision = createExampleComponent(createGame); diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index aba159023..ce476c808 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -193,6 +193,11 @@ const ExamplePoolMatter = lazy(() => default: m.ExamplePoolMatter, })), ); +const ExampleLineCollision = lazy(() => + import("./examples/lineCollision/ExampleLineCollision").then((m) => ({ + default: m.ExampleLineCollision, + })), +); const ExamplePhysicsShapes = lazy(() => import("./examples/physicsShapes/ExamplePhysicsShapes").then((m) => ({ default: m.ExamplePhysicsShapes, @@ -553,6 +558,14 @@ const examples: { description: "Top-down 8-ball pool driven by @melonjs/matter-adapter — drag-to-aim, release-to-strike.", }, + { + component: , + label: "Line Collision", + path: "line-collision", + sourceDir: "lineCollision", + description: + "What each physics backend does with a Line, authored geometry against what actually collides.", + }, { component: , label: "Physics Shapes", diff --git a/packages/melonjs/skills/melonjs-physics/SKILL.md b/packages/melonjs/skills/melonjs-physics/SKILL.md index d25ce8d71..5966680c1 100644 --- a/packages/melonjs/skills/melonjs-physics/SKILL.md +++ b/packages/melonjs/skills/melonjs-physics/SKILL.md @@ -250,6 +250,75 @@ resolved from it mints its own shapes. `Body#fromJSON()` and passing an exported list to `addShape()` are the old, built-in-only spelling of this and are deprecated since 20.7.0. +### Which shapes each backend can actually simulate + +| shape | builtin | matter | planck | +|---|---|---|---| +| `Rect`, `Polygon` | exact | exact | exact, up to 12 vertices | +| `RoundRect` | exact | exact (decomposed) | sampled down to 12 vertices | +| `Ellipse` | exact | circle of the average radius | circle of the average radius | +| `Line` | exact | thin quad along the segment | thin quad along the segment | +| `Point`, `Box3d`, `Sphere` | supported | **throws** | **throws** | + +Two of those are worth knowing before you author geometry. Box2D caps a +polygon's vertex count and says nothing when you go over: measured, a +16-point outline comes back with 12 vertices and a 36-point one collapses +from 70x36 to 47x18. A `RoundRect` carries 36 points, four corners of nine +arc segments each, so it is the usual way to meet that cap; the adapter now +samples it down and keeps the extent. Anything the 2D backends cannot express +is refused loudly on both, rather than leaving a body with no collision +geometry that silently never collides. + +Whatever a backend really simulates is what `getBodyShapes()` reports, so the +debug panel is the fastest way to see an approximation for what it is. + +### `Line` shapes, and why slopes need a rigid-body adapter + +A `Line` is two points and no area, and it is what Tiled emits for every +polyline, so it is the natural way to author a slope or a strip of ground. +Neither rigid-body engine has a segment primitive, so both simulate one as a +thin quad following the segment (2px), which is what `getBodyShapes()` reports +back. Before 20.7 each substituted something of its own instead: matter the +line's bounding box, planck a one-metre square wherever the body happened to +be. Neither logged anything. + +The builtin solver resolves a sloped segment too, and correctly: a body lands +on it and SLIDES down it, holding a constant distance from the surface while +its x advances. What it has no notion of is SURFACE friction. `def.friction` +is ignored entirely and `body.friction` is a per-step velocity damping vector +fed from `def.frictionAir`, so nothing removes the tangential component of +gravity. A body on an incline therefore accelerates down it forever and leaves +at the end, and in a valley of segments it swings from arm to arm for a while +before coming to rest on the flat. (It used to escape through an arm instead; +that was a separate defect in how a body was separated from a shape list, fixed +in 20.7.) + +matter and planck both have real surface friction, so the same valley holds a +body still. + +**Several shapes on one body.** A Tiled polyline arrives as ONE body carrying +one shape per segment, and until 20.7 a body resting on such a ground could +sink through one of the segments and drop out. The narrowphase reports the +FIRST overlapping shape pair it finds, and the compound resolution pass +re-ran that same first-hit-wins scan, so it kept re-resolving the pair it had +already resolved while a neighbour was penetrated unmeasured. Every pair is +enumerated now. If you are ever diagnosing something similar, the experiment +that localises it is to re-run the same geometry as SEVERAL single-shape +bodies: if that holds and one multi-shape body does not, the fault is in the +compound path rather than in the shape type. + +Do not diagnose this by dropping a body and reading its final `y`. A body that +slid off the end of a ramp and a body that fell straight through it both end up +far below, and they look identical in that one number. Track the body against +the surface height at its CURRENT x instead: riding the slope shows a constant +offset while x advances. + +So segments collide on every backend. If you need a body to come to REST on a +sloped one, use matter or planck, or damp it yourself: the builtin will slide +it down the incline indefinitely. The `Line Collision` example draws the +authored segment against the geometry each backend reports, which is the +quickest way to see what a backend is really simulating. + ### `anchorPoint` moves the collision shapes with the drawing `anchorPoint` says where in its own bounds a renderable sits on its `pos`: @@ -616,6 +685,8 @@ use them. `adapter.capabilities` (`constraints`, | `response.depth` / `response.normal` are `undefined` | reading a legacy `onCollision` response — it carries `overlap` / `overlapN` | | `onCollisionEnd` handler throws on `response` | built-in dispatches it with `undefined` | | off-screen bodies stop simulating | built-in gating on `inViewport`; set `alwaysUpdate` | +| a body sinks through one shape of a multi-shape body | fixed in 20.7; before that the compound pass only ever re-resolved the first overlapping pair | +| a body will not stay put on a slope on the builtin | it is riding the slope and sliding: the builtin has no surface friction, only `frictionAir` damping. Damp it yourself, or use matter/planck | | the sprite is drawn offset from its hitbox | pre-20.7 `anchorPoint` moved the drawing but not the collision shapes; upgrade, and drop any offsets you added to compensate | | a rotated renderable reports an unrotated bounding box | `autoTransform: false` opts `updateBounds()` out of the transform | | a body in a pile sinks into the floor (built-in) | fixed in 20.7 for anything with an immovable side; bodies pinned only by other DYNAMIC bodies still overlap by a pixel or two, which is what planck/matter are for | From 2b0e953899cf80581071e6bc7852216a13ed737f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 22 Sep 2026 15:10:25 +0800 Subject: [PATCH 6/6] Adapters: date the 1.5.0 and 1.6.0 changelog sections matter-adapter 1.5.0 and planck-adapter 1.6.0 are being released. melonjs stays `_unreleased_`: the adapter fixes read only `anchorPoint`, `width` and `height`, API that has always existed, so neither needs a newer engine. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/matter-adapter/CHANGELOG.md | 2 +- packages/planck-adapter/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/matter-adapter/CHANGELOG.md b/packages/matter-adapter/CHANGELOG.md index 8876b6d74..1952be754 100644 --- a/packages/matter-adapter/CHANGELOG.md +++ b/packages/matter-adapter/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.5.0 - _unreleased_ +## 1.5.0 - _2026-09-22_ ### Fixed - A `Line` collides as the segment it is. matter has no segment primitive and refuses a zero-area outline, so a line fell back to its AXIS-ALIGNED BOUNDING BOX: a diagonal became a solid block, and a body rested on the top of that rectangle rather than on the slope. It is simulated as a thin oriented rectangle following the segment now, which is also what `getBodyShapes()` reports. `Line` is what Tiled emits for a polyline, so this is the usual way to author a slope or a strip of ground diff --git a/packages/planck-adapter/CHANGELOG.md b/packages/planck-adapter/CHANGELOG.md index 07cdbf6a5..acd08576b 100644 --- a/packages/planck-adapter/CHANGELOG.md +++ b/packages/planck-adapter/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.6.0 - _unreleased_ +## 1.6.0 - _2026-09-22_ ### Fixed - A `RoundRect` collides at the size it was given. It extends `Polygon`, not `Rect`, and carries 36 points for its corner arcs, while Box2D caps a polygon's vertex count and truncates past it without a word: an 80x40 rounded rect was simulated as a 10x34 blob. An outline over the cap is sampled down to it evenly now, keeping the extent and the silhouette, and `getBodyShapes()` reports the decimated outline so the debug overlay draws what is really simulated