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
1 change: 1 addition & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- **The six remaining CSS blend modes now work on both GPU backends** ([#1318](https://github.com/melonjs/melonJS/issues/1318)): `overlay`, `hard-light`, `color-dodge`, `color-burn`, `soft-light` and `difference`. All thirteen modes the engine names are now supported by all three renderers, so the Canvas fallback is no longer the most capable backend for blending. These six cannot be expressed as `src * sfactor + dst * dfactor` — each needs a per-pixel branch, a division or a `sqrt` on the *destination* — and neither WebGL 2 nor WebGPU can read the destination in a fragment shader, so each draw captures the destination, renders to an offscreen target and composites through a shader carrying both a GLSL and a WGSL body. Nothing changes in how you use them: set `sprite.blendMode = "overlay"` or call `renderer.setBlendMode("overlay")` as before, on any renderable — sprites, text, image layers, particles, Tiled layers — or on a direct shape fill. `setBlendMode` now reports these six as applied rather than falling back, so the capability probe pattern (comparing the return value against the request) reports them supported

### Fixed
- **Pointer events missed every non-floating renderable once the world was offset** ([#1605](https://github.com/melonjs/melonJS/pull/1605)). `Camera2d.localToWorld` subtracts `world.pos`, so a pointer's `gameWorldX/Y` are level-local, while a non-floating renderable's bounds are absolute and include that offset. With the world at the origin the two spaces coincide and nothing is wrong — move it, as a game does to centre a level, and hit detection stopped firing entirely for those regions. Not a coordinate drift: the handler was never called. Floating regions are indexed in level-local space and keep the original path, so a screen-pinned HUD is unaffected either way (thanks @Vareniel)
- **`ParticleEmitter.blendMode` did nothing.** An emitter draws no pixels of its own — each particle is a renderable carrying its own blend mode, copied from `settings.blendMode` when it is born — so assigning `emitter.blendMode`, which is what every other renderable takes and the obvious thing to write, reached nothing at all. Particles kept rendering `"normal"` and it read as particles not supporting blend modes. The emitter now fans a changed mode out on its next update: to `settings.blendMode` so particles emitted afterwards inherit it, and to the particles already alive so the switch is visible immediately rather than fading in over a particle lifetime. Detected with one string compare per emitter per frame rather than an accessor on `Renderable`, which every renderable in the scene would have paid for on every `preDraw`
- **`darken` and `lighten` were wrong for any translucent source.** Fixed-function `MIN`/`MAX` compute `min(src, dst)` and nothing else, so there was nowhere to put the `(1 - srcAlpha) * dst` term source-over contributes after the blend — the backdrop's share simply vanished. At 60% opacity `darken` came out 84/255 off the W3C result, and a white `lighten` glow over a light backdrop rendered *completely invisible* rather than brightening it. Both now composite through the same shader path as the other advanced modes and are exact at any alpha. They cost a capture and a composite per draw where they were previously free, which is the price of being correct; `multiply`, `screen` and `exclusion` stay on the fixed-function path, where measurement confirms they are already exact (the "approximate for a translucent source" comments they carried were wrong)
- **Test harness: a renderer that threw during construction reported as a skip, not a failure.** `getWebGLRenderer` caught every error and treated it as "this machine has no WebGL", so engine breakage turned the whole WebGL suite green-by-skipping. `webgl_available.spec.js` was the backstop, but it only runs when the full suite does — anyone running a subset lost the signal entirely, which is how the program-cache bug above stayed invisible to 47 spec files. The helper now classifies: a genuinely missing GL stack still skips, anything else fails loudly and carries the original error. Not shipped code, but it is why two real bugs in this release were found by looking at a screenshot rather than by the suite
Expand Down
45 changes: 41 additions & 4 deletions packages/melonjs/src/input/pointerevent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,10 @@ function dispatchEvent(normalizedEvents: Pointer[]): boolean {
lastTimeStamp = pointer.event!.timeStamp;
}

const worldOffset = _app.world.pos;
const absoluteWorldX = pointer.gameWorldX + worldOffset.x;
const absoluteWorldY = pointer.gameWorldY + worldOffset.y;

currentPointer.pos.set(pointer.gameWorldX, pointer.gameWorldY);
currentPointer.setSize(pointer.width, pointer.height);

Expand All @@ -323,13 +327,38 @@ function dispatchEvent(normalizedEvents: Pointer[]): boolean {
emit(POINTERMOVE, pointer);
}

// fetch valid candiates from the game world container
// Fetch candidates in level-local coordinates. Floating regions are indexed
// in this coordinate space because Camera2d.localToWorld removes the root
// world offset.
let candidates = _app.world.broadphase.retrieve(
currentPointer,
(a: any, b: any) => _app.world._sortReverseZ(a, b),
undefined,
);

// Non-floating renderable bounds include the root world offset. This offset
// is introduced when a level is centered by flex-height or flex-width, so a
// second query is required to find their absolute broadphase entries.
if (worldOffset.x !== 0 || worldOffset.y !== 0) {
currentPointer.pos.set(absoluteWorldX, absoluteWorldY);
const absoluteCandidates = _app.world.broadphase.retrieve(
currentPointer,
(a: any, b: any) => _app.world._sortReverseZ(a, b),
undefined,
);
for (const candidate of candidates) {
if (
candidate.isFloating === true &&
!absoluteCandidates.includes(candidate)
) {
absoluteCandidates.push(candidate);
}
}
candidates = absoluteCandidates.sort((a, b) =>
_app.world._sortReverseZ(a, b),
);
}

// add the main game viewport to the list of candidates
candidates = candidates.concat([_app.viewport]);

Expand All @@ -355,11 +384,19 @@ function dispatchEvent(normalizedEvents: Pointer[]): boolean {
// within the region ancestor container
if (typeof ancestor !== "undefined") {
const parentBounds = ancestor.getBounds();
pointer.gameLocalX = pointer.gameX - parentBounds.x;
pointer.gameLocalY = pointer.gameY - parentBounds.y;
if (region.isFloating === true) {
pointer.gameLocalX = pointer.gameX - parentBounds.x;
pointer.gameLocalY = pointer.gameY - parentBounds.y;
} else {
pointer.gameLocalX = absoluteWorldX - parentBounds.x;
pointer.gameLocalY = absoluteWorldY - parentBounds.y;
}
}

const eventInBounds = bounds.contains(pointer.gameX, pointer.gameY);
const eventInBounds =
region.isFloating === true
? bounds.contains(pointer.gameX, pointer.gameY)
: bounds.contains(absoluteWorldX, absoluteWorldY);

switch (pointer.type) {
case POINTER_MOVE[0]:
Expand Down
76 changes: 76 additions & 0 deletions packages/melonjs/tests/input.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,85 @@ describe("input", () => {
afterEach(() => {
// clean up the game world
app.world.reset();
app.world.pos.set(0, 0, 0);
app.world.broadphase.clear();
});

it.each([
{
name: "vertical world offset used by flex-height",
worldOffset: [0, 120],
renderablePosition: [50, 100],
screenPosition: [70, 240],
expectedWorldPosition: [70, 120],
},
{
name: "horizontal world offset used by flex-width",
worldOffset: [120, 0],
renderablePosition: [100, 50],
screenPosition: [240, 70],
expectedWorldPosition: [120, 70],
},
])(
"should trigger pointerdown with a $name",
({
worldOffset,
renderablePosition,
screenPosition,
expectedWorldPosition,
}) => {
const renderable = new Renderable(
renderablePosition[0],
renderablePosition[1],
40,
40,
);
renderable.anchorPoint.set(0, 0);
renderable.isKinematic = false;

app.world.addChild(renderable);
app.world.pos.set(worldOffset[0], worldOffset[1], 0);
renderable.updateBounds(true);
app.world.broadphase.clear();
app.world.broadphase.insertContainer(app.world);

let receivedPointer;
input.registerPointerEvent("pointerdown", renderable, (pointer) => {
receivedPointer = pointer;
});

try {
const canvas = app.renderer.getCanvas();
const canvasBounds = canvas.getBoundingClientRect();
canvas.dispatchEvent(
new PointerEvent("pointerdown", {
clientX:
canvasBounds.left +
(screenPosition[0] * canvasBounds.width) / canvas.width,
clientY:
canvasBounds.top +
(screenPosition[1] * canvasBounds.height) / canvas.height,
pointerId: 1,
width: 1,
height: 1,
isPrimary: true,
bubbles: true,
}),
);

expect(receivedPointer).toBeDefined();
expect(receivedPointer.gameWorldX).toBeCloseTo(
expectedWorldPosition[0],
);
expect(receivedPointer.gameWorldY).toBeCloseTo(
expectedWorldPosition[1],
);
} finally {
input.releasePointerEvent("pointerdown", renderable);
}
},
);

it("should register and trigger a pointerdown event", () => {
return new Promise((resolve) => {
const renderable = new Renderable(0, 0, 100, 100);
Expand Down
61 changes: 61 additions & 0 deletions packages/melonjs/tests/pointer-scale-modes.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from "vitest";
import { Application, boot, video } from "../src/index.js";

/**
* No scale method introduces a world offset.
*
* #1605 changes the coordinate space used for pointer hit tests, but only
* when `world.pos` is non-zero. That matters for the blast radius: if a scale
* method set the offset, every game using that method would take the new
* path. Nothing in the engine does — `world.pos` is moved by GAME code, to
* centre a level — and these pin it, so an ordinary game keeps taking the
* original path in every mode.
*
* Deliberately NOT asserted here: that a click lands on a region under each
* mode. These modes call `renderer.resize()` against the parent element, and
* in a headless harness that parent has no meaningful size (the canvas comes
* out at sizes like 800x1731), so such a test measures the harness rather
* than the engine. Verified separately instead: the behaviour under every
* mode is byte-identical before and after #1605.
*/
const METHODS = [
"fit",
"fill-min",
"fill-max",
"flex",
"flex-width",
"flex-height",
"stretch",
];

describe("scale methods and the world offset", () => {
let app;

afterEach(() => {
app?.destroy();
app = undefined;
});

it.for(METHODS)("%s leaves world.pos at the origin", async (scaleMethod) => {
boot();
app = new Application(800, 600, {
parent: "screen",
scaleMethod,
renderer: video.CANVAS,
});
await app.init();

expect(app.world.pos.x, `${scaleMethod} shifted the world on x`).toBe(0);
expect(app.world.pos.y, `${scaleMethod} shifted the world on y`).toBe(0);

// and a resize must not introduce one either — that is the path the
// bug report came in through
app.resize(1024, 768);
expect(app.world.pos.x, `${scaleMethod} shifted the world on resize`).toBe(
0,
);
expect(app.world.pos.y, `${scaleMethod} shifted the world on resize`).toBe(
0,
);
});
});
Loading
Loading