Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 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
3ebc32a
Merge remote-tracking branch 'origin/master' into claude/modest-johns…
claude Nov 1, 2026
39f681b
Tiled: seed object GUIDs past the map's ids when it has no nextobject…
claude Nov 1, 2026
26494a3
Merge remote-tracking branch 'origin/master' into claude/modest-johns…
claude Nov 1, 2026
b311be0
fix(line): 선분 연장선의 점을 포함하지 않도록 수정
mmporong Oct 31, 2026
ec612a5
fix(color): hex 알파 정밀도를 보존
mmporong Oct 31, 2026
8c16d0c
fix(math): 2의 거듭제곱 입력을 검증
mmporong Oct 31, 2026
864eddd
Color: keep toHex8() output as it was; the hex alpha fix is in parseH…
claude Nov 1, 2026
404663f
Merge remote-tracking branch 'origin/master' into claude/modest-johns…
claude Nov 1, 2026
6349541
Tests: a full local run of the engine suite comes out clean (#1817)
claude Nov 1, 2026
ba0a426
Tests: the audio unlock test waits for the clip to start (#1817)
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
21 changes: 13 additions & 8 deletions packages/melonjs/tests/audio-backend.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1115,14 +1115,19 @@ describe("audio backend", () => {
{ once: true },
);
document.dispatchEvent(new Event("click"));
await tick();
// the hardware is running: a generated tone would be heard here
expect(ctx.state).toBe("running");
// and so must the engine be, with the clip actually started. The
// fixture is ten milliseconds long, so `playing()` is already false
// again by now: the "play" event is the record that it started
expect(audioEngine.state).toBe("running");
expect(played).toHaveBeenCalledTimes(1);
// waited for rather than given a fixed time, which a full parallel
// run can outlast (#1817): an engine wedged as in the bug never
// gets there at all
await vi.waitFor(() => {
// the hardware is running: a generated tone would be heard here
expect(ctx.state).toBe("running");
// and so must the engine be, with the clip actually started.
// The fixture is ten milliseconds long, so `playing()` is
// already false again by now: the "play" event is the record
// that it started
expect(audioEngine.state).toBe("running");
expect(played).toHaveBeenCalledTimes(1);
});
expect(played.mock.calls[0][0]).toBe(id);
delete ctx.state;
ctx.suspend = savedSuspend;
Expand Down
22 changes: 15 additions & 7 deletions packages/melonjs/tests/drawmesh_bench.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,20 +233,28 @@ describe("drawMesh benchmark (baseline for #1468)", () => {
);
};

it("baseline cost across mesh counts", (ctx) => {
// Run on demand, and given the time it takes under software GL:
// BENCH=1 npx vitest run --config packages/melonjs/vitest.config.ts tests/drawmesh_bench.spec.js
it("baseline cost across mesh counts", { timeout: 300000 }, (ctx) => {
// `measure()` below logs and returns when there is no WebGL, which
// reports as a PASS — a benchmark that never ran then looks exactly
// like one that did. Decide it once, visibly, up front.
if (!(renderer instanceof WebGLRenderer)) {
ctx.skip("Canvas renderer — drawMesh benchmark not applicable");
return;
}
// Skipped on CI, visibly rather than by quietly measuring nothing.
// The runner is a GPU-less container rasterizing through SwiftShader's
// Subzero JIT on four shared vCPUs, so these figures describe that
// environment and not the engine. The work is also heavy enough to
// starve the shared GPU process for minutes, which is what made the
// suite flaky. Run it locally to compare against #1507's numbers.
// Skipped unless asked for (`BENCH=1`), visibly rather than by quietly
// measuring nothing. On CI, the runner is a GPU-less container
// rasterizing through SwiftShader's Subzero JIT on four shared vCPUs,
// so these figures describe that environment and not the engine. The
// work is also heavy enough to starve the shared GPU process for
// minutes: in a full run, on CI or on a machine without a GPU, it
// timed out itself and the specs beside it (#1817). Run it on its own
// to compare against #1507's numbers.
if (import.meta.env.VITE_BENCH === "") {
ctx.skip("benchmark — run it with BENCH=1");
return;
}
if (import.meta.env.VITE_CI !== "") {
ctx.skip("CI — a software-rasterized benchmark measures the runner");
return;
Expand Down
33 changes: 20 additions & 13 deletions packages/melonjs/tests/text-ink-clipping.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,16 +102,23 @@ describe("Text — the glyph is baked whole, not clipped", () => {
return m.actualBoundingBoxAscent + m.actualBoundingBoxDescent + stroke;
};

it("bakes the whole glyph, stroke included", () => {
// The oracle is the ink box the browser reports plus the stroke, which
// no metric covers. Anything less means the bake cut the glyph off.
const text = stroked();

/**
* The glyph's ink is as tall as the ink box the browser reports, plus the
* stroke, which no metric covers: anything less means the bake cut it
* off. Within a row: the box is measured on the outline, and how the
* face's hinting rasterizes it can paint one row less (#1817: 58 rows for
* a reported 59 on one machine, with nothing cut).
* @param text - a Text whose offscreen canvas has been baked
*/
const expectFullHeight = (text) => {
const { top, bottom } = inkRows(text);

expect(bottom - top + 1).toBeGreaterThanOrEqual(
Math.floor(expectedInk(text)),
Math.floor(expectedInk(text)) - 1,
);
};

it("bakes the whole glyph, stroke included", () => {
expectFullHeight(stroked());
});

it("keeps the ink inside the canvas at the bottom too", () => {
Expand All @@ -126,12 +133,12 @@ describe("Text — the glyph is baked whole, not clipped", () => {
// the other half of the bug: a face whose glyphs rise above the em box
// overshoots with no stroke involved at all
const text = stroked({ lineWidth: 0, size: 64 });

const { top, bottom } = inkRows(text);

expect(bottom - top + 1).toBeGreaterThanOrEqual(
Math.floor(expectedInk(text)),
);
expectFullHeight(text);
// and with nothing on the canvas's first or last row, where a cut
// would leave it
const { top, bottom, height } = inkRows(text);
expect(top).toBeGreaterThan(0);
expect(bottom).toBeLessThan(height - 1);
});

describe("the reported layout box is unchanged", () => {
Expand Down
106 changes: 90 additions & 16 deletions packages/melonjs/tests/transition-second-change.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import {
Application,
boot,
Expand All @@ -24,9 +33,74 @@ import {
*/

const DURATION = 60;
// Generous: a covering transition advances on animation frames, and under a
// full parallel run the browser can go seconds without giving this page one.
const WAIT = { timeout: 15000 };
const FRAME = 1000 / 60;
// how long, in real time, to keep running frames for something to happen: a
// WebGL renderer can hold a switch while its shaders build
const LIMIT = 20000;

/*
* The game loop is run here, one frame at a time, rather than by the
* browser: `requestAnimationFrame` only keeps the engine's frame callback,
* and `frame()` calls it with a clock moved on by 1/60 s. Under a full
* parallel run the browser can go seconds without giving this page an
* animation frame, which timed these tests out (#1817); the engine's own
* loop, its timers and deferred switches run as they do in a game.
*/
let nextFrame = null;
let clock = 0;

/** run one frame of the game loop, if it is running, then what it deferred */
const frame = async () => {
const callback = nextFrame;
nextFrame = null;
if (callback !== null) {
clock += FRAME;
callback(clock);
}
// a turn of the event loop, for the timers it queued (a state switch)
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
};

/** run frames for `ms` of game time */
const runFor = async (ms) => {
for (let t = 0; t < ms; t += FRAME) {
await frame();
}
};

/** run frames until `check` passes, or fail with its last error */
const runUntil = async (check) => {
const end = globalThis.performance.now() + LIMIT;
while (globalThis.performance.now() < end) {
try {
check();
return;
} catch {
await frame();
}
}
check();
};

beforeAll(() => {
clock = globalThis.performance.now();
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation(
(callback) => {
nextFrame = callback;
return 1;
},
);
vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => {
nextFrame = null;
});
});

afterAll(() => {
vi.restoreAllMocks();
});

const covers = {
fade: FadeEffect,
mask: MaskEffect,
Expand Down Expand Up @@ -71,15 +145,13 @@ describe.each([
* camera effect at all, the transition's own or any other
*/
const settlesOn = async (id) => {
await vi.waitFor(() => {
await runUntil(() => {
expect(state.isCurrent(id)).toBe(true);
expect(app.viewport.cameraEffects).toHaveLength(0);
}, WAIT);
});
// and stays so: a cover left behind is persistent, and outlives
// the reveal that would otherwise be the last effect to go
await new Promise((resolve) => {
setTimeout(resolve, DURATION * 3);
});
await runFor(DURATION * 3);
expect(state.isCurrent(id)).toBe(true);
expect(app.viewport.cameraEffects).toHaveLength(0);
};
Expand All @@ -92,6 +164,10 @@ describe.each([
start = newState("start");
state.setTransition(start, false);
state.change(start, true);
// the loop running, its warm-up over
await runUntil(() => {
expect(nextFrame).not.toBeNull();
});
resets.length = 0;
state.transition(
type,
Expand Down Expand Up @@ -122,9 +198,7 @@ describe.each([
const a = newState("a");
const b = newState("b");
state.change(a);
await new Promise((resolve) => {
setTimeout(resolve, DURATION / 2);
});
await runFor(DURATION / 2);
expect(state.isCurrent(a)).toBe(false);
state.change(b);
expect(effects()).toHaveLength(1);
Expand Down Expand Up @@ -162,9 +236,9 @@ describe.each([
const a = newState("a");
const b = newState("b");
state.change(a);
await vi.waitFor(() => {
await runUntil(() => {
expect(state.isCurrent(a)).toBe(true);
}, WAIT);
});
// during a's reveal: a change of its own, with a cover of its own
state.change(b);
await settlesOn(b);
Expand Down Expand Up @@ -289,9 +363,9 @@ describe("a transition left covering by a destroyed application", () => {
try {
// not taken by the dead transition: switched, directly
state.change(b);
await vi.waitFor(() => {
await runUntil(() => {
expect(state.isCurrent(b)).toBe(true);
}, WAIT);
});
// and with no reveal of the dead transition's added to this one
expect(
app.viewport.cameraEffects.filter((fx) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/melonjs/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ export default defineConfig(() =>
// contributor's machine that genuinely has no GL).
define: {
"import.meta.env.VITE_CI": JSON.stringify(process.env.CI ?? ""),
// Benchmarks run only when asked for, with `BENCH=1`: they measure
// rather than test, and the heavy ones tie up the browser's shared
// GPU process long enough to time out the specs running beside
// them (#1817).
"import.meta.env.VITE_BENCH": JSON.stringify(process.env.BENCH ?? ""),
},
publicDir: resolve(__dirname, "tests/public"),
plugins: [
Expand Down
Loading