Skip to content

Commit c045d2d

Browse files
obiotclaude
andauthored
Loading screen: show the bar and the logo together; 20.11.1 (#1776)
The progress bar went on screen at once and the logo, decoded a frame or so later, popped in after it. The bar is now built at once (it still follows the load from the first asset) but put on screen in the same frame as the logo. It shows alone when the logo cannot be decoded, or when the game's assets finish first so the screen is not left empty for a transition. A bar torn down before it was ever shown is destroyed, dropping its LOADER_PROGRESS subscription. Also bumps melonjs and the Claude plugin to 20.11.1. Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c1a60f2 commit c045d2d

6 files changed

Lines changed: 109 additions & 10 deletions

File tree

‎.claude-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "melonjs",
3-
"version": "20.11.0",
3+
"version": "20.11.1",
44
"description": "Build games with melonJS — 25 guides to the 2D, 2.5D and 3D HTML5 game engine, its conventions and idioms, so generated code runs the first time.",
55
"author": {
66
"name": "melonJS",

‎packages/melonjs/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Fixed
66
- Geometry: the methods that take a point (`translate`, `shift` and `contains` on `Bounds`, `Polygon`, `Rect`, `RoundRect`, `Ellipse` and `Line`, and `translate` on `Matrix2d` and `Matrix3d`) take any object with `x` and `y`, such as a renderable's `pos`. Given anything but a `Vector2d` they treated it as a number and came out `NaN`, so `bounds.clone().translate(renderable.pos)` gave no world bounds and `overlaps()` then reported every area as taken
7+
- Loader: the default loading screen brings up its progress bar and the melonJS logo in the same frame. The bar was drawn first and the logo popped in a frame or so later; the bar now shows alone only when the logo cannot be decoded or the game's assets finish first
78

89
## [20.11.0] (melonJS 2) - _2026-10-27_
910

‎packages/melonjs/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "melonjs",
3-
"version": "20.11.0",
3+
"version": "20.11.1",
44
"description": "A modern & fully featured HTML5 game engine (2D & 3D) for JavaScript/TypeScript, with WebGL/WebGPU rendering, built-in physics, Tiled map & glTF support and WebAudio.",
55
"homepage": "https://www.melonjs.org/",
66
"type": "module",

‎packages/melonjs/skills/melonjs-loading-assets/SKILL.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,8 @@ the hard way:
183183
### `DefaultLoadingScreen` lives in the WORLD, not on the screen
184184

185185
The built-in loading screen is written for a `Camera2d`. Its progress bar and
186-
logo are added with `app.world.addChild(bar, 1)` / `addChild(logo, 2)` and are
186+
logo are added together, once the logo is decoded (a frame or so after the
187+
screen comes up), with `app.world.addChild(bar, 1)` / `addChild(logo, 2)`, and are
187188
**`floating === false`** — ordinary world-space renderables. They read as an
188189
overlay only because that stage's world is otherwise empty.
189190

@@ -195,7 +196,7 @@ in front of a bar sitting at z = 1. What does NOT rescue it, all tried:
195196
depth, so the scene's own z values are geometry, not layering, and the bar
196197
still loses to the depth test.
197198
- **`moveToTop()`** — it early-returns for a child already at index 0, which the
198-
loading screen's children always are, having been added before anything else.
199+
loading screen's children usually are, having been added before anything else.
199200
It silently does nothing.
200201
- **Flipping them to `floating = true`** for the duration — the scene still
201202
covers them.

‎packages/melonjs/src/loader/loadingscreen.js‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ class DefaultLoadingScreen extends Stage {
8585
//
8686
// `barHeight / 2` because the private bar this replaces was built at 8
8787
// and drew at half that, so the look is preserved exactly.
88+
//
89+
// Built now, so it follows the load from the first asset, but put on
90+
// screen together with the logo (see `#showBar`): the logo decodes a
91+
// frame or so after this, and a bar drawn on its own first made the
92+
// logo pop in after it.
8893
this.progressBar = new ProgressBar(0, height / 2, {
8994
width,
9095
height: barHeight / 2,
@@ -103,7 +108,6 @@ class DefaultLoadingScreen extends Stage {
103108
// fill at the old scale until the next asset happened to land.
104109
bindEvent: LOADER_PROGRESS,
105110
});
106-
app.world.addChild(this.progressBar, 1);
107111

108112
// Latch "the preloader is done" — and ONLY that.
109113
//
@@ -142,9 +146,13 @@ class DefaultLoadingScreen extends Stage {
142146
frameheight: 256,
143147
});
144148
app.world.addChild(this.logoSprite, 2);
149+
// in the same frame as the logo
150+
this.#showBar();
145151
})
146152
.catch(() => {
147-
// the logo is decoration; a game must still be able to load
153+
// the logo is decoration; a game must still be able to load,
154+
// and to see that it is loading
155+
this.#showBar();
148156
});
149157
}
150158

