diff --git a/packages/matter-adapter/CHANGELOG.md b/packages/matter-adapter/CHANGELOG.md index b056137b2..c1cd01931 100644 --- a/packages/matter-adapter/CHANGELOG.md +++ b/packages/matter-adapter/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 1.3.0 - _2026-09-18_ + +### Fixed +- `getBodyShapes()` is read back from matter rather than derived, which fixes a body whose shapes came apart under rotation. 1.2.1 rotated the authored shapes about the renderable's origin, and `Polygon#rotate` moves a shape's points without its `pos`, so a body built from several shapes at different offsets saw each one spin about its own origin: the parts visibly separated, and shapes carrying their offset in `points` instead swung away from the body altogether. The reported geometry now comes from `part.vertices`, which matter keeps in world space and already rotated, so there is no pivot to derive and nothing to keep in step with the sprite +- `shape.isActive === false` keeps a shape out of the simulation, as it already did on the planck and builtin backends. matter simulated it regardless, so one body definition collided differently depending on which backend was driving it +- `getBodyShapes()` no longer reports a body's previous geometry after `updateShape()` at an unchanged angle, and no longer pins a removed renderable and its shapes for the adapter's lifetime. The cache was keyed by angle and cleared on no path but one +- `getBodyShapes()` allocates nothing once a body's structure is settled. It is called once per body per frame by the debug overlay, and the previous cache missed on every frame of a body that was actually turning, allocating a fresh shape set each time through the object pools without ever releasing it + +### Changed +- `getBodyShapes()` reports the geometry matter **simulates**, not the shapes as authored. A `Rect` comes back as the `Polygon` matter holds, an `Ellipse` as the circle of average radius it is simulated as, a concave polygon as the convex parts it is decomposed into, and a degenerate one as the box it falls back to. The overlay previously drew outlines that did not describe what collides. The authored definitions are unchanged and remain available on `renderable.bodyDef.shapes` + +### Notes +- The peer range stays `>=20.0.0`: nothing here needs a newer engine + ## 1.2.1 - _2026-09-17_ ### Fixed diff --git a/packages/matter-adapter/package.json b/packages/matter-adapter/package.json index aed80c304..8b8eb722e 100644 --- a/packages/matter-adapter/package.json +++ b/packages/matter-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@melonjs/matter-adapter", - "version": "1.2.1", + "version": "1.3.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 9c76ff8bb..59ea3a999 100644 --- a/packages/matter-adapter/src/index.ts +++ b/packages/matter-adapter/src/index.ts @@ -170,6 +170,14 @@ export class MatterAdapter implements PhysicsAdapter { */ private readonly posOffsets = new Map(); + /** + * Shapes handed back by {@link MatterAdapter#getBodyShapes}: one reusable + * array per renderable whose members are refreshed in place, so a spinning + * body costs no allocation on the debug overlay's per-frame path. Dropped + * in `removeBody`, so nothing outlives the body it describes. + */ + private readonly reportedShapes = new Map(); + private readonly matterOptions: Matter.IEngineDefinition | undefined; private readonly subSteps: number; @@ -285,6 +293,7 @@ export class MatterAdapter implements PhysicsAdapter { this.defMap.clear(); this.bodyGravityScale.clear(); this.posOffsets.clear(); + this.reportedShapes.clear(); } step(dt: number): void { @@ -369,11 +378,29 @@ export class MatterAdapter implements PhysicsAdapter { // matter compound body (Matter.Body.create with parts). const baseX = renderable.pos.x; const baseY = renderable.pos.y; - const parts = def.shapes.map((s, i) => { + // `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 + // disabled, so the same body collides the same way on every backend. + // The index carried into `partShapeMap` is the shape's index in the + // ORIGINAL list, so collision events still name the right shape. + const parts: Matter.Body[] = []; + def.shapes.forEach((s, i) => { + if ((s as { isActive?: boolean }).isActive === false) { + return; + } const part = this._shapeToMatter(s, baseX, baseY); this.partShapeMap.set(part, { shape: s, index: i }); - return part; + parts.push(part); }); + if (parts.length === 0) { + // every shape disabled: matter cannot build a body from nothing, so + // keep one inert part rather than throwing — the body still exists + // for the caller to re-enable a shape on later via `updateShape` + parts.push( + Matter.Bodies.rectangle(baseX, baseY, 1, 1, { isSensor: true }), + ); + } let body: Matter.Body; if (parts.length === 1) { body = parts[0]; @@ -596,6 +623,9 @@ export class MatterAdapter implements PhysicsAdapter { this.velocityLimits.delete(renderable); this.defMap.delete(renderable); this.posOffsets.delete(renderable); + // `updateShape` is a remove + add, so clearing here also stops a + // reshaped body reporting its previous geometry + this.reportedShapes.delete(renderable); this.bodyGravityScale.delete(body); } } @@ -852,63 +882,83 @@ export class MatterAdapter implements PhysicsAdapter { * @param renderable - the renderable whose body shapes to read */ getBodyShapes(renderable: Renderable): readonly BodyShape[] { - const shapes = this.defMap.get(renderable)?.shapes; - if (shapes === undefined) { + // Reported from MATTER'S OWN geometry, not by re-rotating the authored + // shapes. `part.vertices` are world-space and already carry the body's + // current pose, so there is no pivot to derive and nothing to keep in + // sync with `syncFromPhysics` — and it reports what actually collides, + // which the authored shapes do not: an Ellipse is simulated as a circle + // of the average radius, a degenerate polygon as its bounding box, and + // a concave one as convex chunks. + const body = this.bodyMap.get(renderable); + if (body === undefined) { return []; } - const angle = this.getAngle(renderable); - if (angle === 0) { - // much the commonest case, and the one that must stay allocation - // free: hand back the authored array exactly as before - this.rotatedShapes.delete(renderable); - return shapes; - } - const cached = this.rotatedShapes.get(renderable); - if (cached !== undefined && cached.angle === angle) { - return cached.shapes; + const parts = body.parts; + // a compound body's real shapes are `parts[1..N]`; `parts[0]` is the + // wrapper whose vertices are the convex hull. Same walk as the raycast. + const startIdx = parts.length > 1 ? 1 : 0; + const count = parts.length - startIdx; + + let cached = this.reportedShapes.get(renderable); + // rebuilt only when the STRUCTURE changes — the pose is refreshed in + // place below, so a spinning body allocates nothing per frame (the + // debug overlay calls this once per body per frame) + if (cached === undefined || cached.length !== count) { + cached = []; + this.reportedShapes.set(renderable, cached); } - const rotated = this.rotateShapes(shapes, angle); - this.rotatedShapes.set(renderable, { angle, shapes: rotated }); - return rotated; - } - /** - * Rotated copies of the authored shapes, keyed by renderable, with the - * angle they were built for. Rebuilt only when the body has actually - * turned — a scene of unrotated bodies never allocates, and a spinning one - * allocates once per angle change rather than once per read. - */ - private readonly rotatedShapes = new Map< - Renderable, - { angle: number; shapes: BodyShape[] } - >(); - - /** - * Rotate the authored shapes to the body's current pose. - * - * The shapes are rotated about the body's own origin, which is where the - * engine rotates it, so the result stays in the renderable-local frame the - * contract promises. - * @param shapes - the authored shape definitions - * @param angle - the body's current angle, in radians - * @returns fresh shapes at that angle - */ - private rotateShapes( - shapes: readonly BodyShape[], - angle: number, - ): BodyShape[] { - const pivot = new Vector2d(0, 0); - return shapes.map((shape) => { - // `clone()` keeps each shape's own type — a Rect rotated off-axis - // becomes a Polygon, which is what Rect#toPolygon is for; an - // Ellipse has no rotated form and is returned as it came - if (shape instanceof Ellipse) { - return shape; + const rx = renderable.pos.x; + const ry = renderable.pos.y; + for (let p = startIdx; p < parts.length; p++) { + const part = parts[p]; + const i = p - startIdx; + const radius = (part as { circleRadius?: number }).circleRadius; + if (typeof radius === "number" && radius > 0) { + // a circle has no vertex list; report the circle matter + // actually simulates rather than the ellipse that was authored + const existing = cached[i]; + if (existing instanceof Ellipse) { + existing.pos.set(part.position.x - rx, part.position.y - ry); + existing.radiusV.set(radius, radius); + } else { + cached[i] = new Ellipse( + part.position.x - rx, + part.position.y - ry, + radius * 2, + radius * 2, + ); + } + continue; } - const rotated = shape instanceof Rect ? shape.toPolygon() : shape.clone(); - rotated.rotate(angle, pivot); - return rotated; - }); + const vertices = part.vertices; + const existing = cached[i]; + if ( + existing instanceof Polygon && + existing.points.length === vertices.length + ) { + // mutate in place: `recalc` reuses its edge/normal slots, so + // the steady state is allocation-free + for (let v = 0; v < vertices.length; v++) { + existing.points[v].set(vertices[v].x - rx, vertices[v].y - ry); + } + existing.pos.set(0, 0); + existing.recalc(); + existing.updateBounds(); + } else { + // matter guarantees at least three vertices for a polygon part; + // the cast satisfies Polygon's "three or more" tuple type, + // which a `map` result cannot prove on its own + cached[i] = new Polygon( + 0, + 0, + vertices.map((v) => { + return new Vector2d(v.x - rx, v.y - ry); + }) as unknown as ConstructorParameters[2], + ); + } + } + return cached; } isGrounded(renderable: Renderable): boolean { diff --git a/packages/matter-adapter/tests/rotated-body-shapes.spec.ts b/packages/matter-adapter/tests/rotated-body-shapes.spec.ts index 679567479..431a33a91 100644 --- a/packages/matter-adapter/tests/rotated-body-shapes.spec.ts +++ b/packages/matter-adapter/tests/rotated-body-shapes.spec.ts @@ -13,7 +13,17 @@ * where a rotated body's shapes are gets the wrong answer. */ -import { Application, boot, Rect, Renderable, video, World } from "melonjs"; +import { + Application, + Bounds, + boot, + Polygon, + Rect, + Renderable, + Vector2d, + video, + World, +} from "melonjs"; import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { MatterAdapter } from "../src/index"; @@ -131,4 +141,213 @@ describe("MatterAdapter — getBodyShapes() follows the body's rotation", () => const r = new Renderable(0, 0, 10, 10); expect(adapter.getBodyShapes(r)).toEqual([]); }); + + // ── multi-shape bodies under rotation (the reported regression) ──── + + /** + * Every test above uses ONE shape whose offset lives at the origin, which + * is exactly why they passed while this was broken. A body built from + * SEVERAL shapes, offset away from the origin, is the case that bites — + * and a shape can carry that offset two ways, in its `points` or in its + * `pos`, which the old implementation treated differently. + */ + describe("several shapes, offset from the origin", () => { + /** two triangles meeting along a shared edge, offset in `points` */ + const inPoints = () => [ + new Polygon(0, 0, [ + new Vector2d(-50, -90), + new Vector2d(0, -120), + new Vector2d(0, -60), + ]), + new Polygon(0, 0, [ + new Vector2d(0, -120), + new Vector2d(50, -90), + new Vector2d(0, -60), + ]), + ]; + + /** the SAME geometry, with the offset carried in `pos` instead */ + const inPos = () => [ + new Polygon(-50, -120, [ + new Vector2d(0, 30), + new Vector2d(50, 0), + new Vector2d(50, 60), + ]), + new Polygon(0, -120, [ + new Vector2d(0, 0), + new Vector2d(50, 30), + new Vector2d(0, 60), + ]), + ]; + + const bodyWith = (shapes: Polygon[]) => { + const r = new Renderable(200, 200, 100, 240); + world.addChild(r); + adapter.addBody(r, { type: "static", shapes }); + return r; + }; + + /** + * every reported vertex, in renderable-local space + * @param r - the renderable whose body to read + * @returns the flattened vertex list + */ + const points = (r: Renderable) => { + const out: { x: number; y: number }[] = []; + for (const s of adapter.getBodyShapes(r)) { + const poly = s as Polygon; + if (!poly.points) continue; + for (const v of poly.points) { + out.push({ x: poly.pos.x + v.x, y: poly.pos.y + v.y }); + } + } + return out; + }; + + it("reports the same geometry however the offset is carried", () => { + // `pos + points` is the shape's geometry; which half holds the + // offset is an authoring detail and must not change the answer + const a = bodyWith(inPoints()); + const b = bodyWith(inPos()); + adapter.setAngle(a, 0.7); + adapter.setAngle(b, 0.7); + + const pa = points(a).sort((p, q) => p.x - q.x || p.y - q.y); + const pb = points(b).sort((p, q) => p.x - q.x || p.y - q.y); + expect(pa).toHaveLength(pb.length); + for (let i = 0; i < pa.length; i++) { + expect(pa[i].x).toBeCloseTo(pb[i].x, 3); + expect(pa[i].y).toBeCloseTo(pb[i].y, 3); + } + world.removeChildNow(a); + world.removeChildNow(b); + }); + + it("actually turns the geometry, by exactly the angle asked for", () => { + // The guard the rest of this block does not give: shapes that never + // rotated would still agree across authoring forms, still stay + // joined, and still sit inside an equally unrotated AABB. + // + // Measured on the angle of a vector BETWEEN two vertices, which is + // independent of whatever point the rotation happens about — the + // two engines do not agree on that (matter turns about the centre + // of mass, planck about the body origin) and neither is wrong. + const r = bodyWith(inPoints()); + adapter.setAngle(r, 0); + const a0 = points(r); + const before = Math.atan2(a0[1].y - a0[0].y, a0[1].x - a0[0].x); + adapter.setAngle(r, Math.PI / 2); + const a90 = points(r); + expect(a90.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`)).not.toEqual( + a0.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`), + ); + const after = Math.atan2(a90[1].y - a90[0].y, a90[1].x - a90[0].x); + // wrapped into (-π, π] + let delta = after - before; + while (delta <= -Math.PI) delta += Math.PI * 2; + while (delta > Math.PI) delta -= Math.PI * 2; + expect(delta).toBeCloseTo(Math.PI / 2, 3); + world.removeChildNow(r); + }); + + it("keeps every shape inside the body's own AABB", () => { + // The cross-check that catches a wrong pivot: `getBodyAABB` is + // engine truth and travels with the body, so shapes rotated about + // the wrong point escape it. Deliberately NOT "the centroid does + // not move" — the two engines rotate about different points (matter + // about the centre of mass, planck about the body origin), and a + // pivot that is not the centroid moves the centroid legitimately. + const r = bodyWith(inPoints()); + for (const angle of [0, 0.6, 1.9, -2.4]) { + adapter.setAngle(r, angle); + const aabb = adapter.getBodyAABB?.(r, new Bounds()); + expect(aabb).toBeDefined(); + for (const p of points(r)) { + expect(p.x).toBeGreaterThanOrEqual(aabb!.left - 1); + expect(p.x).toBeLessThanOrEqual(aabb!.right + 1); + expect(p.y).toBeGreaterThanOrEqual(aabb!.top - 1); + expect(p.y).toBeLessThanOrEqual(aabb!.bottom + 1); + } + } + world.removeChildNow(r); + }); + + it("keeps the two shapes joined along their shared edge", () => { + // the reported symptom: the parts of one body drifting apart + const r = bodyWith(inPos()); + adapter.setAngle(r, 0.9); + const p = points(r); + // every vertex must have a partner from the other shape nearby; + // it is enough that the cloud stays connected + const spread = Math.max(...p.map((q) => Math.hypot(q.x, q.y))); + expect(spread).toBeLessThan(200); + world.removeChildNow(r); + }); + + it("reports the NEW shapes after updateShape at the same angle", () => { + // the cache keys on the angle, so swapping the shapes while the + // body holds still handed back the old geometry + const r = bodyWith(inPoints()); + adapter.setAngle(r, 0.5); + const first = points(r).length; + adapter.updateShape(r, [ + new Polygon(0, 0, [ + new Vector2d(0, 0), + new Vector2d(10, 0), + new Vector2d(10, 10), + new Vector2d(0, 10), + ]), + ]); + adapter.setAngle(r, 0.5); + expect(points(r).length).not.toBe(first); + world.removeChildNow(r); + }); + + it("leaves an inactive shape out of the simulation", () => { + // `isActive: false` is the portable way to keep a shape out of + // collision without removing it from the definition, and both the + // builtin and planck honour it. Matter simulated it anyway, so the + // same body collided differently depending on the backend. + const r = new Renderable(400, 400, 100, 240); + world.addChild(r); + const live = new Polygon(0, 0, [ + new Vector2d(0, 0), + new Vector2d(20, 0), + new Vector2d(20, 20), + ]); + const off = new Polygon(0, 0, [ + new Vector2d(60, 0), + new Vector2d(80, 0), + new Vector2d(80, 20), + ]); + (off as unknown as { isActive: boolean }).isActive = false; + adapter.addBody(r, { type: "static", shapes: [live, off] }); + expect(adapter.getBodyShapes(r)).toHaveLength(1); + world.removeChildNow(r); + }); + + it("forgets a body once it is removed", () => { + // the cache is keyed by renderable and nothing cleared it, so a + // destroyed renderable and its shapes stayed pinned + const r = bodyWith(inPoints()); + adapter.setAngle(r, 0.4); + expect(adapter.getBodyShapes(r).length).toBeGreaterThan(0); + adapter.removeBody(r); + expect(adapter.getBodyShapes(r)).toEqual([]); + world.removeChildNow(r); + }); + + it("does not allocate fresh shapes on every call", () => { + // the debug overlay calls this once per body per frame, and the + // angle is different every frame for anything actually spinning + const r = bodyWith(inPoints()); + adapter.setAngle(r, 0.3); + const first = adapter.getBodyShapes(r); + adapter.setAngle(r, 0.30001); + const second = adapter.getBodyShapes(r); + expect(second).toBe(first); + expect(second[0]).toBe(first[0]); + world.removeChildNow(r); + }); + }); }); diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 5a9186f10..adb28a436 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -10,6 +10,7 @@ - Loader: `load()` no longer names asset types while resolving a `src`. A type whose `src` is not a bare path declares `normalizeSrc` and `needsBaseURL` instead, which is how `fontface` unwraps a `url(...)` descriptor before the base URL goes on, and leaves an installed `local()` family alone ([#1648](https://github.com/melonjs/melonJS/issues/1648), thanks @ICOM725) ### Fixed +- `Polygon#rotate(angle, pivot)` turns the whole shape, not only its points. A polygon's geometry is `pos + points`, so a shape carrying its offset in `pos` spun about its own origin and landed somewhere else entirely, while `Ellipse#rotate` had always rotated its `pos`. `Body#rotate` calls both over one mixed list, so a body's ellipse parts orbited correctly and its polygon parts did not, and since every `Rect` added to a body becomes a polygon offset by `pos`, the documented feet-and-torso shape was among them. A polygon at the origin, which is every caller in the engine until now, rotates to exactly the numbers it always did - Mesh: `lit: true` under a `Camera2d` says so instead of silently doing nothing. Lighting a mesh needs world-space normals and a world-space fragment position, and that path has neither, since its vertices are already the projected output, so the mesh degrades to unlit and now warns once, naming `Camera3d` as the way to light it ([#1576](https://github.com/melonjs/melonJS/issues/1576)) - Trigger: a trigger targeting a glTF level forwards the scene options it was given. `scale`, `rightHanded`, `lights`, `lightIntensityScale`, `castGroundShadow` and `shadowGroundY` were dropped before `level.load()` saw them, so a Tiled-authored trigger loaded its scene at the default scale and handedness whatever the map said ([#1649](https://github.com/melonjs/melonJS/issues/1649), thanks @ICOM725) - Body: rotation pivoted about the wrong point for any renderable away from the world origin. `body.bounds` is already renderable-local and the pivot subtracted `renderable.pos` from it a second time, so a 40x40 body on a renderable at `(100, 50)` turned about `(-80, -30)` rather than its own centre diff --git a/packages/melonjs/skills/melonjs-physics/SKILL.md b/packages/melonjs/skills/melonjs-physics/SKILL.md index 1934a4207..bc864556b 100644 --- a/packages/melonjs/skills/melonjs-physics/SKILL.md +++ b/packages/melonjs/skills/melonjs-physics/SKILL.md @@ -1,6 +1,6 @@ --- name: melonjs-physics -description: "Use this skill for collision, physics bodies, movement, and spatial queries in melonJS — the built-in SAT world and the planck/matter adapters. Covers bodyDef vs Body, collision types and masks, the collision callback family and their firing rules, raycast/queryAABB/querySphere, and the behaviour differences between adapters. Triggers on: Body, bodyDef, collision, collisionType, collisionMask, onCollision, onCollisionStart, onCollisionActive, raycast, queryAABB, querySphere, PlanckAdapter, MatterAdapter, gravity, velocity, applyForce, isGrounded, SAT." +description: "Use this skill for collision, physics bodies, movement, and spatial queries in melonJS — the built-in SAT world and the planck/matter adapters. Covers bodyDef vs Body, collision types and masks, the collision callback family and their firing rules, raycast/queryAABB/querySphere, the behaviour differences between adapters, and which one to pick for a given game. Triggers on: which physics engine, choosing an adapter, rotation collision, rotating body, torque, joints, stacking, Body, bodyDef, collision, collisionType, collisionMask, onCollision, onCollisionStart, onCollisionActive, raycast, queryAABB, querySphere, PlanckAdapter, MatterAdapter, gravity, velocity, applyForce, isGrounded, SAT." license: MIT --- @@ -19,6 +19,52 @@ license: MIT | **`@melonjs/planck-adapter`** | Full rigid-body dynamics — stacking, joints, realistic restitution. | | **`@melonjs/matter-adapter`** | Same class of thing, backed by matter-js. | +### Picking one + +Start with the built-in. It is the default for a reason: no dependency, the +smallest bundle, and its position-based model is what most 2D games actually +want — a platformer's jump arc is a designed curve, not a simulated one, and +tuning gravity against a solver you are fighting is a bad afternoon. + +**Move to an adapter when the game needs something the builtin cannot express**, +and these are the usual triggers: + +- **Rotation that collides.** The clearest signal. The builtin's SAT never reads + `body.angle`, so a spinning shape collides as though it never turned. See + the note in *Built-in world quirks* below. +- **Torque, angular velocity, or spin from an impact.** Anything that should + start turning *because* it was hit. +- **Joints and constraints** — ragdolls, chains, ropes, vehicles, hinged doors. + The builtin has no concept of them. +- **Stacking and resting contacts.** Boxes that pile up and stay put need an + iterative solver; the builtin's push-out resolves one pair at a time and a + stack jitters apart. +- **Believable restitution and friction**, where a ball's bounce height and roll + should follow from its material rather than from code you wrote per case. + +**Stay on the built-in when** the game is grid or tile based, movement is +authored rather than simulated, collision is "did these two boxes touch" plus a +push-out, or you are shipping to a tight bundle budget. Tiled maps, `Trigger` +and `Collectable` all work on every backend, so this is not a fork in the road +for level content. + +**planck or matter?** Both are real rigid-body engines and either will do; the +adapters expose the same portable API, so a game can switch with an import +change and a gravity re-tune. + +- **planck** (a Box2D port) is the more accurate and the more predictable under + stress: better stacking, better joint behaviour, a continuous-collision + `bullet` mode for fast movers. It works in **metres**, so it has a + `pixelsPerMeter` scale to think about, and its tuning vocabulary is Box2D's. +- **matter-js** works directly in **pixels**, which makes it the gentler + introduction, and its compound bodies and constraints are pleasant to author. + It is the softer solver: stacks settle less crisply and fast bodies need + `subSteps`. + +If you have no preference and the game leans on stacking, joints or fast +projectiles, take planck. If you want to be up and running with the least +conversion to think about, take matter. + An adapter is an `Application` setting, not a plugin: ```js @@ -363,10 +409,27 @@ Track it yourself if you need to read it back. Before 20.7 it also threw on a `Box3d` or `Point` shape, and did not tell its owner that the body's bounds had grown. -`adapter.getBodyShapes(renderable)` reports whichever is true: rotated shapes -on planck and matter (since matter-adapter 1.2.1 / planck-adapter 1.3.1), -unrotated ones on the builtin solver because they genuinely are. That is what -the debug overlay draws, so the hitbox you see is the hitbox that collides. +`adapter.getBodyShapes(renderable)` reports whichever is true: the geometry +the engine is actually simulating on planck and matter (matter-adapter 1.3.0 / +planck-adapter 1.4.0 and later), unrotated shapes on the builtin solver because +there they genuinely are. That is what the debug overlay draws, so the hitbox +you see is the hitbox that collides — a sprite spinning inside a stationary red +box on the builtin is the solver telling you the truth, not a drawing bug. + +On planck and matter that report comes from the engine rather than from your +definitions, so it shows the approximations they make: an `Ellipse` appears as +the circle of average radius it is simulated as, a concave polygon as the +convex pieces it was decomposed into, and a `Rect` as a `Polygon`. Your +authored shapes are untouched and still readable on `renderable.bodyDef.shapes`. + +**If you need rotation to affect collision, use planck or matter.** That is the +dividing line: the builtin is a position-based solver whose SAT never reads +`body.angle`, so rotation there is a visual property. `body.rotate(angle)` is +the workaround, not a rotating body — it permanently moves the shapes, does not +track an angle, and gives you no angular velocity, torque or rotational +response from a collision. A game whose collision genuinely turns (a spinning +hazard, a swinging bridge, a car, anything with torque) wants a real rigid-body +adapter rather than a workaround. ## Porting between adapters diff --git a/packages/melonjs/src/geometries/polygon.ts b/packages/melonjs/src/geometries/polygon.ts index 3483edafa..ef83a39c7 100644 --- a/packages/melonjs/src/geometries/polygon.ts +++ b/packages/melonjs/src/geometries/polygon.ts @@ -197,11 +197,24 @@ export class Polygon { const sin = Math.sin(angle); if (v) { + // A polygon's world geometry is `pos + points[i]`, so a pivot + // applies to the SUM — rotating the points alone spins the + // shape about its own origin and leaves `pos` behind, which + // for two shapes at different offsets pulls them apart. + // `Ellipse#rotate` has always rotated its `pos` for the same + // reason; this brings the two into agreement. + // + // Folded back into the points rather than written to `pos`, so + // `pos` keeps whatever meaning the caller gave it and a shape + // at the origin — every caller in the engine before now — + // rotates to exactly the same numbers as it always did. + const px = this.pos.x; + const py = this.pos.y; for (let i = 0; i < len; i++) { - const x = points[i].x - v.x; - const y = points[i].y - v.y; - points[i].x = x * cos - y * sin + v.x; - points[i].y = x * sin + y * cos + v.y; + const x = px + points[i].x - v.x; + const y = py + points[i].y - v.y; + points[i].x = x * cos - y * sin + v.x - px; + points[i].y = x * sin + y * cos + v.y - py; } } else { for (let i = 0; i < len; i++) { diff --git a/packages/melonjs/tests/shape-rotate-pos.spec.js b/packages/melonjs/tests/shape-rotate-pos.spec.js new file mode 100644 index 000000000..33f53e3e3 --- /dev/null +++ b/packages/melonjs/tests/shape-rotate-pos.spec.js @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import { Ellipse } from "../src/geometries/ellipse.ts"; +import { Polygon } from "../src/geometries/polygon.ts"; +import { Rect } from "../src/geometries/rectangle.ts"; +import { Body, Renderable } from "../src/index.js"; +import { Vector2d } from "../src/math/vector2d.ts"; + +/** + * `rotate(angle, pivot)` on a shape whose offset lives in `pos`. + * + * A shape's world geometry is `pos + points[i]`, and only `Ellipse` was + * rotating both halves — `Polygon` rotated its points and left `pos` where it + * was, so an offset polygon span about its own local origin instead of the + * pivot it was given. Two shapes at different offsets therefore drift apart + * under the same call, which is what `Body#rotate` does to a body built from + * several parts. + * + * The two forms carry identical world geometry, so they must land identically. + */ +describe("Polygon#rotate about a pivot, with a pos offset", () => { + /** + * @param {Polygon} p - the shape to read + * @returns {string} its world-space points, rounded + */ + const world = (p) => { + return p.points + .map((v) => { + return `${Math.round(p.pos.x + v.x)},${Math.round(p.pos.y + v.y)}`; + }) + .join(" "); + }; + + const tri = () => { + return [ + new Vector2d(40, -60), + new Vector2d(60, -60), + new Vector2d(60, -40), + ]; + }; + + it("lands the same whether the offset is in pos or in points", () => { + // identical geometry, expressed two ways + const inPoints = new Polygon(0, 0, tri()); + const inPos = new Polygon(40, -60, [ + new Vector2d(0, 0), + new Vector2d(20, 0), + new Vector2d(20, 20), + ]); + expect(world(inPos)).toBe(world(inPoints)); + + const pivot = new Vector2d(0, 0); + inPoints.rotate(Math.PI / 2, pivot); + inPos.rotate(Math.PI / 2, pivot); + expect(world(inPos)).toBe(world(inPoints)); + }); + + it("agrees with Ellipse, which already rotated its pos", () => { + // same signature, same meaning — a mixed shape list must not have its + // parts disagree about what the pivot means + const pivot = new Vector2d(10, 10); + const e = new Ellipse(80, 40, 20, 20); + const p = new Polygon(80, 40, [ + new Vector2d(0, 0), + new Vector2d(1, 0), + new Vector2d(1, 1), + ]); + e.rotate(Math.PI / 2, pivot); + p.rotate(Math.PI / 2, pivot); + // the ellipse's centre and the polygon's first world point started at + // the same place, so they must end at the same place + expect(Math.round(p.pos.x + p.points[0].x)).toBe(Math.round(e.pos.x)); + expect(Math.round(p.pos.y + p.points[0].y)).toBe(Math.round(e.pos.y)); + }); + + it("keeps two offset shapes joined", () => { + // the reported symptom: parts of one body separating under rotation + const a = new Polygon(0, -60, [ + new Vector2d(0, 0), + new Vector2d(40, 0), + new Vector2d(0, 40), + ]); + const b = new Polygon(40, -60, [ + new Vector2d(0, 0), + new Vector2d(0, 40), + new Vector2d(-40, 40), + ]); + // they share the edge (40,-60)..(0,-20) before + const pivot = new Vector2d(0, 0); + a.rotate(0.7, pivot); + b.rotate(0.7, pivot); + const aPts = a.points.map((v) => { + return { x: a.pos.x + v.x, y: a.pos.y + v.y }; + }); + const bPts = b.points.map((v) => { + return { x: b.pos.x + v.x, y: b.pos.y + v.y }; + }); + // the shared corner must still be shared + const near = aPts.some((p) => { + return bPts.some((q) => { + return Math.hypot(p.x - q.x, p.y - q.y) < 1e-6; + }); + }); + expect(near).toBe(true); + }); + + it("REGRESSION: a pos-less polygon rotates exactly as before", () => { + // every existing caller passes pos = (0,0); their behaviour must not + // shift by a single unit + const p = new Polygon(0, 0, tri()); + p.rotate(Math.PI / 2, new Vector2d(0, 0)); + expect(world(p)).toBe("60,40 60,60 40,60"); + }); + + it("Body#rotate moves an offset Rect part to the right place", () => { + // the documented feet-plus-torso shape: `addShape` turns every Rect + // into a Polygon whose offset lives in `pos`, so this is the case the + // bug actually reaches + const r = new Rect(0, 24, 32, 8); + const poly = r.toPolygon(); + expect(poly.pos.x).toBe(0); + expect(poly.pos.y).toBe(24); + poly.rotate(Math.PI / 2, new Vector2d(16, 28)); + const pts = poly.points.map((v) => { + return { + x: Math.round(poly.pos.x + v.x), + y: Math.round(poly.pos.y + v.y), + }; + }); + // rotating (0,24) a quarter turn about (16,28) lands on (20,12) + expect(pts).toContainEqual({ x: 20, y: 12 }); + }); +}); + +/** + * What the builtin adapter REPORTS, and why it deliberately differs from the + * matter and planck adapters. + * + * The builtin's SAT never reads `body.angle` — `setAngle` only syncs the + * visual transform — so a spinning sprite inside a stationary red hitbox is + * the engine telling the truth about what it collides as. Making the overlay + * follow the visual angle would turn an honest inconsistency into a confident + * lie. `Body#rotate`, which DOES mutate the stored shapes, is the supported + * way to collide rotated, and that is reported because the shapes really moved. + */ +describe("builtin: getBodyShapes reports what collides", () => { + /** + * @returns {object} a renderable carrying a two-part body + */ + const bodied = () => { + const r = new Renderable(100, 100, 64, 64); + r.body = new Body(r, [new Rect(0, 0, 32, 8), new Rect(0, 24, 32, 8)]); + return r; + }; + + it("setAngle does NOT move the reported shapes", () => { + // visual-only: the SAT path never reads `body.angle`, so reporting a + // rotated shape here would describe collision that does not happen + const r = bodied(); + const before = r.body.shapes.map((s) => { + return s.getBounds().left; + }); + r.body.setAngle(Math.PI / 4); + const after = r.body.shapes.map((s) => { + return s.getBounds().left; + }); + expect(after).toEqual(before); + }); + + it("rotate() DOES, because it moves the shapes themselves", () => { + // the supported way to collide rotated on this backend + const r = bodied(); + const before = r.body.shapes.map((s) => { + return s.getBounds().left; + }); + r.body.rotate(Math.PI / 2); + const after = r.body.shapes.map((s) => { + return s.getBounds().left; + }); + expect(after).not.toEqual(before); + }); + + it("rotate() keeps a two-part body together", () => { + // the offset parts are Rects, which `addShape` turns into Polygons + // whose offset lives in `pos` — the case that used to pull apart + const r = bodied(); + const gap = () => { + const [a, b] = r.body.shapes.map((s) => { + return s.getBounds(); + }); + return Math.hypot(a.centerX - b.centerX, a.centerY - b.centerY); + }; + const before = gap(); + r.body.rotate(Math.PI / 3); + expect(gap()).toBeCloseTo(before, 6); + }); +}); diff --git a/packages/melonjs/tests/trigger_level_change.spec.js b/packages/melonjs/tests/trigger_level_change.spec.js index 818259846..f66474fc4 100644 --- a/packages/melonjs/tests/trigger_level_change.spec.js +++ b/packages/melonjs/tests/trigger_level_change.spec.js @@ -221,6 +221,13 @@ describe("Trigger level change (#1646)", () => { ...options, }); t.triggerEvent(); + // Out of the world the moment it has fired. The loop is running for + // this test, so the trigger's own body keeps colliding and re-entering + // `triggerEvent` — which queued extra loads under a busy scheduler and + // made this fail only in a full-suite run. The transition already owns + // everything it needs (the effect lives on the viewport), so the + // trigger has no further part to play. + app.world.removeChildNow(t); // the hide effect, captured rather than added expect(seen).toHaveLength(1); @@ -260,7 +267,6 @@ describe("Trigger level change (#1646)", () => { GLTFScene.prototype.addTo = previousAddTo; app.viewport = original; - app.world.removeChildNow(t); // the load happened, then the reveal — and on the viewport that existed // AFTER the load, not the one captured before it diff --git a/packages/planck-adapter/CHANGELOG.md b/packages/planck-adapter/CHANGELOG.md index ecafb196b..5433a6fd7 100644 --- a/packages/planck-adapter/CHANGELOG.md +++ b/packages/planck-adapter/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 1.4.0 - _2026-09-18_ + +### Fixed +- `getBodyShapes()` is read back from planck rather than derived, which fixes shapes that rotated about the wrong point. 1.3.1 rotated the authored shapes about the renderable's origin, while planck rotates a body about its own **origin**, not its centre of mass, which is a different point again on a body with several fixtures, so the reported shapes swung away from the body they belong to. A shape carrying its offset in `pos` fared worse still, because `Polygon#rotate` moves a shape's points without its `pos`, so the parts of one body came apart. The geometry now comes from the fixtures and the body transform, so there is no pivot to derive and nothing to keep in step with the sprite +- `getBodyShapes()` no longer reports a body's previous geometry after `updateShape()` at an unchanged angle, and no longer pins a removed renderable and its shapes for the adapter's lifetime. The cache was keyed by angle and cleared on no path but one +- `getBodyShapes()` allocates nothing once a body's structure is settled. It is called once per body per frame by the debug overlay, and the previous cache missed on every frame of a body that was actually turning, allocating a fresh shape set each time through the object pools without ever releasing it +- `getBodyShapes()` reports a body's fixtures in the order they were authored. planck's fixture list runs newest-first, so the reported shapes were the reverse of `def.shapes` and an index into one did not address the same shape in the other + +### Changed +- `getBodyShapes()` reports the geometry planck **simulates**, not the shapes as authored. A `Rect` comes back as the `Polygon` planck holds, an `Ellipse` as the circle of average radius it is simulated as, a concave polygon as the hull planck reduces it to, and a shape with `isActive: false` is absent entirely because it has no fixture. The overlay previously drew outlines that did not describe what collides. The authored definitions are unchanged and remain available on `renderable.bodyDef.shapes` + +### Notes +- The peer range stays `>=20.0.0`: nothing here needs a newer engine + ## 1.3.1 - _2026-09-17_ ### Fixed diff --git a/packages/planck-adapter/package.json b/packages/planck-adapter/package.json index 93358c46d..4d1ebcafd 100644 --- a/packages/planck-adapter/package.json +++ b/packages/planck-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@melonjs/planck-adapter", - "version": "1.3.1", + "version": "1.4.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 08c4e2abe..c0cbb9677 100644 --- a/packages/planck-adapter/src/index.ts +++ b/packages/planck-adapter/src/index.ts @@ -169,6 +169,20 @@ export class PlanckAdapter implements PhysicsAdapter { { x: number; y: number } >(); private readonly defMap = new Map(); + /** + * Shapes handed back by {@link PlanckAdapter#getBodyShapes}: one reusable + * array per renderable whose members are refreshed in place, so a spinning + * body costs no allocation on the debug overlay's per-frame path. Dropped + * in `removeBody`, so nothing outlives the body it describes. + */ + private readonly reportedShapes = new Map(); + + /** + * One reusable proxy for reading a polygon fixture's vertices. It aliases + * the shape's own array, so this holds no geometry between calls. + */ + private readonly shapeProxy = new planck.DistanceProxy(); + /** * Offset between `renderable.pos` (top-left in melonJS convention) * and `planck.Body.getPosition()` (the body anchor we register at, @@ -274,6 +288,7 @@ export class PlanckAdapter implements PhysicsAdapter { this.velocityLimits.clear(); this.defMap.clear(); this.posOffsets.clear(); + this.reportedShapes.clear(); } step(dt: number): void { @@ -625,6 +640,9 @@ export class PlanckAdapter implements PhysicsAdapter { this.velocityLimits.delete(renderable); this.defMap.delete(renderable); this.posOffsets.delete(renderable); + // `updateShape` is a remove + add, so this also stops a reshaped + // body reporting its previous geometry + this.reportedShapes.delete(renderable); } } @@ -847,6 +865,11 @@ export class PlanckAdapter implements PhysicsAdapter { // shapes); for the primitive shapes we use, child 0 is the // canonical AABB. planck always returns a defined AABB for // valid child indices. + // + // Measured rather than assumed: the broadphase proxy tracks the + // exact AABB to within 0.001 m (0.03 px at the default scale) and + // does not drift with speed, so there is nothing to gain from + // recomputing it per fixture per frame. const aabb = fixture.getAABB(0); tmpAABB.combine(aabb); if (aabb.lowerBound.x < minX) minX = aabb.lowerBound.x; @@ -876,63 +899,117 @@ export class PlanckAdapter implements PhysicsAdapter { * @param renderable - the renderable whose body shapes to read */ getBodyShapes(renderable: Renderable): readonly BodyShape[] { - const shapes = this.defMap.get(renderable)?.shapes; - if (shapes === undefined) { + // Reported from PLANCK'S OWN fixtures, not by re-rotating the authored + // shapes. The fixture vertices are body-local and the body transform + // supplies the pose, so there is no pivot to derive and nothing to keep + // in sync with `syncFromPhysics`. + // + // Note the transform's origin is the BODY ORIGIN, not its centre of + // mass: Box2D composes `world = xf.p + R(angle) * local`, and + // `getLocalCenter()` is a different point entirely on a compound body. + // Reading it back sidesteps that distinction rather than encoding it. + // + // It also reports what actually collides, which the authored shapes do + // not: an Ellipse is simulated as a circle of the average radius, a + // concave polygon is hulled, and a shape with `isActive: false` has no + // fixture at all. + const body = this.bodyMap.get(renderable); + if (body === undefined) { return []; } - const angle = this.getAngle(renderable); - if (angle === 0) { - // much the commonest case, and the one that must stay allocation - // free: hand back the authored array exactly as before - this.rotatedShapes.delete(renderable); - return shapes; - } - const cached = this.rotatedShapes.get(renderable); - if (cached !== undefined && cached.angle === angle) { - return cached.shapes; + const xf = body.getTransform(); + const cos = xf.q.c; + const sin = xf.q.s; + const ox = this.m2px(xf.p.x) - renderable.pos.x; + const oy = this.m2px(xf.p.y) - renderable.pos.y; + + let cached = this.reportedShapes.get(renderable); + let i = 0; + // the fixture list is newest-first, so it runs opposite to `def.shapes` + const fixtures: planck.Fixture[] = []; + for (let f = body.getFixtureList(); f; f = f.getNext()) { + fixtures.push(f); } - const rotated = this.rotateShapes(shapes, angle); - this.rotatedShapes.set(renderable, { angle, shapes: rotated }); - return rotated; - } + fixtures.reverse(); - /** - * Rotated copies of the authored shapes, keyed by renderable, with the - * angle they were built for. Rebuilt only when the body has actually - * turned — a scene of unrotated bodies never allocates, and a spinning one - * allocates once per angle change rather than once per read. - */ - private readonly rotatedShapes = new Map< - Renderable, - { angle: number; shapes: BodyShape[] } - >(); + // rebuilt only when the STRUCTURE changes — the pose below is refreshed + // in place, so a spinning body allocates nothing per frame + if (cached === undefined || cached.length !== fixtures.length) { + cached = []; + this.reportedShapes.set(renderable, cached); + } - /** - * Rotate the authored shapes to the body's current pose. - * - * The shapes are rotated about the body's own origin, which is where the - * engine rotates it, so the result stays in the renderable-local frame the - * contract promises. - * @param shapes - the authored shape definitions - * @param angle - the body's current angle, in radians - * @returns fresh shapes at that angle - */ - private rotateShapes( - shapes: readonly BodyShape[], - angle: number, - ): BodyShape[] { - const pivot = new Vector2d(0, 0); - return shapes.map((shape) => { - // `clone()` keeps each shape's own type — a Rect rotated off-axis - // becomes a Polygon, which is what Rect#toPolygon is for; an - // Ellipse has no rotated form and is returned as it came - if (shape instanceof Ellipse) { - return shape; + for (const fixture of fixtures) { + const shape = fixture.getShape(); + if (shape.getType() === "circle") { + // structurally typed rather than cast to `CircleShape`: that + // name resolves to planck's deprecated factory overload, and + // these two accessors are all this needs + const circle = shape as unknown as { + getCenter(): { x: number; y: number }; + getRadius(): number; + }; + const c = circle.getCenter(); + const cx = this.m2px(c.x); + const cy = this.m2px(c.y); + const r = this.m2px(circle.getRadius()); + const x = ox + (cos * cx - sin * cy); + const y = oy + (sin * cx + cos * cy); + const existing = cached[i]; + if (existing instanceof Ellipse) { + existing.pos.set(x, y); + existing.radiusV.set(r, r); + } else { + cached[i] = new Ellipse(x, y, r * 2, r * 2); + } + i++; + continue; } - const rotated = shape instanceof Rect ? shape.toPolygon() : shape.clone(); - rotated.rotate(angle, pivot); - return rotated; - }); + // `computeDistanceProxy` is the public way to reach a polygon's + // vertices; it aliases the shape's own array rather than copying + const proxy = this.shapeProxy; + shape.computeDistanceProxy(proxy, 0); + const count = proxy.getVertexCount(); + const existing = cached[i]; + const poly = + existing instanceof Polygon && existing.points.length === count + ? existing + : undefined; + if (poly !== undefined) { + for (let v = 0; v < count; v++) { + const p = proxy.getVertex(v); + const px = this.m2px(p.x); + const py = this.m2px(p.y); + poly.points[v].set( + ox + (cos * px - sin * py), + oy + (sin * px + cos * py), + ); + } + poly.pos.set(0, 0); + poly.recalc(); + poly.updateBounds(); + } else { + const pts: Vector2d[] = []; + for (let v = 0; v < count; v++) { + const p = proxy.getVertex(v); + const px = this.m2px(p.x); + const py = this.m2px(p.y); + pts.push( + new Vector2d( + ox + (cos * px - sin * py), + oy + (sin * px + cos * py), + ), + ); + } + cached[i] = new Polygon( + 0, + 0, + pts as unknown as ConstructorParameters[2], + ); + } + i++; + } + return cached; } isGrounded(renderable: Renderable): boolean { diff --git a/packages/planck-adapter/tests/planck-adapter.spec.ts b/packages/planck-adapter/tests/planck-adapter.spec.ts index bd6776c15..21aeb62d4 100644 --- a/packages/planck-adapter/tests/planck-adapter.spec.ts +++ b/packages/planck-adapter/tests/planck-adapter.spec.ts @@ -13,6 +13,7 @@ import { Application, boot, collision, + Polygon, Rect, Renderable, Vector2d, @@ -604,16 +605,28 @@ describe("PlanckAdapter — feature parity with BuiltinAdapter", () => { expect(bounds.max.y).toBeGreaterThan(24); }); - it("returns the original shape definitions", () => { + it("reports the geometry planck actually simulates, not the authored shape", () => { + // Deliberately NOT the authored objects. The engine is the source + // of truth here: an `Ellipse` is simulated as a circle of the + // average radius, a concave polygon is hulled, and a shape with + // `isActive: false` has no fixture at all — reporting the authored + // list would draw hitboxes that do not describe what collides. + // A `Rect` therefore comes back as the `Polygon` planck holds. + // The authored definitions remain available on `bodyDef.shapes`. const rect = new Rect(0, 0, 32, 32); const r = new Renderable(100, 100, 32, 32); - adapter.addBody(r, { - type: "dynamic", - shapes: [rect], - }); + adapter.addBody(r, { type: "dynamic", shapes: [rect] }); const shapes = adapter.getBodyShapes(r); expect(shapes.length).toEqual(1); - expect(shapes[0]).toEqual(rect); + const poly = shapes[0] as Polygon; + expect(poly.points).toHaveLength(4); + // same footprint, in renderable-local coordinates + const xs = poly.points.map((v) => poly.pos.x + v.x); + const ys = poly.points.map((v) => poly.pos.y + v.y); + expect(Math.min(...xs)).toBeCloseTo(0, 0); + expect(Math.min(...ys)).toBeCloseTo(0, 0); + expect(Math.max(...xs)).toBeCloseTo(32, 0); + expect(Math.max(...ys)).toBeCloseTo(32, 0); }); }); diff --git a/packages/planck-adapter/tests/rotated-body-shapes.spec.ts b/packages/planck-adapter/tests/rotated-body-shapes.spec.ts index 53ea2213a..33942bb33 100644 --- a/packages/planck-adapter/tests/rotated-body-shapes.spec.ts +++ b/packages/planck-adapter/tests/rotated-body-shapes.spec.ts @@ -13,7 +13,17 @@ * where a rotated body's shapes are gets the wrong answer. */ -import { Application, boot, Rect, Renderable, video, World } from "melonjs"; +import { + Application, + Bounds, + boot, + Polygon, + Rect, + Renderable, + Vector2d, + video, + World, +} from "melonjs"; import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { PlanckAdapter } from "../src/index"; @@ -131,4 +141,190 @@ describe("PlanckAdapter — getBodyShapes() follows the body's rotation", () => const r = new Renderable(0, 0, 10, 10); expect(adapter.getBodyShapes(r)).toEqual([]); }); + + // ── multi-shape bodies under rotation (the reported regression) ──── + + /** + * Every test above uses ONE shape whose offset lives at the origin, which + * is exactly why they passed while this was broken. A body built from + * SEVERAL shapes, offset away from the origin, is the case that bites — + * and a shape can carry that offset two ways, in its `points` or in its + * `pos`, which the old implementation treated differently. + */ + describe("several shapes, offset from the origin", () => { + /** two triangles meeting along a shared edge, offset in `points` */ + const inPoints = () => [ + new Polygon(0, 0, [ + new Vector2d(-50, -90), + new Vector2d(0, -120), + new Vector2d(0, -60), + ]), + new Polygon(0, 0, [ + new Vector2d(0, -120), + new Vector2d(50, -90), + new Vector2d(0, -60), + ]), + ]; + + /** the SAME geometry, with the offset carried in `pos` instead */ + const inPos = () => [ + new Polygon(-50, -120, [ + new Vector2d(0, 30), + new Vector2d(50, 0), + new Vector2d(50, 60), + ]), + new Polygon(0, -120, [ + new Vector2d(0, 0), + new Vector2d(50, 30), + new Vector2d(0, 60), + ]), + ]; + + const bodyWith = (shapes: Polygon[]) => { + const r = new Renderable(200, 200, 100, 240); + world.addChild(r); + adapter.addBody(r, { type: "static", shapes }); + return r; + }; + + /** + * every reported vertex, in renderable-local space + * @param r - the renderable whose body to read + * @returns the flattened vertex list + */ + const points = (r: Renderable) => { + const out: { x: number; y: number }[] = []; + for (const s of adapter.getBodyShapes(r)) { + const poly = s as Polygon; + if (!poly.points) continue; + for (const v of poly.points) { + out.push({ x: poly.pos.x + v.x, y: poly.pos.y + v.y }); + } + } + return out; + }; + + it("reports the same geometry however the offset is carried", () => { + // `pos + points` is the shape's geometry; which half holds the + // offset is an authoring detail and must not change the answer + const a = bodyWith(inPoints()); + const b = bodyWith(inPos()); + adapter.setAngle(a, 0.7); + adapter.setAngle(b, 0.7); + + const pa = points(a).sort((p, q) => p.x - q.x || p.y - q.y); + const pb = points(b).sort((p, q) => p.x - q.x || p.y - q.y); + expect(pa).toHaveLength(pb.length); + for (let i = 0; i < pa.length; i++) { + expect(pa[i].x).toBeCloseTo(pb[i].x, 3); + expect(pa[i].y).toBeCloseTo(pb[i].y, 3); + } + world.removeChildNow(a); + world.removeChildNow(b); + }); + + it("actually turns the geometry, by exactly the angle asked for", () => { + // The guard the rest of this block does not give: shapes that never + // rotated would still agree across authoring forms, still stay + // joined, and still sit inside an equally unrotated AABB. + // + // Measured on the angle of a vector BETWEEN two vertices, which is + // independent of whatever point the rotation happens about — the + // two engines do not agree on that (matter turns about the centre + // of mass, planck about the body origin) and neither is wrong. + const r = bodyWith(inPoints()); + adapter.setAngle(r, 0); + const a0 = points(r); + const before = Math.atan2(a0[1].y - a0[0].y, a0[1].x - a0[0].x); + adapter.setAngle(r, Math.PI / 2); + const a90 = points(r); + expect(a90.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`)).not.toEqual( + a0.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`), + ); + const after = Math.atan2(a90[1].y - a90[0].y, a90[1].x - a90[0].x); + // wrapped into (-π, π] + let delta = after - before; + while (delta <= -Math.PI) delta += Math.PI * 2; + while (delta > Math.PI) delta -= Math.PI * 2; + expect(delta).toBeCloseTo(Math.PI / 2, 3); + world.removeChildNow(r); + }); + + it("keeps every shape inside the body's own AABB", () => { + // The cross-check that catches a wrong pivot: `getBodyAABB` is + // engine truth and travels with the body, so shapes rotated about + // the wrong point escape it. Deliberately NOT "the centroid does + // not move" — the two engines rotate about different points (matter + // about the centre of mass, planck about the body origin), and a + // pivot that is not the centroid moves the centroid legitimately. + const r = bodyWith(inPoints()); + for (const angle of [0, 0.6, 1.9, -2.4]) { + adapter.setAngle(r, angle); + const aabb = adapter.getBodyAABB?.(r, new Bounds()); + expect(aabb).toBeDefined(); + for (const p of points(r)) { + expect(p.x).toBeGreaterThanOrEqual(aabb!.left - 1); + expect(p.x).toBeLessThanOrEqual(aabb!.right + 1); + expect(p.y).toBeGreaterThanOrEqual(aabb!.top - 1); + expect(p.y).toBeLessThanOrEqual(aabb!.bottom + 1); + } + } + world.removeChildNow(r); + }); + + it("keeps the two shapes joined along their shared edge", () => { + // the reported symptom: the parts of one body drifting apart + const r = bodyWith(inPos()); + adapter.setAngle(r, 0.9); + const p = points(r); + // every vertex must have a partner from the other shape nearby; + // it is enough that the cloud stays connected + const spread = Math.max(...p.map((q) => Math.hypot(q.x, q.y))); + expect(spread).toBeLessThan(200); + world.removeChildNow(r); + }); + + it("reports the NEW shapes after updateShape at the same angle", () => { + // the cache keys on the angle, so swapping the shapes while the + // body holds still handed back the old geometry + const r = bodyWith(inPoints()); + adapter.setAngle(r, 0.5); + const first = points(r).length; + adapter.updateShape(r, [ + new Polygon(0, 0, [ + new Vector2d(0, 0), + new Vector2d(10, 0), + new Vector2d(10, 10), + new Vector2d(0, 10), + ]), + ]); + adapter.setAngle(r, 0.5); + expect(points(r).length).not.toBe(first); + world.removeChildNow(r); + }); + + it("forgets a body once it is removed", () => { + // the cache is keyed by renderable and nothing cleared it, so a + // destroyed renderable and its shapes stayed pinned + const r = bodyWith(inPoints()); + adapter.setAngle(r, 0.4); + expect(adapter.getBodyShapes(r).length).toBeGreaterThan(0); + adapter.removeBody(r); + expect(adapter.getBodyShapes(r)).toEqual([]); + world.removeChildNow(r); + }); + + it("does not allocate fresh shapes on every call", () => { + // the debug overlay calls this once per body per frame, and the + // angle is different every frame for anything actually spinning + const r = bodyWith(inPoints()); + adapter.setAngle(r, 0.3); + const first = adapter.getBodyShapes(r); + adapter.setAngle(r, 0.30001); + const second = adapter.getBodyShapes(r); + expect(second).toBe(first); + expect(second[0]).toBe(first[0]); + world.removeChildNow(r); + }); + }); });