Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7f9fecc
fix(platform): catch iPadOS 13+ + deprecate dead-platform flags (#1467)
obiot Jun 1, 2026
9de375c
fix(input): drop `isMobile` gate on keyboard event registration (#1467)
obiot Jun 1, 2026
611ecef
refactor(system): convert device.js to TypeScript (#1467)
obiot Jun 1, 2026
9183006
fix(device): restore `let autoFocus` (was flipped to `const` by lint)
obiot Jun 1, 2026
ba60914
review(copilot): address inline feedback on #1485
obiot Jun 1, 2026
d78126c
feat(application): move requestFullscreen / exitFullscreen onto Appli…
obiot Jun 1, 2026
3b3fb5a
review(application): add `isFullscreen`, clean up the device fullscre…
obiot Jun 1, 2026
ec4e896
review: un-deprecate device.isFullscreen — it's a pure document probe…
obiot Jun 1, 2026
edf14a7
review(copilot): three rounds of inline feedback on #1485
obiot Jun 2, 2026
b35cb5a
feat(examples): bind F → fullscreen toggle on AfterBurner
obiot Jun 2, 2026
e2f59a3
feat(examples): actually add the F→fullscreen handler on AfterBurner
obiot Jun 2, 2026
f69bf64
fix(examples): F→fullscreen + dynamic HUD positions in AfterBurner
obiot Jun 2, 2026
c1f9265
chore(examples): add melonJS credit + tighten bottom-strip layout
obiot Jun 2, 2026
290e1fc
review(copilot): mark `isIPadOnMacUA` as `@internal`
obiot Jun 2, 2026
d4c2da3
fix(lint): add JSDoc `@param` / `@returns` to `isIPadOnMacUA`
obiot Jun 3, 2026
34f8a6c
chore(examples): drop debug-plugin from AfterBurner
obiot Jun 3, 2026
e8650ad
review(copilot): vendor-prefix exitFullscreen on both call sites
obiot Jun 3, 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
41 changes: 31 additions & 10 deletions packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,18 @@
* - `GameController.ts` — per-frame tick (player, enemies, bullets,
* exhaust, collision, score, camera follow)
*
* Controls: arrow keys / WASD to maneuver, space to fire, R to restart.
* Controls: arrow keys / WASD to maneuver, space to fire, R to restart, F to toggle fullscreen.
*
* Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License.
* See `packages/examples/LICENSE.md` for full license + asset credits.
*/
import { DebugPanelPlugin } from "@melonjs/debug-plugin";
import {
Application,
audio,
Camera3d,
event,
input,
loader,
plugin,
state,
video,
} from "melonjs";
Expand Down Expand Up @@ -62,7 +62,7 @@ const createGame = () => {
// error buried in the dev console.
let app: Application;
try {
app = new Application(1024, 768, {
app = new Application(1024, 576, {
Comment thread
obiot marked this conversation as resolved.
parent: "screen",
renderer: video.WEBGL,
scale: "auto",
Expand All @@ -87,16 +87,17 @@ const createGame = () => {
// paints the actual visible background each frame
app.world.backgroundColor.setColor(0, 0, 0, 0);

// Register the debug panel. Its usual toggle key (`S`) is bound by
// `GameController` for WASD-down movement, so the panel won't open
// via keyboard here — toggle it via the debug-plugin button instead.
plugin.register(DebugPanelPlugin, "debugPanel");

// Audio init — mp3 preferred (universal), m4a as a fallback for
// AAC-only browsers, ogg last for the Firefox/Linux path. Howler
// will try them in order and use the first one it can decode.
audio.init("mp3,m4a,ogg");

// hoisted so both the preload callback (assigns it) and the teardown
// closure (reads it for `event.off`) can see the same reference
let onKeyDown:
| ((action: string | undefined, keyCode: number) => void)
| undefined;

loader.preload(
[
{
Expand Down Expand Up @@ -133,14 +134,34 @@ const createGame = () => {
// state.pause(true) → audio.pauseTrack). Volume kept
// moderate so the procedural SFX layer over it without
// ducking.
// F toggles fullscreen for this app's canvas parent. Registered
// INSIDE the preload callback (not at createGame's sync tail)
// so it survives the React StrictMode dev double-mount cycle:
// utils.tsx runs teardown on the first unmount, then the
// canvas remount path doesn't re-run createGame — registering
// here instead means the listener installs after StrictMode
// settles, alongside the rest of the game-running state.
onKeyDown = (_action, keyCode) => {
if (keyCode === input.KEY.F) {
if (!app.isFullscreen()) app.requestFullscreen();
else app.exitFullscreen();
}
};
event.on(event.KEYDOWN, onKeyDown);

audio.playTrack(BGM_NAME, 0.45);
},
);

// Teardown — same-tab navigation back to the example index
// triggers this; without it the looping music keeps playing after
// the canvas is detached.
// the canvas is detached, and the F-key handler would leak across
// remounts (stacking up duplicate fullscreen toggles per press).
// `onKeyDown` is undefined if teardown fires before preload
// completes (StrictMode dev unmount path); skip the off in that
// case — the closure was never registered.
return () => {
if (onKeyDown !== undefined) event.off(event.KEYDOWN, onKeyDown);
audio.stopTrack();
};
};
Expand Down
46 changes: 33 additions & 13 deletions packages/examples/src/examples/afterBurner/HUD.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ import {
type WebGLRenderer,
} from "melonjs";

const CANVAS_W = 1024;
const CANVAS_H = 768;
// World-Z = camera position, so the squared distance to camera is the
// smallest possible — the world's depth-sort then draws the HUD last,
// on top of every other renderable.
Expand Down Expand Up @@ -52,9 +50,13 @@ const DEATH_FLASH_FADE_MS = 1100;
class DeathFlash extends Renderable {
private remainingMs = 0;
private startAlpha = DEATH_FLASH_ALPHA;
private screenW: number;
private screenH: number;

constructor() {
super(0, 0, CANVAS_W, CANVAS_H);
constructor(width: number, height: number) {
super(0, 0, width, height);
this.screenW = width;
this.screenH = height;
this.floating = true;
this.alwaysUpdate = true;
// Renderable defaults anchorPoint to (0.5, 0.5), which would
Expand Down Expand Up @@ -97,7 +99,7 @@ class DeathFlash extends Renderable {
renderer.save();
renderer.setColor(this.tint);
renderer.setGlobalAlpha(alpha);
renderer.fillRect(0, 0, CANVAS_W, CANVAS_H);
renderer.fillRect(0, 0, this.screenW, this.screenH);
renderer.restore();
}
}
Expand All @@ -124,6 +126,14 @@ export class HUD {
save.add({ [HISCORE_KEY]: 0 });
this.hiScore = (save[HISCORE_KEY] as number) ?? 0;

// Read the canvas dimensions from the live viewport so the
// overlay positions track whatever size the example was
// configured with (1024×576 today vs the original 1024×768)
// instead of hard-coding numbers that silently drift when the
// game's aspect ratio is retuned.
const w = app.viewport.width;
const h = app.viewport.height;

this.scoreText = this._makeText(app, 16, 24, {
size: 32,
fillStyle: "#ffe066",
Expand All @@ -133,7 +143,7 @@ export class HUD {
text: "SCORE 000000",
});

this.hiScoreText = this._makeText(app, CANVAS_W - 16, 24, {
this.hiScoreText = this._makeText(app, w - 16, 24, {
size: 32,
fillStyle: "#ffae3a",
textAlign: "right",
Expand All @@ -144,7 +154,7 @@ export class HUD {

// Lives readout — top-center, big enough to be glanceable but
// smaller than the score so the eye still goes there first.
this.livesText = this._makeText(app, CANVAS_W / 2, 24, {
this.livesText = this._makeText(app, w / 2, 24, {
size: 28,
fillStyle: "#ff7766",
textAlign: "center",
Expand All @@ -157,25 +167,35 @@ export class HUD {
// tint) so it doesn't compete with the score or game-over text.
// The world owns the reference via `addChild`, so we don't keep
// a field for it here.
this._makeText(app, 16, CANVAS_H - 16, {
this._makeText(app, 16, h - 6, {
size: 11,
fillStyle: "#bbbbbb",
textAlign: "left",
textBaseline: "bottom",
text: "Music by davidKBD: https://www.davidkbd.com",
});

// Engine attribution — bottom-center, paired with the same muted
// tint so it reads as one connected credits strip across the bottom.
this._makeText(app, w / 2, h - 6, {
size: 11,
fillStyle: "#bbbbbb",
textAlign: "center",
textBaseline: "bottom",
text: "Powered by melonJS: https://melonjs.org",
});

// Art-asset attribution — bottom-right, same muted tint as the
// music credit so the two read as one paired strip of credits.
this._makeText(app, CANVAS_W - 16, CANVAS_H - 16, {
this._makeText(app, w - 16, h - 6, {
size: 11,
fillStyle: "#bbbbbb",
textAlign: "right",
textBaseline: "bottom",
text: "3D assets by Kenney: https://kenney.nl/assets/space-kit",
text: "3D assets by Kenney: https://kenney.nl",
});

this.gameOverLine = this._makeText(app, CANVAS_W / 2, CANVAS_H / 2 - 12, {
this.gameOverLine = this._makeText(app, w / 2, h / 2 - 12, {
size: 42,
fillStyle: "#ff5566",
textAlign: "center",
Expand All @@ -185,7 +205,7 @@ export class HUD {
});
this.gameOverLine.setOpacity(0);

this.gameOverSub = this._makeText(app, CANVAS_W / 2, CANVAS_H / 2 + 32, {
this.gameOverSub = this._makeText(app, w / 2, h / 2 + 32, {
size: 18,
fillStyle: "#cccccc",
textAlign: "center",
Expand All @@ -194,7 +214,7 @@ export class HUD {
});
this.gameOverSub.setOpacity(0);

this.deathFlash = new DeathFlash();
this.deathFlash = new DeathFlash(w, h);
app.world.addChild(this.deathFlash, FLASH_Z);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,10 @@ export const createGame = () => {
audio.setVolume(Math.max(0, audio.getVolume() - 0.1));
}
if (keyCode === input.KEY.F) {
if (!device.isFullscreen()) {
device.requestFullscreen();
if (!_app.isFullscreen()) {
_app.requestFullscreen();
} else {
device.exitFullscreen();
_app.exitFullscreen();
}
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ class FSControl extends UISpriteElement {
* function called when the object is clicked on
*/
onClick(/* event */) {
if (!device.isFullscreen()) {
device.requestFullscreen();
if (!game.isFullscreen()) {
game.requestFullscreen();
} else {
device.exitFullscreen();
game.exitFullscreen();
}
return false;
}
Expand Down
6 changes: 3 additions & 3 deletions packages/examples/src/examples/platformer/createGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@ export const createGame = () => {
audio.setVolume(audio.getVolume() - 0.1);
}
if (keyCode === input.KEY.F) {
if (!device.isFullscreen()) {
device.requestFullscreen();
if (!_app.isFullscreen()) {
_app.requestFullscreen();
} else {
device.exitFullscreen();
_app.exitFullscreen();
}
}
});
Expand Down
6 changes: 3 additions & 3 deletions packages/examples/src/examples/platformer/entities/HUD.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ class FSControl extends UISpriteElement {
* function called when the object is clicked on
*/
onClick(/* event */) {
if (!device.isFullscreen()) {
device.requestFullscreen();
if (!game.isFullscreen()) {
game.requestFullscreen();
} else {
device.exitFullscreen();
game.exitFullscreen();
}
return false;
}
Expand Down
9 changes: 9 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,17 @@
- **Mesh rendering now clears depth once per target, not per mesh** (#1468). Each `WebGLRenderer.drawMesh` used to `gl.clear(DEPTH_BUFFER_BIT)` and toggle `DEPTH_TEST` / `BLEND` / `depthMask` on entry and exit, paying ~10 GL state calls per mesh. The new path moves all mesh-mode state ownership into `MeshBatcher` itself: `bind()` enters the mode (depth state + lazy per-target depth clear), `unbind()` restores non-mesh defaults, a new `event.RENDER_TARGET_CHANGED` broadcast re-arms the lazy clear when the active framebuffer changes identity. Consecutive mesh draws pay zero state-toggle cost between them. Matches Three.js's well-proven approach — the GPU's `LEQUAL` depth test resolves inter-mesh occlusion per pixel against the accumulated depth attachment, no per-mesh isolation needed. The renderer doesn't know anything mesh-specific anymore, so the WebGPU port stays clean (same lifecycle, different backend). **Behavioural change for one edge case:** under the old path two intersecting meshes drawn in painter-wrong order would silently swap (newer mesh's per-mesh clear wiped the older's depth → newer drew on top regardless of distance). Now the GPU's depth test does the right thing per pixel — closer mesh wins regardless of draw order. AfterBurner-scale scenes (~5 mesh draws/frame) see a marginal CPU saving; the real win is dense 3D scenes (50+ meshes) where per-mesh clear+state overhead used to scale linearly.
- **`Application` fails loudly on WebGL-required misconfiguration** (#1479). Two checks, both at construction time, both in `application.ts` — Camera3d stays pure-math, Stage untouched. **Throws** when `renderer: video.WEBGL` is requested but WebGL is unavailable (driver-blocklisted GPU, `failIfMajorPerformanceCaveat: true` on a software renderer, etc.) instead of silently falling back to Canvas. **Warns** (via `console.warn`) when `cameraClass` declares `static defaultSortOn = "depth"` (Camera3d or any subclass) but the active renderer isn't a `WebGLRenderer` — catches the `video.AUTO + cameraClass: Camera3d` combination where AUTO fell back to Canvas and the user would otherwise see a black canvas with no signal why. Warn (not throw) keeps integration tests that bypass real rendering working; the strong user-facing signal is `renderer: video.WEBGL`. **Migration:** Apps that relied on the silent `video.WEBGL` → Canvas fallback should switch to `video.AUTO` to keep the fallback semantics; Camera3d games should pair `cameraClass: Camera3d` with `renderer: video.WEBGL` to get a hard failure if WebGL isn't there.
- **`Camera2d.updateTarget` smooth follow is now frame-rate independent.** Previously `pos.lerp(target, damping)` ran a parametric per-frame fraction — same `damping = 0.1` covered 10% of the gap per frame at 30Hz, 60Hz, 120Hz or 144Hz, so wall-clock convergence sped up linearly with the player's refresh rate. Now uses `pos.damp(target, lambda, dt)` with `lambda = -ln(1 - damping) * timer.maxfps`, which recovers the legacy per-frame fraction exactly at the configured target framerate AND keeps wall-clock convergence constant if the actual frame rate drifts. **No tuning change required** — existing `damping` values keep their feel at the engine's target framerate (default 60); high-refresh users finally get the same feel the dev tuned for. Dogfoods the new `math.damp` API on melonJS's most prominent older follow path.
- **`device.platform.isMobile` no longer ORs the dead-platform regexes** (#1467). `wp` / `BlackBerry` / `Kindle` regexes were burning cycles on every page load testing for hardware nobody ships (Windows Phone EOL 2017, BB10 EOL 2016, Kindle behaves like Android anyway). The remaining chain — `/Mobi/.test(ua) || iOS || android` — covers ~99.9% of mobile traffic in 2026 per MDN. The deprecated exports themselves still compute and return; only the `isMobile` aggregate stopped consulting them.
- **`initKeyboardEvent` no longer skips listener registration on `isMobile === true`** (#1467). The gate assumed "mobile = no physical keyboard" — invalid for iPads with Magic Keyboard (now correctly detected per the iPad fix above), Samsung DeX, ChromeOS tablet mode, Bluetooth-keyboard-on-phone, etc. Two empty listener slots cost nothing on touch-only devices; the unbound-key path is a single map lookup that returns undefined.
- **`system/device` converted to TypeScript** (#1467, renamed from `device.js` → `device.ts`). 945 lines / 53 exports / 56 JSDoc blocks of feature-detection helpers and platform plumbing now ship as a `.ts` file with native type signatures. JSDoc was already exhaustive, so the conversion is mostly mechanical — `@param {Type}` blocks become parameter annotations and `@type {Type}` constants get TS-inferred. Non-standard / legacy browser surfaces (`Document.mozFullScreenEnabled`, `Navigator.standalone` / `browserLanguage` / `userLanguage`, iOS-only `DeviceOrientationEvent.requestPermission`, deprecated `Screen.lockOrientation`, `webkitAudioContext`) are typed via narrow local intersection types declared at the top of the file. Behavioural parity verified against the full 3975-test suite; downstream call sites (`pointerevent.ts`, `application.ts`, `resize.ts`, `header.ts`, etc.) are unchanged thanks to bundler-resolution rewriting `.js` imports to `.ts` source. One small correctness improvement fell out of the conversion: `onDeviceMotion` now guards against `accelerationIncludingGravity === null` rather than crashing.
- **`Application#requestFullscreen` / `Application#exitFullscreen`** — fullscreen control finally has app-instance context. `requestFullscreen` defaults to the app's `parentElement` (the container the canvas was appended into — `getParentElement()`), so the canvas plus any sibling HUD / overlay markup inside that container go fullscreen together; accepts an optional `Element` override. No deprecated `getParent()` / global-game lookup involved — the canonical fullscreen path now reaches the canvas through the Application it was created on. `Application#isFullscreen` is a thin convenience around the (still non-deprecated) `device.isFullscreen` so the trio reads together on the app instance; the underlying probe stays on `device` because there's exactly one fullscreen state per document regardless of how many Applications are running. The two examples that wire `F` → toggle fullscreen (platformer + platformer-matter) migrate to the new API.
Comment thread
obiot marked this conversation as resolved.

### Deprecated
- **`device.requestFullscreen()` / `device.exitFullscreen()`** (#1467, since 19.7.0). Use `app.requestFullscreen()` / `app.exitFullscreen()` instead. The device wrappers still work for backwards compat through the 19.x line but rely on the deprecated global-game canvas lookup (`getParent()` → `game.getParentElement()`, deprecated since 18.3.0).
- **`device.platform.wp` / `BlackBerry` / `Kindle` / `android2`** (#1467). The underlying platforms are end-of-life (Windows Phone discontinued 2017, BlackBerry stopped BB10 in 2016, Android 2.x predates 2012, Kindle has negligible mobile-web share). Exports stay functional through the 19.x line for backwards compatibility — IDE warnings light up at consumer sites; removal scheduled for 20.x. For "is this a touch device?" use the existing `device.touch` flag (feature-detected via `navigator.maxTouchPoints` / pointer events).

### Fixed
- **`device.platform.iOS` / `device.platform.isMobile` now correctly identify iPads on iPadOS 13+** (#1467). Since Sept 2019, Safari on iPad has shipped the desktop Mac UA — no `iPad` token — so every modern iPad was falling through `isMobile` as desktop. The detection now layers a feature check on top of the UA regex: `navigator.platform === "MacIntel"` (Apple-frozen legacy string that persists on Apple Silicon Macs/iPads for backwards compat — NOT a CPU check) plus `navigator.maxTouchPoints > 1` (Macs don't have touchscreens; iPads do). Every internal consumer of `isMobile` (`keyboard.ts`, `application.ts`, `header.ts`) inherits the fix transparently.
Comment thread
obiot marked this conversation as resolved.
- **`timer.step` is now the precise per-frame duration** (`1000 / maxfps`) instead of `Math.ceil(1000 / maxfps)`. The ceil rounded 16.667ms up to 17ms at 60fps target, undershooting the `delta / step` tick interpolation factor by ~2% under frame drops. The fast path (`delta < minstep → tick = 1`) masked it on healthy frames; only slow frames felt the discrepancy. Animations driven by the interpolation factor now advance the mathematically correct fraction under frame drops.
- **GPU TMX layer reset crash when a non-material batcher was active** (#1471). `OrthogonalTMXLayerGPURenderer.reset()` grabbed `renderer.currentBatcher` and called `deleteTexture2D` on it, but uploads always flow through the "quad" batcher (`_drawLayer`). When the previous frame left a `PrimitiveBatcher` active — e.g. the debug plugin's quadtree overlay — the reset path hit a method that doesn't exist on it and threw `TypeError: batcher.deleteTexture2D is not a function` on every stage change. Reset now pins to `batchers.get("quad")` and falls through to a manual cache cleanup if a user-supplied custom batcher doesn't expose `deleteTexture2D`.
- **WebGL `TextureCache` cross-batcher binding desync** — a unit-pool reset only cleared the current batcher's `boundTextures` map, leaving stale entries on every other batcher; meshes rendered as black silhouettes and bullets as pure white in mixed-batcher scenes after sustained gameplay. Fixed via the new `event.GPU_TEXTURE_CACHE_RESET` event consumed by `MaterialBatcher`.
Expand Down
Loading