@@ -156,6 +164,24 @@ class DefaultLoadingScreen extends Stage {
156164
*/
157165
#onLoaderComplete() {
158166
this.#loadingComplete = true;
167+
// the assets beat the logo: show the bar now rather than leave the
168+
// screen empty while the game finishes setting up, and for a
169+
// `state.transition()` to fade over
170+
this.#showBar();
171+
}
172+
173+
/**
174+
* Put the progress bar on screen, once: with the logo, or on its own when
175+
* the logo failed or the assets finished first.
176+
* @ignore
177+
* @internal
178+
*/
179+
#showBar() {
180+
const bar = this.progressBar;
181+
if (this.#cleanedUp || bar === null || this.#app.world.hasChild(bar)) {
182+
return;
183+
}
184+
this.#app.world.addChild(bar, 1);
159185
}
160186

161187
/**
@@ -176,6 +202,11 @@ class DefaultLoadingScreen extends Stage {
176202
if (this.progressBar) {
177203
if (this.#app.world.hasChild(this.progressBar)) {
178204
this.#app.world.removeChild(this.progressBar);
205+
} else {
206+
// never shown (torn down before the logo or the assets came
207+
// in), so the world will not destroy it: do it here, which
208+
// also drops its LOADER_PROGRESS subscription
209+
this.progressBar.destroy();
179210
}
180211
this.progressBar = null;
181212
}

‎packages/melonjs/tests/loadingscreen.spec.js‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
1+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
22
import { Application, boot, event, video } from "../src/index.js";
33
import DefaultLoadingScreen from "../src/loader/loadingscreen.js";
44

@@ -37,16 +37,82 @@ describe("DefaultLoadingScreen", () => {
3737
return screen;
3838
};
3939

40-
it("puts a progress bar in the world", () => {
40+
/**
41+
* Wait until `test()` holds, a few frames at most.
42+
* @param {Function} test - the condition
43+
* @returns {Promise<boolean>} whether it came true in time
44+
*/
45+
const until = async (test) => {
46+
for (let i = 0; i < 100 && !test(); i++) {
47+
await new Promise((resolve) => {
48+
setTimeout(resolve, 10);
49+
});
50+
}
51+
return test();
52+
};
53+
54+
it("shows the bar and the logo together, once the logo is decoded", async () => {
55+
// The logo decodes a frame or so after the screen comes up. A bar put
56+
// on screen at once made the logo pop in after it; now neither shows
57+
// until both can.
4158
const screen = mount();
4259
try {
43-
expect(screen.progressBar).toBeDefined();
44-
expect(app.world.hasChild(screen.progressBar)).toBe(true);
60+
const bar = screen.progressBar;
61+
expect(bar).toBeDefined();
62+
expect(app.world.hasChild(bar)).toBe(false);
63+
expect(screen.logoSprite).toBe(null);
64+
65+
expect(
66+
await until(() => {
67+
return screen.logoSprite !== null;
68+
}),
69+
).toBe(true);
70+
expect(app.world.hasChild(screen.logoSprite)).toBe(true);
71+
expect(app.world.hasChild(bar)).toBe(true);
4572
} finally {
4673
screen.destroy(app);
4774
}
4875
});
4976

77+
it("shows the bar alone when the logo cannot be decoded", async () => {
78+
const decode = vi
79+
.spyOn(globalThis, "createImageBitmap")
80+
.mockRejectedValue(new Error("cannot decode"));
81+
const screen = mount();
82+
try {
83+
const bar = screen.progressBar;
84+
expect(
85+
await until(() => {
86+
return app.world.hasChild(bar);
87+
}),
88+
).toBe(true);
89+
expect(screen.logoSprite).toBe(null);
90+
} finally {
91+
decode.mockRestore();
92+
screen.destroy(app);
93+
}
94+
});
95+
96+
it("tracks the load before the bar is on screen", () => {
97+
const screen = mount();
98+
try {
99+
event.emit(event.LOADER_PROGRESS, 0.4);
100+
expect(screen.progressBar.value).toBe(0.4);
101+
} finally {
102+
screen.destroy(app);
103+
}
104+
});
105+
106+
it("destroys a bar that was never shown, and its subscription with it", () => {
107+
// torn down before the logo or the assets came in: the bar never
108+
// reached the world, so the world will not destroy it
109+
const screen = mount();
110+
const bar = screen.progressBar;
111+
screen.destroy(app);
112+
event.emit(event.LOADER_PROGRESS, 0.7);
113+
expect(bar.value).toBe(0);
114+
});
115+
50116
it("stays on screen after the preloader reports done", () => {
51117
// The regression. `LOADER_COMPLETE` fires when the ASSETS are in —
52118
// which is before the game has finished its own post-preload setup and

0 commit comments

Comments
 (0)