Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 0 additions & 41 deletions .github/workflows/main-docs.yml

This file was deleted.

21 changes: 8 additions & 13 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
name: Build & Test
# Docs-only PRs (markdown / media changes) skip these jobs via `paths-ignore`.
# Required `lint`/`test` checks are satisfied for those PRs by the shim in
# `main-docs.yml`, which mirrors this file's path filters and emits matching
# job names that succeed immediately. Together the two form a partition —
# exactly one runs per push/PR.
# Runs lint + test on every push/PR — no path-filtered shim partition.
# The previous two-workflow setup (`main.yml` + `main-docs.yml`) was
# racy on mixed PRs (PRs touching both `.md` and code): GitHub Actions
# evaluates `paths` / `paths-ignore` against the full PR diff, so a
# mixed PR triggered BOTH workflows, they shared `concurrency.group`,
# and the faster docs-shim cancelled the real CI run while still
# reporting green checks. Docs-only PRs pay ~2 minutes of CI cost as
# the trade-off; cheaper than silently merging a lint failure.
on:
push:
branches: [master]
paths-ignore:
- '*.md'
- 'media/**'
- 'packages/melonjs/DOC_README.md'
pull_request:
types: [opened, synchronize]
paths-ignore:
- '*.md'
- 'media/**'
- 'packages/melonjs/DOC_README.md'

concurrency:
group: ci-${{ github.ref }}
Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,17 @@ Graphics

