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
2 changes: 2 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ jobs:
run: pnpm -F @melonjs/tiled-inflate-plugin build
- name: Build spine-plugin
run: pnpm -F @melonjs/spine-plugin build
- name: Build matter-adapter
run: pnpm -F @melonjs/matter-adapter build
- name: Build API docs
run: pnpm doc
- name: Build examples
Expand Down
1 change: 1 addition & 0 deletions packages/matter-adapter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface MatterAdapterOptions {
* });
*/
export class MatterAdapter implements PhysicsAdapter {
readonly physicLabel = "matter";
readonly name = "@melonjs/matter-adapter";
readonly version = __VERSION__;
readonly url = "https://www.npmjs.com/package/@melonjs/matter-adapter";
Expand Down
5 changes: 5 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@

### Fixed
- Physics: **`MatterAdapter.syncFromPhysics` rotates around body centroid, not renderable `pos`** — the per-frame transform sync used `Matrix3d.identity().rotate(body.angle)`, which `Renderable.preDraw` then applies with the pivot at `renderable.pos`. For renderables whose `pos` is the top-left (anchor `(0, 0)`) and whose body shape is centered inside the bounds, this rotated the sprite around its corner instead of its visible center — visible the moment any matter body opted into `fixedRotation: false`. The adapter now pre-translates by the negated `posOffset` (which is exactly the centroid → pos delta tracked at addBody time), so the rotation lands on the visible center regardless of anchor. Latent until now because no first-party example unlocked rotation; the pool-matter ball-spin experiment surfaced it.
- Physics: **`World.step` now runs the physics step under every adapter, not just builtin** — the conditional `if (this.physic === "builtin")` was an off-by-name check that reads as "is this the builtin adapter?" but was set to `"builtin"` for any non-disabled physics regardless of adapter. After the `physicLabel` change below, that comparison would have started returning `false` for matter and silently stopped stepping the simulation. Now reads `if (this.physic !== "none")`. No behavior change against pre-`physicLabel` matter games (those had `physic === "builtin"` and stepped correctly); behavior preserved against the labelled state.
- Physics: **`Body.ignoreGravity` marked `@deprecated`** — the portable equivalent is `gravityScale = 0` (or `bodyDef.gravityScale = 0` at construction, or `body.setGravityScale(0)` at runtime). `ignoreGravity` is read only by `BuiltinAdapter.applyGravity` and `Body.update`'s falling-state machine; `MatterAdapter` silently ignores it. The duplicate check in both call sites is kept (so legacy code that sets `ignoreGravity = true` still works), with a comment noting the redundancy. `Body.update`'s falling/jumping flag update now also gates on `gravityScale !== 0` so floating bodies (`gravityScale: 0`) no longer get mistakenly marked "falling" on a side-on collision.

### Changed
- Physics: **`world.physic` now identifies the active adapter, not just an on/off flag** — was always `"builtin"` (or `"none"` when disabled) regardless of which adapter was wired in. Now reflects each adapter's `physicLabel`: `"builtin"` for `BuiltinAdapter`, `"matter"` for `@melonjs/matter-adapter`, or whatever short identifier a third-party adapter chooses. Lets user code branch on `app.world.physic === "matter"` (etc.) without importing the concrete adapter class. New optional `PhysicsAdapter.physicLabel` field; adapters predating it fall back to `"builtin"` so existing code keeps working.

### Performance
- No major perf items this release — the focus was the physics adapter abstraction. Existing rendering wins from 19.3 and 19.4 continue to apply on both adapters.
Expand Down
29 changes: 16 additions & 13 deletions packages/melonjs/src/application/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,22 @@
*/
function resolvePhysicSetting(physic: ApplicationSettings["physic"]): {
adapter: PhysicsAdapter | undefined;
legacyString: string;
physicLabel: string;
} {
if (physic === "none") {
return { adapter: undefined, legacyString: "none" };
return { adapter: undefined, physicLabel: "none" };
}
if (physic === undefined || physic === "builtin") {

Check warning on line 73 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, the types have no overlap

Check warning on line 73 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, the types have no overlap

Check warning on line 73 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, the types have no overlap
return { adapter: undefined, legacyString: "builtin" };
return { adapter: undefined, physicLabel: "builtin" };
}
// instance or { adapter } object — extract and pass through
// instance or { adapter } object — extract and pass through. The
// adapter's `physicLabel` becomes `world.physic` so user code can
// branch on `world.physic === "matter"` (etc.) without importing the
// concrete adapter class. Falls back to "builtin" for adapters
// predating the `physicLabel` field.
const adapter =
typeof physic === "object" && "adapter" in physic ? physic.adapter : physic;
return { adapter, legacyString: "builtin" };
return { adapter, physicLabel: adapter?.physicLabel ?? "builtin" };

Check warning on line 83 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 83 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 83 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary optional chain on a non-nullish value
}

/**
Expand Down Expand Up @@ -311,7 +315,7 @@
this.settings = settings;

// identify parent element and/or the html target for resizing
this.parentElement = device.getElement(this.settings.parent!);

Check warning on line 318 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 318 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 318 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion
if (typeof this.settings.scaleTarget !== "undefined") {
this.settings.scaleTarget = device.getElement(this.settings.scaleTarget);
}
Expand Down Expand Up @@ -416,9 +420,7 @@
// `new BuiltinAdapter({ gravity })` or
// `new MatterAdapter()` from `@melonjs/matter-adapter`), or the
// explicit form `{ adapter: PhysicsAdapter }`.
const { adapter, legacyString } = resolvePhysicSetting(
this.settings.physic,
);
const { adapter, physicLabel } = resolvePhysicSetting(this.settings.physic);

// create a new physic world wired to the resolved adapter
this.world = new World(
Expand All @@ -431,11 +433,12 @@

// set the reference to this application instance
this.world.app = this;
// preserve the legacy string for the World.step() no-op gate
// ("builtin" runs the simulation; "none" skips it). Custom-adapter
// users still get "builtin" stepping — they opted in by passing
// the adapter instance.
this.world.physic = legacyString;
// `world.physic` carries the active adapter's identifier
// (`"builtin"`, `"matter"`, third-party label, or the reserved
// `"none"` when physics is disabled). User code branches on it;
// `World.step` short-circuits the simulation only when the value
// is `"none"`.
this.world.physic = physicLabel;
this.world.gpuTilemap = this.settings.gpuTilemap;

// report the active physics adapter once the world is wired —
Expand All @@ -447,7 +450,7 @@
if (this.settings.consoleHeader) {
if (this.world.physic === "none") {
console.log("physics: disabled");
} else if (this.world.adapter) {

Check warning on line 453 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 453 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 453 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, value is always truthy
const a = this.world.adapter as {
constructor: { name: string };
name?: string;
Expand Down Expand Up @@ -512,7 +515,7 @@
// point to the current active stage "default" camera
const current = state.get();
if (typeof current !== "undefined") {
this.viewport = current.cameras.get("default")!;

Check warning on line 518 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 518 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 518 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion
}

// publish reset notification
Expand Down Expand Up @@ -619,21 +622,21 @@
globalThis.removeEventListener("resize", this._onResize);
globalThis.removeEventListener(
"orientationchange",
this._onOrientationChange!,

Check warning on line 625 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 625 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 625 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion
);
globalThis.removeEventListener("scroll", this._onScroll!);

Check warning on line 627 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 627 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 627 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion
if (device.screenOrientation) {
globalThis.screen.orientation.onchange = null;
}
}

// destroy the world and all its children
if (this.world) {

Check warning on line 634 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 634 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 634 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, value is always truthy
this.world.destroy();
}

// remove the canvas from the DOM
if (removeCanvas && this.renderer) {

Check warning on line 639 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 639 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 639 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, value is always truthy
const canvas = this.renderer.getCanvas();
if (canvas.parentElement) {
canvas.parentElement.removeChild(canvas);
Expand Down Expand Up @@ -752,7 +755,7 @@
// update all objects (and pass the elapsed time since last frame)
this.isDirty = this.world.update(this.updateDelta);
this.isDirty =
state.current()!.update(this.updateDelta) || this.isDirty;

Check warning on line 758 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 758 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 758 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion

this.lastUpdate = globalThis.performance.now();
this.updateAverageDelta = this.lastUpdate - this.lastUpdateStart;
Expand Down
13 changes: 12 additions & 1 deletion packages/melonjs/src/application/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,18 @@ export type ApplicationSettings = {
blendMode: BlendMode;

/**
* the physic system to use (default: "builtin", or "none" to disable builtin physic)
* The physics system to use. Accepts:
* - `"builtin"` (default) — the built-in SAT physics adapter
* - `"none"` — disables physics; `World.step` skips the simulation,
* the world container behaves like a pure scene graph
* - a `PhysicsAdapter` instance — e.g. `new MatterAdapter()` from
* `@melonjs/matter-adapter`, or any third-party adapter
* - `{ adapter: PhysicsAdapter }` — explicit form, reserved for
* future per-app physics options
*
* The adapter's `physicLabel` becomes `world.physic` so user code
* can branch on the active engine without importing the concrete
* adapter class (`app.world.physic === "matter"`, etc.).
* @default "builtin"
*/
physic: PhysicsType;
Expand Down
25 changes: 25 additions & 0 deletions packages/melonjs/src/physics/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,31 @@ export interface PhysicsAdapter {
/** advertised capabilities; user code may branch on these */
readonly capabilities: AdapterCapabilities;

/**
* Short adapter identifier exposed as `world.physic`. User code uses
* it to branch on which physics implementation is active without
* importing the adapter class — e.g.
*
* ```ts
* if (app.world.physic === "matter") {
* // matter-only setup (constraints, etc.)
* }
* ```
*
* Convention: a single lowercase token. The first-party labels are
* `"builtin"` (default — `BuiltinAdapter`) and `"matter"`
* (`@melonjs/matter-adapter`). Third-party adapters should pick a
* concise identifier that won't collide with future official ones.
*
* The reserved value `"none"` is set on `world.physic` only when the
* user passes `physic: "none"` to `Application` to disable physics
* entirely; adapters should not use it.
*
* Defaults to `"builtin"` if an adapter doesn't declare its own — keeps
* legacy adapters wired in before this field was added still working.
*/
readonly physicLabel?: string;

/**
* Optional display name reported on the startup banner. Defaults to
* the adapter class name. Third-party packages typically set this to
Expand Down
17 changes: 15 additions & 2 deletions packages/melonjs/src/physics/builtin/body.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,19 @@ export default class Body {
this.gravityScale = 1.0;

/**
* If true this body won't be affected by the world gravity
* If true this body won't be affected by the world gravity.
* @public
* @see {@link World.gravity}
* @type {boolean}
* @default false
* @deprecated since 19.5.0 — use `gravityScale = 0` (or
* `bodyDef.gravityScale = 0` at construction, or
* `body.setGravityScale(0)` at runtime) instead. `gravityScale`
* is part of the portable {@link PhysicsBody} surface and works
* on every adapter; `ignoreGravity` is a builtin-only field that
* the matter adapter silently ignores. The two-field check
* (`!ignoreGravity && gravityScale !== 0`) used by builtin is
* redundant — set `gravityScale = 0` and both branches agree.
*/
this.ignoreGravity = false;

Expand Down Expand Up @@ -843,7 +851,12 @@ export default class Body {
}
}

if (overlap.y !== 0 && !this.ignoreGravity) {
// Update falling/jumping flags only for gravity-affected bodies.
// `ignoreGravity` is the legacy opt-out; `gravityScale === 0` is the
// portable equivalent (see Body#gravityScale). Either disables the
// state machine — a hovering platform or a free-floating projectile
// shouldn't be marked "falling" on a head-on side collision.
if (overlap.y !== 0 && !this.ignoreGravity && this.gravityScale !== 0) {
// cancel the falling an jumping flags if necessary
const dir = this.falling === true ? 1 : this.jumping === true ? -1 : 0;
this.falling = overlap.y >= dir;
Expand Down
16 changes: 15 additions & 1 deletion packages/melonjs/src/physics/builtin/builtin-adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ import Detector from "./detector.js";
* @category Physics
*/
export default class BuiltinAdapter {
/**
* Short adapter identifier exposed as `world.physic`. Lets user code
* branch on the active physics implementation without importing the
* concrete adapter class.
* @type {string}
* @default "builtin"
*/
physicLabel = "builtin";

/**
* @param {AdapterOptions} [options]
*/
Expand Down Expand Up @@ -481,7 +490,12 @@ export default class BuiltinAdapter {
* @param {Body} body
*/
applyGravity(body) {
if (!body.ignoreGravity && body.gravityScale !== 0) {
// `ignoreGravity` is deprecated in favor of `gravityScale = 0`
// (see Body#ignoreGravity JSDoc). Keep both checks until the
// legacy field is removed — `gravityScale = 0` is the portable
// path that also works under matter; `ignoreGravity = true` is
// the legacy path that only this adapter reads.
if (body.gravityScale !== 0 && !body.ignoreGravity) {
body.force.x += body.mass * this.gravity.x * body.gravityScale;
body.force.y += body.mass * this.gravity.y * body.gravityScale;
}
Expand Down
26 changes: 23 additions & 3 deletions packages/melonjs/src/physics/world.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,29 @@ export default class World extends Container {
this.app = undefined;

/**
* the physic engine used by melonJS
* Identifier of the active physics adapter, taken from the
* adapter's `physicLabel` field at `Application` construction —
* `"builtin"` (default — `BuiltinAdapter`), `"matter"`
* (`@melonjs/matter-adapter`), or a third-party label.
* The reserved value `"none"` is set when physics is disabled via
* `physic: "none"` in `ApplicationSettings`; `World.step` skips
* the simulation entirely under that label, and the rest of the
* world container behaves like a pure scene graph.
*
* User code can branch on the value without importing the
* adapter class:
*
* ```ts
* if (app.world.physic === "matter") {
* // matter-only setup (constraints, native queries, …)
* }
* ```
* @see ApplicationSettings.physic
* @see PhysicsAdapter.physicLabel
* @type {string}
* @default "builtin"
* @example
* // disable builtin physic
* // disable physics entirely
* app.world.physic = "none";
*/
this.physic = "builtin";
Expand Down Expand Up @@ -284,7 +301,10 @@ export default class World extends Container {
* @param {number} dt - the time passed since the last frame update
*/
step(dt) {
if (this.physic === "builtin") {
// `physic` is the active adapter's identifier ("builtin", "matter",
// etc.) or the sentinel "none" when physics is disabled. The step
// runs under any adapter; only "none" skips it.
if (this.physic !== "none") {
this.adapter.step(dt);
this.adapter.syncFromPhysics();
}
Expand Down
33 changes: 33 additions & 0 deletions packages/melonjs/tests/application.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,39 @@ describe("Application", () => {
expect(app.world.physic).toBe("builtin");
});

it("custom adapter's `physicLabel` propagates to world.physic", () => {
// A third-party adapter declares its own identifier. We don't
// have a full custom-adapter implementation here, just an
// object that satisfies the minimum the wiring touches.
const customAdapter = Object.assign(
new BuiltinAdapter({ gravity: new Vector2d(0, 0.5) }),
{ physicLabel: "stub" },
);
const app = new Application(320, 240, {
parent: "screen",
renderer: video.CANVAS,
consoleHeader: false,
physic: customAdapter,
});
expect(app.world.adapter).toBe(customAdapter);
expect(app.world.physic).toBe("stub");
});

it("adapter without `physicLabel` falls back to 'builtin'", () => {
// Legacy / third-party adapters that predate the field. We strip
// `physicLabel` from a BuiltinAdapter instance to simulate.
const legacyAdapter = new BuiltinAdapter();
delete legacyAdapter.physicLabel;
const app = new Application(320, 240, {
parent: "screen",
renderer: video.CANVAS,
consoleHeader: false,
physic: legacyAdapter,
});
expect(app.world.adapter).toBe(legacyAdapter);
expect(app.world.physic).toBe("builtin");
});

it("each Application instance gets its own adapter (no sharing)", () => {
const app1 = new Application(320, 240, {
parent: "screen",
Expand Down