Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
12218f4
20.13.0: bump the version
claude Oct 31, 2026
acbb163
UI: checkbox, slider, skinned button, panel layout and scrolling (#1722)
claude Oct 31, 2026
1fbc5c9
UI: fixes from review
claude Oct 31, 2026
1bf6e4b
Examples: a taller level list in the UI example
claude Oct 31, 2026
4cacf94
Examples: the UI example's level list at the top right, beside the bars
claude Oct 31, 2026
32626f9
Examples: the UI example's level list shows the first 16 levels
claude Oct 31, 2026
2e3c8f1
UI: consistent widget API, and fixes from a second review
claude Oct 31, 2026
d4a577c
Examples: UI example lint
claude Oct 31, 2026
99c7c51
UI: drop shadows on UIPanel and ProgressBar, used in the UI example
claude Oct 31, 2026
12bedbd
Gamepad plugin: keep a touch's hover events on the controls from the …
claude Oct 31, 2026
59f6398
UI: keyboard and gamepad focus navigation, and a hover/press bounce
claude Oct 31, 2026
6f1acf6
UI: keep the focus navigation to the minimum
claude Oct 31, 2026
44ee453
UI: fixes from a review of the focus navigation and the bounce
claude Oct 31, 2026
5291863
UI: focus lives on the widgets, with no focus manager to set up
claude Oct 31, 2026
1440ba7
Remove a scratch test file committed by mistake
claude Oct 31, 2026
32a4edc
UI: fill the gaps in the widget tests, and fix what they found
claude Oct 31, 2026
596b23b
Merge remote-tracking branch 'origin/master' into claude/modest-johns…
claude Oct 31, 2026
d706c94
gamepad-plugin 1.0.2
claude Oct 31, 2026
d345737
Merge remote-tracking branch 'origin/master' into claude/modest-johns…
claude Oct 31, 2026
b39dd70
Skill: UI widgets, with a widget map and a complete menu example
claude Oct 31, 2026
ec8e1a4
Docs: UICheckbox and UISlider, with examples
claude Oct 31, 2026
57109da
Docs: UIPanel and ProgressBar, with examples
claude Oct 31, 2026
139c957
Docs: UIBaseElement, UITextButton, UISpriteElement, with examples
claude Oct 31, 2026
0a785e6
Changelog: onCancel also covers a called-off bindKey press
claude Oct 31, 2026
c365da3
UI: UISpriteElement lets events through by default, like the others
claude Oct 31, 2026
1d86e82
UI: a press stops at a UI element by default
claude Oct 31, 2026
6d971f4
UI: UISpriteElement is a UIBaseElement holding a Sprite
claude Nov 1, 2026
515a59a
Tests: UISpriteElement's public behaviour, old and new
claude Nov 1, 2026
71c91de
Input: keep the hover after a click, and end it when the pointer leav…
claude Nov 1, 2026
63bfee5
UI: fix the widget bugs found while writing the docs
claude Nov 1, 2026
8a41b06
UI: a radio group holds one choice from when its checkboxes are made
claude Nov 1, 2026
8a47072
Fixes from review: canvas leave reaches every hovered region; UISprit…
claude Nov 1, 2026
6b9b22b
UISpriteElement: setCurrentAnimation passes preserve_dt on
claude Nov 1, 2026
582b27c
Input: a cancelled pointer, or a lifted legacy touch, ends the hover
claude Nov 1, 2026
c70fe49
Changelog: a cancelled pointer ends the hover too
claude Nov 1, 2026
316a7c2
Merge remote-tracking branch 'origin/master' into claude/modest-johns…
claude Nov 1, 2026
6d4a96a
Rendering: a clip inside a clip is the overlap of the two on WebGL an…
claude Nov 1, 2026
4d1c82e
Merge remote-tracking branch 'origin/master' into claude/modest-johns…
claude Nov 1, 2026
c0c2258
Camera: a fade always calls onComplete before it is removed (#1810)
claude Nov 1, 2026
927b0c9
Tests: fadeIn / fadeOut onComplete through the game loop (#1810)
claude Nov 1, 2026
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 @@ -25,6 +25,7 @@
- UI: `UITextButton#bitmapText`, for `label`

### Fixed
- Camera: `fadeIn()` / `fadeOut()` (and a `FadeEffect`, so the fade state transition and a fading level trigger too) always call `onComplete`. The fade counted as done once its colour's alpha read as the target, which a 32-bit float can do one step before the tween ends; the camera then removed the effect, stopping the tween before it called `onComplete`, so code waiting on it stayed stuck. The fade now ends when its tween does ([#1810](https://github.com/melonjs/melonJS/issues/1810))
- Rendering: on WebGL and WebGPU, a clip inside a clip is the overlap of the two, as it already was on Canvas. A clipping container nested in another, or a scrolling `UIPanel` in a clipping parent, drew past the outer clip wherever it stuck out of it, because the inner `clipRect` replaced the outer scissor ([#1816](https://github.com/melonjs/melonJS/issues/1816)). The clipping example shows the case
- UI: a radio group holds one choice from when its checkboxes are made, not only once they are in the scene: before, several could be checked at once until they were added, and `UICheckbox.checkedIn()` answered `undefined`. A checkbox leaves its group when destroyed; groups hold their members weakly, so one dropped without being destroyed can still be collected
- UI: `UICheckbox#toggle()` on a checked radio button leaves it checked, as a click does, instead of leaving its group with nothing checked
Expand Down
12 changes: 7 additions & 5 deletions packages/melonjs/src/camera/effects/fade_effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default class FadeEffect extends CameraEffect {
direction: "in" | "out";

/**
* target alpha value for completion check
* the alpha the fade ends on
* @ignore
* @internal
*/
Expand Down Expand Up @@ -103,10 +103,12 @@ export default class FadeEffect extends CameraEffect {
}

override update(): void {
// check if the tween has reached its target
if (this.direction === "in" && this.color.alpha >= this._targetAlpha) {
this.isComplete = true;
} else if (this.direction === "out" && this.color.alpha <= 0.0) {
// Over when the tween is: it has run its course and called
// `onComplete`. Not when the alpha reads as the target: the colour
// stores it as a 32-bit float, so a value one step short of the end
// can already round to it, and the camera then destroyed the effect,
// stopping the tween before it ever called `onComplete` (#1810).
if (this.tween !== undefined && !this.tween._isRunning) {
this.isComplete = true;
}
}
Expand Down
53 changes: 52 additions & 1 deletion packages/melonjs/tests/camera.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
Application,
boot,
Expand Down Expand Up @@ -1473,6 +1473,57 @@ describe("Camera2d", () => {
expect(called).toEqual(true);
});

it("a fade calls onComplete before the camera removes it, whatever the float rounding (#1810)", () => {
// frame by frame as the game loop runs it: the tween steps, then the
// camera updates its effects. The alpha is stored as a 32-bit float,
// so on some durations it reads as the target one step before the
// tween ends; the effect must still wait for the tween
const frame = 1000 / 60;
const missed = [];
for (const direction of ["in", "out"]) {
for (let duration = 100; duration <= 1000; duration += 25) {
const { camera } = setup();
const onComplete = vi.fn();
const fade = new FadeEffect(camera, {
color: "#ffffff",
duration,
direction,
onComplete,
});
camera.addCameraEffect(fade);
const tween = fade.tween;
for (let i = 0; i < 200 && camera.getCameraEffect(FadeEffect); i++) {
tween.update(frame);
camera.update(frame);
}
expect(camera.getCameraEffect(FadeEffect)).toBeUndefined();
if (onComplete.mock.calls.length !== 1) {
missed.push(`${direction} ${duration}ms`);
}
}
}
expect(missed).toEqual([]);
});

it("a fade stays until its tween ends, even once its alpha reads as the target", () => {
const { camera } = setup();
const onComplete = vi.fn();
const fade = camera.addCameraEffect(
new FadeEffect(camera, { color: "#000", duration: 300, onComplete }),
);
// the colour already at the target, the tween not yet over
fade.color.alpha = 1;
camera.update(16);
expect(camera.getCameraEffect(FadeEffect)).toBe(fade);
expect(onComplete).not.toHaveBeenCalled();
// to its end exactly: a finished tween leaves the game loop, so
// it is not stepped again
advanceTween(fade.tween, 300);
camera.update(16);
expect(onComplete).toHaveBeenCalledTimes(1);
expect(camera.getCameraEffect(FadeEffect)).toBeUndefined();
});

it("fadeIn() convenience method should add a FadeEffect", () => {
const { camera } = setup();
camera.fadeIn("#000", 500);
Expand Down
117 changes: 117 additions & 0 deletions packages/melonjs/tests/fade-oncomplete.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* `viewport.fadeIn()` / `fadeOut()` always call `onComplete` (#1810).
*
* Driven the way a game runs: each frame is a `TICK`, as the browser loop
* emits it. The application updates first (its stage updates the camera,
* which removes the effects that are complete), then the fade's tween steps.
*
* A fade used to count as complete once its colour's alpha read as the
* target. The alpha is a 32-bit float, so on some durations it rounds to
* the target one step before the tween ends; the camera then removed the
* fade and stopped the tween before it called `onComplete`. Code waiting on
* the callback, a scene change say, never ran.
*/
import { beforeAll, describe, expect, it, vi } from "vitest";
import { Application, boot, event, FadeEffect, video } from "../src/index.js";

const FRAME = 1000 / 60;

let app;
// the clock the frames are stamped with, moved on by every frame
let now;

/** run one frame of the game loop at 60 fps */
const tick = () => {
now += FRAME;
event.emit(event.TICK, now);
};

/**
* run frames until the camera has no fade left, or a limit
* @returns the number of frames run
*/
const runFade = () => {
let frames = 0;
while (app.viewport.getCameraEffect(FadeEffect) && frames < 400) {
tick();
frames++;
}
return frames;
};

describe("viewport.fadeIn() / fadeOut() call onComplete (#1810)", () => {
beforeAll(async () => {
boot();
app = new Application(320, 240, {
parent: "screen",
renderer: video.CANVAS,
consoleHeader: false,
});
await app.init();
now = globalThis.performance.now();
});

it("fadeIn calls onComplete once, then the fade is gone, at every duration", () => {
const missed = [];
for (let duration = 100; duration <= 1000; duration += 25) {
const onComplete = vi.fn();
app.viewport.fadeIn("#000000", duration, onComplete);
runFade();
expect(app.viewport.getCameraEffect(FadeEffect)).toBeUndefined();
if (onComplete.mock.calls.length !== 1) {
missed.push(duration);
}
}
expect(missed).toEqual([]);
});

it("fadeOut calls onComplete once, then the fade is gone, at every duration", () => {
const missed = [];
for (let duration = 100; duration <= 1000; duration += 25) {
const onComplete = vi.fn();
app.viewport.fadeOut("#000000", duration, onComplete);
runFade();
expect(app.viewport.getCameraEffect(FadeEffect)).toBeUndefined();
if (onComplete.mock.calls.length !== 1) {
missed.push(duration);
}
}
expect(missed).toEqual([]);
});

it("the issue's case: a scene change chained on fadeIn callbacks reaches its end", () => {
// as the Alex 4 outro moved from scene to scene in a fadeIn callback
const scenes = [];
const next = (n) => {
scenes.push(n);
if (n < 4) {
app.viewport.fadeIn("#000000", 300, () => {
next(n + 1);
});
}
};
next(1);
for (let i = 0; i < 4 && app.viewport.getCameraEffect(FadeEffect); i++) {
runFade();
}
expect(scenes).toEqual([1, 2, 3, 4]);
});

it("the fade ends on the frame after its tween does, not before", () => {
const onComplete = vi.fn();
app.viewport.fadeIn("#000000", 300, onComplete);
const fade = app.viewport.getCameraEffect(FadeEffect);
let frames = 0;
while (!onComplete.mock.calls.length && frames < 400) {
// still there for every frame before the callback
expect(app.viewport.getCameraEffect(FadeEffect)).toBe(fade);
tick();
frames++;
}
expect(onComplete).toHaveBeenCalledTimes(1);
// 300 ms is 18 frames at 60 fps
expect(frames).toBeGreaterThanOrEqual(18);
tick();
expect(app.viewport.getCameraEffect(FadeEffect)).toBeUndefined();
});
});
Loading