Sound
- Web Audio support with 3D spatial audio and stereo panning based on [Howler](https://howlerjs.com)
- Built-in procedural audio primitives (envelope-shaped oscillators, white/pink/brown noise) for SFX without sample assets
- Direct `AudioContext` / master-gain accessors for custom WebAudio graphs that mix with the engine's master volume / mute

Physics
- Polygon (SAT) based collision algorithm for accurate detection and response
- Fast Broad-phase collision detection using spatial partitioning (QuadTree)
- Fast broad-phase collision detection using spatial partitioning (QuadTree)
- Raycast and AABB region queries with precise entry geometry
- Collision lifecycle hooks on every `Renderable`
- Collision filtering for optimized automatic collision detection
- Multiple shapes per body for complex hitboxes
- Pluggable `PhysicsAdapter` interface for custom physics via official adapters

Input
- Mouse and Touch device support (with mouse emulation)
Expand Down Expand Up @@ -219,9 +224,12 @@ If you wish to develop your own plugin, we also provide a [plugin template](http

Physics Adapters
-------------------------------------------------------------------------------
Since 19.5, melonJS exposes a `PhysicsAdapter` interface so the same game code can run on either the built-in SAT physics (default) or on a third-party rigid-body engine, selected via the `physic` option on `Application`. Official adapter packages maintained by the melonJS team:
- [matter-adapter](https://github.com/melonjs/melonJS/tree/master/packages/matter-adapter) - [matter-js](https://brm.io/matter-js/) integration with rotational dynamics, constraints, sleeping bodies, continuous collision detection, and raycasts
- [planck-adapter](https://github.com/melonjs/melonJS/tree/master/packages/planck-adapter) - [planck.js](https://piqnt.com/planck.js/) integration (Box2D 2.3.0 port) with native joints, CCD bullet flag, sleeping bodies, raycasts, and per-body gravity scale
Since 19.5, melonJS exposes a `PhysicsAdapter` interface so the same game code can run on either the built-in SAT physics (default) or a third-party rigid-body engine, selected via the `physic` option on `Application`. Two official adapter packages, maintained by the melonJS team:

- **[@melonjs/matter-adapter](https://github.com/melonjs/melonJS/tree/master/packages/matter-adapter)** — [matter-js](https://brm.io/matter-js/) integration. Rotational dynamics, constraints (springs / hinges / pins), sleeping bodies, continuous collision detection, raycasts. Showcased by the [Matter Platformer](https://melonjs.github.io/melonJS/examples/#/platformer-matter) and [Pool (Matter)](https://melonjs.github.io/melonJS/examples/#/pool-matter) examples.
- **[@melonjs/planck-adapter](https://github.com/melonjs/melonJS/tree/master/packages/planck-adapter)** — [planck.js](https://piqnt.com/planck.js/) integration (faithful Box2D 2.3.0 port). Native joints, CCD bullet flag, sleeping bodies, native raycasts, per-body gravity scale. Showcased by the [Neon Plinko (Planck)](https://melonjs.github.io/melonJS/examples/#/plinko-planck) example.

See the [Migrating to the Physics Adapter API](https://github.com/melonjs/melonJS/wiki/Migrating-to-the-Physics-Adapter-API), [Switching Physics Adapters](https://github.com/melonjs/melonJS/wiki/Switching-Physics-Adapters), and [BuiltinAdapter Quirks](https://github.com/melonjs/melonJS/wiki/BuiltinAdapter-Quirks) wiki pages for migration guides and the per-adapter behaviour table.

Installation
-------------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion packages/matter-adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Changelog

## 1.0.0 - _unreleased_
## 1.0.0 - _2026-05-22_

### Added
- **`MatterAdapter.Body`** — published type for `renderable.body` under this adapter. Namespace-merged with the class, defined as `ReturnType<typeof Matter.Body.create> & PhysicsBody`. Lets user code reach matter-native fields (`frictionAir`, `angle`, `angularVelocity`, `torque`, …) via `(this.body as MatterAdapter.Body).frictionAir = 0.02` without importing `matter-js` directly — the matter dependency stays behind the adapter boundary.
Expand Down
91 changes: 56 additions & 35 deletions packages/matter-adapter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,32 +332,17 @@ export class MatterAdapter implements PhysicsAdapter {
// `pos` would spin the sprite around its corner. Pre-translate
// by the centroid-relative offset so the rotation lands on
// the visible center regardless of anchor.
const t = (
renderable as {
currentTransform?: {
identity?: () => unknown;
rotate?: (a: number) => unknown;
translate?: (x: number, y: number) => unknown;
};
}
).currentTransform;
if (
t &&
typeof t.identity === "function" &&
typeof t.rotate === "function" &&
typeof t.translate === "function"
) {
const off2 = this.posOffsets.get(renderable);
const cx = off2 ? -off2.x : 0;
const cy = off2 ? -off2.y : 0;
t.identity();
if (cx !== 0 || cy !== 0) {
t.translate(cx, cy);
t.rotate(body.angle);
t.translate(-cx, -cy);
} else {
t.rotate(body.angle);
}
const t = renderable.currentTransform;
const off2 = this.posOffsets.get(renderable);
const cx = off2 ? -off2.x : 0;
const cy = off2 ? -off2.y : 0;
t.identity();
if (cx !== 0 || cy !== 0) {
t.translate(cx, cy);
t.rotate(body.angle);
t.translate(-cx, -cy);
} else {
t.rotate(body.angle);
}
}
}
Expand Down Expand Up @@ -672,11 +657,50 @@ export class MatterAdapter implements PhysicsAdapter {
x: p.x - (off?.x ?? 0),
y: p.y - (off?.y ?? 0),
});
// Matter caches contact pairs across steps for solver continuity.
// After a discontinuous teleport the cached pair still holds the
// previous penetration depth + normal, and matter's Baumgarte
// position-correction phase applies that on the next step before
// narrow-phase invalidates the pair — yanking the body back
// toward the old contact point by ≈ penetration depth. Builtin
// and planck handle this natively (planck's `b2Body::SetTransform`
// documents "contacts are updated on the next call to
// b2World::Step"); matter doesn't, so do it ourselves.
this.invalidateContactsFor(body);
}
renderable.pos.x = p.x;
renderable.pos.y = p.y;
}

private invalidateContactsFor(body: Matter.Body): void {
// Flush the per-body cached position-correction impulse so matter's
// position-warming mechanism doesn't reapply stale penetration
// data on the next step. Used by setPosition to keep a
// discontinuous teleport from being undone by the solver.
// Zero the per-body cached position-correction impulse. Matter's
// `Resolver.postSolvePosition` applies `body.positionImpulse` to
// the body every step via the position-warming mechanism (for
// solver continuity across frames), independently of whether
// `pairs.list` still has an active contact. After a discontinuous
// teleport that cached impulse contains the OLD penetration
// vector, and reapplying it yanks the body back toward the old
// contact point by ≈ penetration depth. Zeroing it disables the
// warming for one frame — matter will rebuild a fresh impulse
// from genuine contacts on the next step if any exist.
// Note: engine.pairs is left alone deliberately so matter's
// natural pair-lifecycle (Pairs.update marking the stale pair
// as inactive on the next step, then moving it to collisionEnd
// and firing the event) still works.
// `positionImpulse` is a real per-body field but isn't in
// `@types/matter-js` (matter exposes it as a solver internal);
// intersect the public Body type with the field we touch rather
// than discarding the type entirely.
const impulse = (body as Matter.Body & { positionImpulse: Matter.Vector })
.positionImpulse;
impulse.x = 0;
impulse.y = 0;
}

setAngle(renderable: Renderable, angle: number): void {
const body = this.bodyMap.get(renderable);
if (body) {
Expand Down Expand Up @@ -1072,15 +1096,12 @@ export class MatterAdapter implements PhysicsAdapter {
// Matter has no native ellipse — approximate as a circle with the
// average radius. For tall/narrow ellipses this is a poor fit;
// a future improvement could synthesize a polygon hull.
const e = shape as unknown as {
pos: { x: number; y: number };
radiusV?: { x: number; y: number };
radius?: number;
};
const rx = e.radiusV?.x ?? e.radius ?? 1;
const ry = e.radiusV?.y ?? e.radius ?? 1;
const radius = (rx + ry) / 2;
return Matter.Bodies.circle(baseX + e.pos.x, baseY + e.pos.y, radius);
const radius = (shape.radiusV.x + shape.radiusV.y) / 2;
return Matter.Bodies.circle(
baseX + shape.pos.x,
baseY + shape.pos.y,
radius,
);
}
if (shape instanceof Polygon) {
// translate polygon vertices into world-space, then let Matter
Expand Down
48 changes: 48 additions & 0 deletions packages/matter-adapter/tests/matter-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -840,4 +840,52 @@ describe("MatterAdapter — feature parity with BuiltinAdapter", () => {
expect(matches).toContain(r);
});
});

describe("gravityScale emulation", () => {
// matter-js 0.20 has no per-body gravityScale field — the adapter
// emulates it via a counter-force loop tracked in `bodyGravityScale`
// (see `src/index.ts:144`). The stress spec already pins that the
// internal map is drained on removeBody; this test pins the
// behavioural side: `gravityScale: 0` actually nulls out gravity
// so the body doesn't fall. Easy to silently break if the gravity
// application path is refactored.
it("gravityScale: 0 prevents a body from falling under world gravity", () => {
const r = new Renderable(100, 100, 32, 32);
adapter.addBody(r, {
type: "dynamic",
shapes: [new Rect(0, 0, 32, 32)],
gravityScale: 0,
});
const startY = r.pos.y;
for (let i = 0; i < 60; i++) adapter.step(16);
adapter.syncFromPhysics();
expect(Math.abs(r.pos.y - startY)).toBeLessThan(0.1);
});

it("gravityScale: 0.5 falls roughly half as fast as scale: 1", () => {
const rFull = new Renderable(100, 100, 32, 32);
adapter.addBody(rFull, {
type: "dynamic",
shapes: [new Rect(0, 0, 32, 32)],
gravityScale: 1,
});
const rHalf = new Renderable(200, 100, 32, 32);
adapter.addBody(rHalf, {
type: "dynamic",
shapes: [new Rect(0, 0, 32, 32)],
gravityScale: 0.5,
});
for (let i = 0; i < 60; i++) adapter.step(16);
adapter.syncFromPhysics();
const fallFull = rFull.pos.y - 100;
const fallHalf = rHalf.pos.y - 100;
// Half-gravity body falls slower than full-gravity body.
expect(fallHalf).toBeLessThan(fallFull);
// And the ratio is in the ballpark of 0.5 (matter integrates
// over dt so the exact ratio drifts a bit; allow a wide band).
const ratio = fallHalf / fallFull;
expect(ratio).toBeGreaterThan(0.3);
expect(ratio).toBeLessThan(0.7);
});
});
});
Loading
Loading