diff --git a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx index aed3ec855e..92a4a3588c 100644 --- a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx +++ b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx @@ -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"; @@ -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, { parent: "screen", renderer: video.WEBGL, scale: "auto", @@ -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( [ { @@ -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(); }; }; diff --git a/packages/examples/src/examples/afterBurner/HUD.ts b/packages/examples/src/examples/afterBurner/HUD.ts index 035d8b0d09..5700fac4e9 100644 --- a/packages/examples/src/examples/afterBurner/HUD.ts +++ b/packages/examples/src/examples/afterBurner/HUD.ts @@ -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. @@ -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 @@ -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(); } } @@ -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", @@ -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", @@ -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", @@ -157,7 +167,7 @@ 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", @@ -165,17 +175,27 @@ export class HUD { 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", @@ -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", @@ -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); } diff --git a/packages/examples/src/examples/platformer-matter/createGame.ts b/packages/examples/src/examples/platformer-matter/createGame.ts index a272a052e5..c6b48596db 100644 --- a/packages/examples/src/examples/platformer-matter/createGame.ts +++ b/packages/examples/src/examples/platformer-matter/createGame.ts @@ -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(); } } }); diff --git a/packages/examples/src/examples/platformer-matter/entities/HUD.ts b/packages/examples/src/examples/platformer-matter/entities/HUD.ts index a6d376689c..86ff5e9b02 100644 --- a/packages/examples/src/examples/platformer-matter/entities/HUD.ts +++ b/packages/examples/src/examples/platformer-matter/entities/HUD.ts @@ -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; } diff --git a/packages/examples/src/examples/platformer/createGame.ts b/packages/examples/src/examples/platformer/createGame.ts index a5cfa133be..36dea466d4 100644 --- a/packages/examples/src/examples/platformer/createGame.ts +++ b/packages/examples/src/examples/platformer/createGame.ts @@ -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(); } } }); diff --git a/packages/examples/src/examples/platformer/entities/HUD.ts b/packages/examples/src/examples/platformer/entities/HUD.ts index 4a3642bf0f..4fb735c4b6 100644 --- a/packages/examples/src/examples/platformer/entities/HUD.ts +++ b/packages/examples/src/examples/platformer/entities/HUD.ts @@ -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; } diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 790761d1d0..9247e869b6 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -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. + +### 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. - **`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`. diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts index 7989ea7d9a..6018c837ce 100644 --- a/packages/melonjs/src/application/application.ts +++ b/packages/melonjs/src/application/application.ts @@ -43,6 +43,27 @@ import type { ResolvedApplicationSettings, } from "./settings.ts"; +// vendor-prefixed fullscreen entry points still ship on older WebKit / +// Gecko / IE-derived engines; spelled out here so requestFullscreen() +// works on any browser whose `hasFullscreenSupport` flag was set via a +// prefix probe rather than the unprefixed standard. +type ElementWithLegacyFullscreen = Element & { + mozRequestFullScreen?: () => void; + webkitRequestFullscreen?: () => void; + msRequestFullscreen?: () => void; +}; +// `document.exitFullscreen()` is the modern entry but older WebKit / +// Gecko / IE-derived browsers shipped vendor-prefixed variants. Same +// rationale as `ElementWithLegacyFullscreen`: kept here as a local +// intersection so `app.exitFullscreen()` no-ops cleanly on every +// browser whose `hasFullscreenSupport` was set via a prefix probe. +// Note: Mozilla uses `mozCancelFullScreen` (not `mozExitFullScreen`). +type DocumentWithLegacyExitFullscreen = Document & { + webkitExitFullscreen?: () => Promise | void; + mozCancelFullScreen?: () => Promise | void; + msExitFullscreen?: () => Promise | void; +}; + /** * Resolve the user-supplied `physic` setting into the (optional) adapter * to pass into the {@link World} constructor plus the legacy "builtin" @@ -655,6 +676,69 @@ export default class Application { return this.parentElement; } + /** + * Returns `true` if the browser/device is currently in fullscreen mode. + * Thin convenience around {@link device.isFullscreen} so the + * fullscreen trio (`isFullscreen` / `requestFullscreen` / + * `exitFullscreen`) lives together on the app instance. + * @category Application + */ + isFullscreen(): boolean { + return device.isFullscreen(); + } + + /** + * Trigger a fullscreen request for this application. Defaults to this + * application's `parentElement` (the container the canvas was appended + * into — see {@link Application#getParentElement}), so the canvas and + * any sibling HUD / overlay markup inside that container go fullscreen + * together. + * @param element - optional element to fullscreen instead of `this.parentElement` + * @example + * // bind F to toggle fullscreen + * me.input.bindKey(me.input.KEY.F, "toggleFullscreen"); + * me.event.on(me.event.KEYDOWN, (action) => { + * if (action === "toggleFullscreen") { + * if (!app.isFullscreen()) app.requestFullscreen(); + * else app.exitFullscreen(); + * } + * }); + * @category Application + */ + requestFullscreen(element?: Element): void { + if (device.hasFullscreenSupport && !this.isFullscreen()) { + const target = (element ?? + this.parentElement) as ElementWithLegacyFullscreen; + /* eslint-disable @typescript-eslint/unbound-method -- `this` is restored explicitly via `.call(target)` below */ + const request = + target.requestFullscreen || + target.webkitRequestFullscreen || + target.mozRequestFullScreen || + target.msRequestFullscreen; + /* eslint-enable @typescript-eslint/unbound-method */ + const result = request?.call(target); + if (result instanceof Promise) result.catch(console.error); + } + } + + /** + * Exit fullscreen mode for this application. + * @category Application + */ + exitFullscreen(): void { + if (!device.hasFullscreenSupport || !this.isFullscreen()) return; + const doc = globalThis.document as DocumentWithLegacyExitFullscreen; + /* eslint-disable @typescript-eslint/unbound-method -- `this` is restored explicitly via `.call(doc)` below */ + const exit = + doc.exitFullscreen || + doc.webkitExitFullscreen || + doc.mozCancelFullScreen || + doc.msExitFullscreen; + /* eslint-enable @typescript-eslint/unbound-method */ + const result = exit?.call(doc); + if (result instanceof Promise) result.catch(console.error); + } + /** * The HTML canvas element associated with this application's renderer. * @example diff --git a/packages/melonjs/src/input/keyboard.ts b/packages/melonjs/src/input/keyboard.ts index b00ab8600c..c711e13b11 100644 --- a/packages/melonjs/src/input/keyboard.ts +++ b/packages/melonjs/src/input/keyboard.ts @@ -1,5 +1,4 @@ import { emit, KEYDOWN, KEYUP } from "../system/event.ts"; -import { isMobile } from "../system/platform.ts"; import { preventDefault as preventDefaultAction } from "./input.ts"; // corresponding actions @@ -82,17 +81,15 @@ const keyUpEvent: KeyEventHandler = (options) => { export const keyBoardEventTarget = null; export function initKeyboardEvent() { - if (!isMobile) { - if (globalThis.addEventListener) { - globalThis.addEventListener( - "keydown", - (e) => { - keyDownEvent(e); - }, - false, - ); - globalThis.addEventListener("keyup", keyUpEvent, false); - } + if (typeof globalThis.addEventListener === "function") { + globalThis.addEventListener( + "keydown", + (e) => { + keyDownEvent(e); + }, + false, + ); + globalThis.addEventListener("keyup", keyUpEvent, false); } } diff --git a/packages/melonjs/src/lang/deprecated.js b/packages/melonjs/src/lang/deprecated.js index 14952d4be7..89c7ac5d78 100644 --- a/packages/melonjs/src/lang/deprecated.js +++ b/packages/melonjs/src/lang/deprecated.js @@ -1,5 +1,7 @@ +import { hasFullscreenSupport, isFullscreen } from "../system/fullscreen.ts"; import CanvasRenderer from "../video/canvas/canvas_renderer.js"; import CanvasRenderTarget from "../video/rendertarget/canvasrendertarget.js"; +import { getParent } from "../video/video.js"; import { Batcher } from "../video/webgl/batchers/batcher.js"; import PrimitiveBatcher from "../video/webgl/batchers/primitive_batcher.js"; import QuadBatcher from "../video/webgl/batchers/quad_batcher.js"; @@ -146,3 +148,57 @@ WebGLRenderer.prototype.setCompositor = function (name = "default", shader) { * Use lowercase `math` export instead. */ export * as Math from "./../math/math.ts"; + +/** + * Triggers a fullscreen request. Requires fullscreen support from the browser/device. + * + * Re-exported under `me.device.*` for backwards compatibility; the canonical + * post-19.7 entry point is `Application#requestFullscreen`, which uses the + * Application's own `parentElement` instead of the deprecated global-game canvas lookup. + * @param {Element} [element] - the element to be set in full-screen mode + * @deprecated since 19.7.0 — use {@link Application#requestFullscreen} instead. + */ +export function requestFullscreen(element) { + warning( + "device.requestFullscreen", + "Application#requestFullscreen", + "19.7.0", + ); + if (!hasFullscreenSupport || isFullscreen()) { + return; + } + const target = element ?? getParent(); + const request = + target.requestFullscreen || + target.webkitRequestFullscreen || + target.mozRequestFullScreen || + target.msRequestFullscreen; + const result = request?.call(target); + if (result instanceof Promise) { + result.catch(console.error); + } +} + +/** + * Exit fullscreen mode. Requires fullscreen support from the browser/device. + * + * Re-exported under `me.device.*` for backwards compatibility; the canonical + * post-19.7 entry point is `Application#exitFullscreen`. + * @deprecated since 19.7.0 — use {@link Application#exitFullscreen} instead. + */ +export function exitFullscreen() { + warning("device.exitFullscreen", "Application#exitFullscreen", "19.7.0"); + if (!hasFullscreenSupport || !isFullscreen()) { + return; + } + const doc = globalThis.document; + const exit = + doc.exitFullscreen || + doc.webkitExitFullscreen || + doc.mozCancelFullScreen || + doc.msExitFullscreen; + const result = exit?.call(doc); + if (result instanceof Promise) { + result.catch(console.error); + } +} diff --git a/packages/melonjs/src/system/device.js b/packages/melonjs/src/system/device.ts similarity index 68% rename from packages/melonjs/src/system/device.js rename to packages/melonjs/src/system/device.ts index e8a90dcdd2..2c42802996 100644 --- a/packages/melonjs/src/system/device.js +++ b/packages/melonjs/src/system/device.ts @@ -1,10 +1,27 @@ import { prefixed } from "./../utils/agent.ts"; -import { getParent } from "./../video/video.js"; import { DOMContentLoaded } from "./dom.ts"; import { BLUR, emit, FOCUS } from "./event.ts"; import * as device_platform from "./platform.ts"; import save from "./save.ts"; +// Narrow local typings for non-standard / legacy browser surfaces. The +// `lib.dom.d.ts` shipped with TypeScript omits vendor-prefixed properties +// and a handful of deprecated APIs that the engine still has to probe at +// runtime (Safari mobile, old Firefox / WebKit fullscreen, IE-era language +// fallbacks, iOS-only DeviceOrientationEvent.requestPermission, etc.). +type NavigatorLegacy = Navigator & { + browserLanguage?: string; + userLanguage?: string; + standalone?: boolean; +}; +type DeviceOrientationEventCtor = typeof DeviceOrientationEvent & { + requestPermission?: () => Promise<"granted" | "denied" | "default">; +}; +type ScreenLegacy = Screen & { + mozOrientation?: string; + msOrientation?: string; +}; + /** * device type and capabilities * @namespace device @@ -14,7 +31,10 @@ let accelInitialized = false; let deviceOrientationInitialized = false; // swipe utility fn & flag let swipeEnabled = true; -// a cache DOMRect object +// a reusable cached rect for getElementBounds() fallback. Plain object +// literal (not `new DOMRect()`) so module load doesn't ReferenceError in +// Node / SSR environments where the DOMRect constructor isn't defined. +// All fields are mutable here; cast to `DOMRect` at the return site. const domRect = { left: 0, top: 0, @@ -26,13 +46,13 @@ const domRect = { bottom: 0, }; -// a list of supported videoCodecs; -let videoCodecs; +// supported videoCodecs lookup (lazily populated on first hasVideoFormat call) +let videoCodecs: Record | undefined; // internal flag to avoid rechecking for support let WebGLSupport = -1; -function disableSwipeFn(e) { +function disableSwipeFn(e: Event) { e.preventDefault(); if (typeof globalThis.scroll === "function") { globalThis.scroll(0, 0); @@ -66,61 +86,49 @@ function hasOffscreenCanvas() { * used by [un]watchAccelerometer() * @ignore */ -function onDeviceMotion(e) { - // Accelerometer information - accelerationX = e.accelerationIncludingGravity.x; - accelerationY = e.accelerationIncludingGravity.y; - accelerationZ = e.accelerationIncludingGravity.z; +function onDeviceMotion(e: DeviceMotionEvent) { + const accel = e.accelerationIncludingGravity; + if (accel === null) { + return; + } + accelerationX = accel.x ?? 0; + accelerationY = accel.y ?? 0; + accelerationZ = accel.z ?? 0; } /** * used by [un]watchDeviceOrientation() * @ignore */ -export function onDeviceRotate(e) { - gamma = e.gamma; - beta = e.beta; - alpha = e.alpha; +export function onDeviceRotate(e: DeviceOrientationEvent) { + gamma = e.gamma ?? 0; + beta = e.beta ?? 0; + alpha = e.alpha ?? 0; } /** * the device platform type - * @memberof device - * @readonly - * @type {device.platform} */ export const platform = device_platform; /** * True if the browser supports Touch Events - * @memberof device - * @type {boolean} - * @readonly */ -export const touchEvent = !!("ontouchstart" in globalThis); +export const touchEvent = "ontouchstart" in globalThis; /** * True if the browser supports Pointer Events - * @memberof device - * @type {boolean} - * @readonly */ export const pointerEvent = !!globalThis.PointerEvent; /** * Touch capabilities (support either Touch or Pointer events) - * @memberof device - * @type {boolean} - * @readonly */ export const touch = touchEvent || (pointerEvent && globalThis.navigator.maxTouchPoints > 0); /** * the maximum number of simultaneous touch contact points are supported by the current device. - * @memberof device - * @type {number} - * @readonly * @example * if (me.device.maxTouchPoints > 1) { * // device supports multi-touch @@ -134,9 +142,6 @@ export const maxTouchPoints = touch /** * W3C standard wheel events - * @memberof device - * @type {boolean} - * @readonly */ export const wheel = typeof globalThis.document !== "undefined" && @@ -144,9 +149,6 @@ export const wheel = /** * Browser pointerlock api support - * @memberof device - * @type {boolean} - * @readonly */ export const hasPointerLockSupport = typeof globalThis.document !== "undefined" && @@ -154,72 +156,49 @@ export const hasPointerLockSupport = /** * Browser device orientation - * @memberof device - * @readonly - * @type {boolean} */ export const hasDeviceOrientation = !!globalThis.DeviceOrientationEvent; /** * Supports the ScreenOrientation API - * @memberof device * @see https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation/onchange - * @type {boolean} - * @readonly */ export const screenOrientation = typeof screen !== "undefined" && typeof screen.orientation !== "undefined"; /** * Browser accelerometer capabilities - * @memberof device - * @readonly - * @type {boolean} */ export const hasAccelerometer = !!globalThis.DeviceMotionEvent; -/** - * Browser full screen support - * @memberof device - * @type {boolean} - * @readonly - */ -export const hasFullscreenSupport = - typeof globalThis.document !== "undefined" && - (prefixed("fullscreenEnabled", globalThis.document) || - globalThis.document.mozFullScreenEnabled); +// `hasFullscreenSupport` + `isFullscreen` live in `system/fullscreen.ts` — +// factored out so `lang/deprecated.js` can use them without depending on +// `device.ts` (would create a circular dep with the deprecated-wrapper +// re-exports below). Re-exported here so `me.device.hasFullscreenSupport` +// / `me.device.isFullscreen` stay unchanged for consumers. +export { hasFullscreenSupport, isFullscreen } from "./fullscreen.ts"; /** * Device WebAudio Support - * @memberof device - * @type {boolean} - * @readonly */ export const hasWebAudio = !!( - globalThis.AudioContext || globalThis.webkitAudioContext + globalThis.AudioContext || + (globalThis as { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext ); /** * Device HTML5Audio Support - * @memberof device - * @type {boolean} - * @readonly */ export const hasHTML5Audio = typeof globalThis.Audio !== "undefined"; /** * Returns true if the browser/device has audio capabilities. - * @memberof device - * @type {boolean} - * @readonly */ export const sound = hasWebAudio || hasHTML5Audio; /** * Device Video Support - * @memberof device - * @type {boolean} - * @readonly */ export const hasVideo = typeof globalThis.document !== "undefined" && @@ -228,102 +207,69 @@ export const hasVideo = /** * Browser Local Storage capabilities
* (this flag will be set to false if cookies are blocked) - * @memberof device - * @readonly - * @type {boolean} */ export const localStorage = hasLocalStorage(); /** * equals to true if the device browser supports OffScreenCanvas. - * @memberof device - * @type {boolean} - * @readonly */ export const offscreenCanvas = hasOffscreenCanvas(); /** * Browser Base64 decoding capability - * @memberof device - * @type {boolean} - * @readonly */ export const nativeBase64 = typeof globalThis.atob === "function"; /** * a string representing the preferred language of the user, usually the language of the browser UI. * (will default to "en" if the information is not available) - * @memberof device - * @type {string} - * @readonly * @see http://www.w3schools.com/tags/ref_language_codes.asp */ export const language = typeof globalThis.navigator !== "undefined" ? globalThis.navigator.language || - globalThis.navigator.browserLanguage || - globalThis.navigator.userLanguage || + (globalThis.navigator as NavigatorLegacy).browserLanguage || + (globalThis.navigator as NavigatorLegacy).userLanguage || "en" : "en"; /** * Ratio of the resolution in physical pixels to the resolution in CSS pixels for the current display device. - * @memberof device - * @type {number} - * @readonly */ export const devicePixelRatio = globalThis.devicePixelRatio || 1; /** * equals to true if a mobile device. - * (Android | iPhone | iPad | iPod | BlackBerry | Windows Phone | Kindle) - * @memberof device - * @type {boolean} - * @readonly + * (Android | iPhone | iPad | iPod | any UA matching `Mobi`) */ export const isMobile = platform.isMobile; /** * contains the g-force acceleration along the x-axis. - * @memberof device - * @type {number} - * @readonly * @see device.watchAccelerometer */ export let accelerationX = 0; /** * contains the g-force acceleration along the y-axis. - * @memberof device - * @type {number} - * @readonly * @see device.watchAccelerometer */ export let accelerationY = 0; /** * contains the g-force acceleration along the z-axis. - * @memberof device - * @type {number} - * @readonly * @see device.watchAccelerometer */ export let accelerationZ = 0; /** * Device orientation Gamma property. Gives angle on tilting a portrait held phone left or right - * @memberof device - * @type {number} - * @readonly * @see device.watchDeviceOrientation */ export let gamma = 0; /** * Device orientation Beta property. Gives angle on tilting a portrait held phone forward or backward - * @memberof device - * @type {number} - * @readonly * @see device.watchDeviceOrientation */ export let beta = 0; @@ -331,25 +277,20 @@ export let beta = 0; /** * Device orientation Alpha property. Gives angle based on the rotation of the phone around its z axis. * The z-axis is perpendicular to the phone, facing out from the center of the screen. - * @memberof device - * @type {number} - * @readonly * @see device.watchDeviceOrientation */ export let alpha = 0; /** * Specify whether to automatically bring the window to the front - * @memberof device - * @type {boolean} * @default true */ +// eslint-disable-next-line prefer-const -- public mutable flag; reassignable via internal setters even though no setter exists today export let autoFocus = true; /** * specify a function to execute when the Device is fully loaded and ready - * @memberof device - * @param {Function} fn - the function to be executed + * @param fn - the function to be executed * @example * // small game skeleton * let game = { @@ -391,7 +332,7 @@ export let autoFocus = true; * @deprecated since 18.3.0 — no longer needed when using {@link Application} as entry point. * @category Application */ -export function onReady(fn) { +export function onReady(fn: () => void) { DOMContentLoaded(fn); } @@ -399,7 +340,6 @@ export function onReady(fn) { * Register blur/focus and visibility change event handlers. * Called once during boot to emit BLUR/FOCUS events when the * window or tab gains/loses focus. - * @memberof device * @ignore */ export function initVisibilityEvents() { @@ -415,7 +355,7 @@ export function initVisibilityEvents() { "focus", () => { emit(FOCUS); - if (autoFocus === true) { + if (autoFocus) { focus(); } }, @@ -428,7 +368,7 @@ export function initVisibilityEvents() { () => { if (globalThis.document.visibilityState === "visible") { emit(FOCUS); - if (autoFocus === true) { + if (autoFocus) { focus(); } } else { @@ -442,22 +382,21 @@ export function initVisibilityEvents() { /** * enable/disable swipe on WebView. - * @memberof device - * @param {boolean} [enable=true] - enable or disable swipe. + * @param [enable=true] - enable or disable swipe. * @category Application */ -export function enableSwipe(enable) { +export function enableSwipe(enable?: boolean) { const moveEvent = pointerEvent ? "pointermove" : touchEvent ? "touchmove" : "mousemove"; if (enable !== false) { - if (swipeEnabled === false) { + if (!swipeEnabled) { globalThis.document.removeEventListener(moveEvent, disableSwipeFn); swipeEnabled = true; } - } else if (swipeEnabled === true) { + } else if (swipeEnabled) { globalThis.document.addEventListener(moveEvent, disableSwipeFn, { passive: false, }); @@ -465,82 +404,42 @@ export function enableSwipe(enable) { } } -/** - * Returns true if the browser/device is in full screen mode. - * @memberof device - * @returns {boolean} - * @category Application - */ -export function isFullscreen() { - if (hasFullscreenSupport) { - return !!( - prefixed("fullscreenElement", globalThis.document) || - globalThis.document.mozFullScreenElement - ); - } else { - return false; - } -} - -/** - * Triggers a fullscreen request. Requires fullscreen support from the browser/device. - * @memberof device - * @param {Element} [element] - the element to be set in full-screen mode. - * @example - * // add a keyboard shortcut to toggle Fullscreen mode on/off - * me.input.bindKey(me.input.KEY.F, "toggleFullscreen"); - * me.event.on(me.event.KEYDOWN, function (action, keyCode, edge) { - * // toggle fullscreen on/off - * if (action === "toggleFullscreen") { - * me.device.requestFullscreen(); - * } else { - * me.device.exitFullscreen(); - * } - * }); - * @category Application - */ -export function requestFullscreen(element) { - if (hasFullscreenSupport && !isFullscreen()) { - element = element || getParent(); - element.requestFullscreen = - prefixed("requestFullscreen", element) || element.mozRequestFullScreen; - element.requestFullscreen(); - } -} - -/** - * Exit fullscreen mode. Requires fullscreen support from the browser/device. - * @memberof device - */ -export const exitFullscreen = () => { - if (hasFullscreenSupport && isFullscreen()) { - document.exitFullscreen(); - } -}; +// `requestFullscreen` / `exitFullscreen` were deprecated in 19.7.0 — the +// implementations live with the rest of the engine's deprecation surface in +// `lang/deprecated.js`. Re-exported here so they stay reachable as +// `me.device.requestFullscreen` / `me.device.exitFullscreen` for backwards +// compat; the disable below covers the re-export of `@deprecated` symbols +// (the whole point — consumers still get the JSDoc + runtime warning when +// they call them). +// eslint-disable-next-line @typescript-eslint/no-deprecated +export { exitFullscreen, requestFullscreen } from "../lang/deprecated.js"; /** * Return a string representing the orientation of the device screen. * It can be "any", "natural", "landscape", "portrait", "portrait-primary", "portrait-secondary", "landscape-primary", "landscape-secondary" - * @memberof device * @see https://developer.mozilla.org/en-US/docs/Web/API/Screen/orientation - * @returns {string} the screen orientation + * @returns the screen orientation * @category Application */ -export function getScreenOrientation() { +export function getScreenOrientation(): string { const PORTRAIT = "portrait"; const LANDSCAPE = "landscape"; const screen = globalThis.screen; // first try using "standard" values - if (screenOrientation === true) { - const orientation = prefixed("orientation", screen); + if (screenOrientation) { + const orientation = prefixed( + "orientation", + screen as unknown as Record, + ); if ( - typeof orientation !== "undefined" && - typeof orientation.type === "string" + typeof orientation === "object" && + orientation !== null && + typeof (orientation as ScreenOrientation).type === "string" ) { // Screen Orientation API specification - return orientation.type; + return (orientation as ScreenOrientation).type; } else if (typeof orientation === "string") { // moz/ms-orientation are strings return orientation; @@ -548,8 +447,12 @@ export function getScreenOrientation() { } // check using the deprecated API - if (typeof globalThis.orientation === "number") { - return Math.abs(globalThis.orientation) === 90 ? LANDSCAPE : PORTRAIT; + if ( + typeof (globalThis as { orientation?: number }).orientation === "number" + ) { + return Math.abs((globalThis as { orientation: number }).orientation) === 90 + ? LANDSCAPE + : PORTRAIT; } // fallback to window size check @@ -559,16 +462,18 @@ export function getScreenOrientation() { /** * locks the device screen into the specified orientation.
* This method only works for installed Web apps or for Web pages in full-screen mode. - * @memberof device * @see https://developer.mozilla.org/en-US/docs/Web/API/Screen/lockOrientation - * @param {string|string[]} orientation - The orientation into which to lock the screen. - * @returns {boolean} true if the orientation was unsuccessfully locked + * @param orientation - The orientation into which to lock the screen. + * @returns true if the orientation was unsuccessfully locked * @category Application */ -export function lockOrientation(orientation) { - const screen = globalThis.screen; +export function lockOrientation(orientation: string | string[]) { + const screen = globalThis.screen as ScreenLegacy | undefined; if (typeof screen !== "undefined") { - const _lockOrientation = prefixed("lockOrientation", screen); + const _lockOrientation = prefixed( + "lockOrientation", + screen as unknown as Record, + ) as ((orientation: string | string[]) => boolean) | undefined; if (typeof _lockOrientation !== "undefined") { return _lockOrientation(orientation); } @@ -579,15 +484,17 @@ export function lockOrientation(orientation) { /** * unlocks the device screen into the specified orientation.
* This method only works for installed Web apps or for Web pages in full-screen mode. - * @memberof device * @see https://developer.mozilla.org/en-US/docs/Web/API/Screen/lockOrientation - * @returns {boolean} true if the orientation was unsuccessfully unlocked + * @returns true if the orientation was unsuccessfully unlocked * @category Application */ export function unlockOrientation() { - const screen = globalThis.screen; + const screen = globalThis.screen as ScreenLegacy | undefined; if (typeof screen !== "undefined") { - const _unlockOrientation = prefixed("unlockOrientation", screen); + const _unlockOrientation = prefixed( + "unlockOrientation", + screen as unknown as Record, + ) as (() => boolean) | undefined; if (typeof _unlockOrientation !== "undefined") { return _unlockOrientation(); } @@ -597,8 +504,6 @@ export function unlockOrientation() { /** * return true if the device screen orientation is in Portrait mode - * @memberof device - * @returns {boolean} * @category Application */ export function isPortrait() { @@ -607,8 +512,6 @@ export function isPortrait() { /** * return true if the device screen orientation is in Portrait mode - * @memberof device - * @returns {boolean} * @category Application */ export function isLandscape() { @@ -617,50 +520,47 @@ export function isLandscape() { /** * return the device storage - * @memberof device * @see save - * @param {string} [type="local"] - * @returns {object} a reference to the device storage + * @param [type="local"] - storage type (currently only `"local"` is supported) + * @returns a reference to the device storage * @category Application */ -export function getStorage(type = "local") { +export function getStorage(type: string = "local") { switch (type) { case "local": return save; default: - throw new Error("storage type " + type + " not supported"); + throw new Error(`storage type ${type} not supported`); } } /** * return the parent DOM element for the given parent name or HTMLElement object - * @memberof device - * @param {string|HTMLElement} element - the parent element name or a HTMLElement object - * @returns {HTMLElement} the parent Element + * @param element - the parent element name or a HTMLElement object + * @returns the parent Element * @category Application */ -export function getParentElement(element) { - let target = getElement(element); +export function getParentElement(element: string | HTMLElement) { + let target: HTMLElement | (Node & ParentNode) = getElement(element); if (target.parentNode !== null) { target = target.parentNode; } - return target; + return target as HTMLElement; } /** * return the DOM element for the given element name or HTMLElement object - * @memberof device - * @param {string|HTMLElement} element - the parent element name or a HTMLElement object - * @returns {HTMLElement} the corresponding DOM Element or null if not existing + * @param element - the parent element name or a HTMLElement object + * @returns the corresponding DOM Element (falls back to `document.body` when the lookup fails or the input isn't an HTMLElement) * @category Application */ -export function getElement(element) { - let target = null; +export function getElement(element: string | HTMLElement): HTMLElement { + let target: HTMLElement | null = null; - if (element !== "undefined") { + if (typeof element !== "undefined") { if (typeof element === "string") { target = globalThis.document.getElementById(element); } else if ( @@ -683,13 +583,12 @@ export function getElement(element) { /** * returns the size of the given HTMLElement and its position relative to the viewport *
- * @memberof device * @see https://developer.mozilla.org/en-US/docs/Web/API/DOMRect - * @param {string|HTMLElement} element - an HTMLElement object - * @returns {DOMRect} the size and position of the element relatively to the viewport + * @param element - an HTMLElement object + * @returns the size and position of the element relatively to the viewport * @category Application */ -export function getElementBounds(element) { +export function getElementBounds(element: string | HTMLElement): DOMRect { if ( typeof element === "object" && element !== globalThis.document.body && @@ -699,38 +598,40 @@ export function getElementBounds(element) { } else { domRect.width = domRect.right = globalThis.innerWidth; domRect.height = domRect.bottom = globalThis.innerHeight; - return domRect; + return domRect as DOMRect; } } /** * returns the size of the given HTMLElement Parent and its position relative to the viewport *
- * @memberof device * @see https://developer.mozilla.org/en-US/docs/Web/API/DOMRect - * @param {string|HTMLElement} element - an HTMLElement object - * @returns {DOMRect} the size and position of the given element parent relative to the viewport + * @param element - an HTMLElement object + * @returns the size and position of the given element parent relative to the viewport * @category Application */ -export function getParentBounds(element) { +export function getParentBounds(element: string | HTMLElement) { return getElementBounds(getParentElement(element)); } /** * returns true if the device supports WebGL - * @memberof device - * @param {object} [options] - context creation options - * @param {boolean} [options.failIfMajorPerformanceCaveat=true] - If true, the renderer will switch to CANVAS mode if the performances of a WebGL context would be dramatically lower than that of a native application making equivalent OpenGL calls. - * @returns {boolean} true if WebGL is supported + * @param [options] - context creation options + * @param [options.failIfMajorPerformanceCaveat=true] - If true, the renderer will switch to CANVAS mode if the performances of a WebGL context would be dramatically lower than that of a native application making equivalent OpenGL calls. + * @returns true if WebGL is supported * @category Application */ -export function isWebGLSupported(options) { +export function isWebGLSupported(options?: { + failIfMajorPerformanceCaveat?: boolean; +}): boolean { if (WebGLSupport === -1) { try { const canvas = globalThis.document.createElement("canvas"); - const ctxOptions = { + const ctxOptions: WebGLContextAttributes = { stencil: true, - failIfMajorPerformanceCaveat: options.failIfMajorPerformanceCaveat, + ...(options?.failIfMajorPerformanceCaveat !== undefined && { + failIfMajorPerformanceCaveat: options.failIfMajorPerformanceCaveat, + }), }; const _supported = !!( globalThis.WebGLRenderingContext && @@ -747,7 +648,6 @@ export function isWebGLSupported(options) { /** * Makes a request to bring this device window to the front. - * @memberof device * @example * if (clicked) { * me.device.focus(); @@ -763,13 +663,12 @@ export function focus() { /** * Enable monitor of the device accelerator to detect the amount of physical force of acceleration the device is receiving. * (one some device a first user gesture will be required before calling this function) - * @memberof device * @see device.accelerationX * @see device.accelerationY * @see device.accelerationZ - * @link {http://www.mobilexweb.com/samples/ball.html} - * @link {http://www.mobilexweb.com/blog/safari-ios-accelerometer-websockets-html5} - * @returns {boolean} false if not supported or permission not granted by the user + * @see http://www.mobilexweb.com/samples/ball.html + * @see http://www.mobilexweb.com/blog/safari-ios-accelerometer-websockets-html5 + * @returns false if not supported or permission not granted by the user * @example * // try to enable device accelerometer event on user gesture * me.input.registerPointerEvent("pointerleave", app.viewport, function() { @@ -784,11 +683,9 @@ export function focus() { */ export function watchAccelerometer() { if (hasAccelerometer && !accelInitialized) { - if ( - DeviceOrientationEvent && - typeof DeviceOrientationEvent.requestPermission === "function" - ) { - DeviceOrientationEvent.requestPermission() + const DOECtor = DeviceOrientationEvent as DeviceOrientationEventCtor; + if (DOECtor && typeof DOECtor.requestPermission === "function") { + DOECtor.requestPermission() .then((response) => { if (response === "granted") { // add a listener for the devicemotion event @@ -808,7 +705,6 @@ export function watchAccelerometer() { /** * unwatch Accelerometer event - * @memberof device * @category Application */ export function unwatchAccelerometer() { @@ -822,11 +718,10 @@ export function unwatchAccelerometer() { /** * Enable monitor of the device orientation to detect the current orientation of the device as compared to the Earth coordinate frame. * (one some device a first user gesture will be required before calling this function) - * @memberof device * @see device.alpha * @see device.beta * @see device.gamma - * @returns {boolean} false if not supported or permission not granted by the user + * @returns false if not supported or permission not granted by the user * @example * // try to enable device orientation event on user gesture * me.input.registerPointerEvent("pointerleave", app.viewport, function() { @@ -841,8 +736,9 @@ export function unwatchAccelerometer() { */ export function watchDeviceOrientation() { if (hasDeviceOrientation && !deviceOrientationInitialized) { - if (typeof DeviceOrientationEvent.requestPermission === "function") { - DeviceOrientationEvent.requestPermission() + const DOECtor = DeviceOrientationEvent as DeviceOrientationEventCtor; + if (typeof DOECtor.requestPermission === "function") { + DOECtor.requestPermission() .then((response) => { if (response === "granted") { globalThis.addEventListener( @@ -864,7 +760,6 @@ export function watchDeviceOrientation() { /** * unwatch Device orientation event - * @memberof device * @category Application */ export function unwatchDeviceOrientation() { @@ -879,8 +774,7 @@ export function unwatchDeviceOrientation() { * If the device doesn't support vibration, this method has no effect.
* If a vibration pattern is already in progress when this method is called, * the previous pattern is halted and the new one begins instead. - * @memberof device - * @param {number|number[]} pattern - pattern of vibration and pause intervals + * @param pattern - pattern of vibration and pause intervals * @example * // vibrate for 1000 ms * me.device.vibrate(1000); @@ -892,7 +786,7 @@ export function unwatchDeviceOrientation() { * me.device.vibrate(0); * @category Application */ -export function vibrate(pattern) { +export function vibrate(pattern: number | number[]) { if ( typeof globalThis.navigator !== "undefined" && typeof globalThis.navigator.vibrate === "function" @@ -903,14 +797,15 @@ export function vibrate(pattern) { /** * detect if the given video format is supported - * @memberof device - * @param {"h264"|"h265"|"ogg"|"mp4"|"m4v"|"webm"|"vp9"|"hls"} codec - the video format to check for support - * @returns {boolean} return true if the given video format is supported + * @param codec - the video format to check for support + * @returns return true if the given video format is supported * @category Application */ -export function hasVideoFormat(codec) { +export function hasVideoFormat( + codec: "h264" | "h265" | "ogg" | "mp4" | "m4v" | "webm" | "vp9" | "hls", +): boolean { let result = false; - if (hasVideo === true) { + if (hasVideo) { if (typeof videoCodecs === "undefined") { // check for support const videoElement = globalThis.document.createElement("video"); diff --git a/packages/melonjs/src/system/fullscreen.ts b/packages/melonjs/src/system/fullscreen.ts new file mode 100644 index 0000000000..d2daed52d9 --- /dev/null +++ b/packages/melonjs/src/system/fullscreen.ts @@ -0,0 +1,51 @@ +// Fullscreen-capability probes, factored out of `device.ts` so the +// (small, leaf-level) `lang/deprecated.js` re-export wrappers can use them +// without creating a circular import — device.ts re-exports these so +// `me.device.hasFullscreenSupport` / `me.device.isFullscreen` stay +// byte-for-byte unchanged for consumers. +// +// `lib.dom.d.ts` only carries the unprefixed `fullscreenEnabled` / +// `fullscreenElement` fields; older WebKit / Gecko / IE-derived engines +// still surface state via vendor-prefixed variants. The intersection type +// below is local + un-exported; consumers cast through it. +type DocumentLegacy = Document & { + mozFullScreenEnabled?: boolean; + mozFullScreenElement?: Element; + webkitFullscreenEnabled?: boolean; + webkitFullscreenElement?: Element; + msFullscreenEnabled?: boolean; + msFullscreenElement?: Element; +}; + +/** + * Browser full screen support — probed once at module load via the + * unprefixed `fullscreenEnabled` field with vendor-prefixed fallbacks. + */ +export const hasFullscreenSupport = + typeof globalThis.document !== "undefined" && + !!( + globalThis.document.fullscreenEnabled || + (globalThis.document as DocumentLegacy).webkitFullscreenEnabled || + (globalThis.document as DocumentLegacy).mozFullScreenEnabled || + (globalThis.document as DocumentLegacy).msFullscreenEnabled + ); + +/** + * Returns true if the browser/device is in full screen mode. + * + * Pure document-state probe — no Application context needed, since the + * browser tracks exactly one fullscreen element per document regardless + * of how many Applications are running. + * @category Application + * @returns true when any of the four vendor variants of `fullscreenElement` is non-null + */ +export function isFullscreen(): boolean { + if (!hasFullscreenSupport) return false; + const doc = globalThis.document as DocumentLegacy; + return !!( + doc.fullscreenElement || + doc.webkitFullscreenElement || + doc.mozFullScreenElement || + doc.msFullscreenElement + ); +} diff --git a/packages/melonjs/src/system/platform.ts b/packages/melonjs/src/system/platform.ts index 30957489ca..8e4a45074b 100644 --- a/packages/melonjs/src/system/platform.ts +++ b/packages/melonjs/src/system/platform.ts @@ -5,12 +5,12 @@ * ua the user agent string for the current device * iOS `true` if the device is an iOS platform * android `true` if the device is an Android platform - * android2 `true` if the device is an Android 2.x platform + * android2 `true` if the device is an Android 2.x platform (deprecated) * linux `true` if the device is a Linux platform * chromeOS `true` if the device is running on ChromeOS. - * wp `true` if the device is a Windows Phone platform - * BlackBerry`true` if the device is a BlackBerry platform - * Kindle`true` if the device is a Kindle platform + * wp `true` if the device is a Windows Phone platform (deprecated) + * BlackBerry `true` if the device is a BlackBerry platform (deprecated) + * Kindle `true` if the device is a Kindle platform (deprecated) * ejecta `true` if running under Ejecta * isWeixin `true` if running under Wechat * nodeJS `true` if running under node.js @@ -22,13 +22,63 @@ export const ua = typeof globalThis.navigator !== "undefined" ? globalThis.navigator.userAgent : ""; -export const iOS = /iPhone|iPad|iPod/i.test(ua); + +// iPadOS 13+ (Sept 2019) ships Safari with the desktop Mac UA — no `iPad` +// token. Feature-detect the iPad-on-Mac-UA case so `iOS` / `isMobile` +// don't miss every modern iPad: +// +// - `navigator.platform === "MacIntel"` is the Mac identity Apple keeps +// frozen on Apple Silicon Macs/iPads for backwards compat (same trick +// as `Win32` on 64-bit Windows). NOT a CPU check — `MacIntel` persists +// on M1/M2/M3/M4. +// - `maxTouchPoints > 1` excludes actual Macs (no touchscreens) and +// keeps real iPads (multi-touch digitizers). +// +/** + * iPad-on-Mac-UA predicate. Exported so the spec file can assert the + * SAME function the module evaluates at load time (no drift between + * docs and implementation), but marked `@internal` because it's a + * test-seam, not a stable public API — the engine reserves the right + * to change / inline / rename it without a breaking-change bump. + * + * Parameter shape is `Partial>` rather than a + * named alias so no engine-defined type leaks into the emitted + * `.d.ts` (`tsconfig.build.json` doesn't currently set + * `stripInternal`). + * @param nav - a `navigator`-shaped object (or `undefined` for Node/SSR) + * @returns `true` when `nav` looks like an iPad reporting under the iPadOS-13+ desktop Mac UA + * @internal + */ +export function isIPadOnMacUA( + nav: Partial> | undefined, +): boolean { + return nav?.platform === "MacIntel" && (nav?.maxTouchPoints ?? 0) > 1; +} + +const _nav = + typeof globalThis.navigator !== "undefined" + ? globalThis.navigator + : undefined; + +export const iOS = /iPhone|iPad|iPod/i.test(ua) || isIPadOnMacUA(_nav); export const android = /Android/i.test(ua); +/** + * @deprecated since 19.7.0 — Android 2.x predates 2012. Will be removed in 20.x. + */ export const android2 = /Android 2/i.test(ua); export const linux = /Linux/i.test(ua); export const chromeOS = /CrOS/.test(ua); +/** + * @deprecated since 19.7.0 — Windows Phone was EOL'd by Microsoft in 2017. Will be removed in 20.x. + */ export const wp = /Windows Phone/i.test(ua); +/** + * @deprecated since 19.7.0 — BlackBerry stopped shipping BB10 devices in 2016. Will be removed in 20.x. + */ export const BlackBerry = /BlackBerry/i.test(ua); +/** + * @deprecated since 19.7.0 — Kindle has a negligible market share and behaves like Android. Will be removed in 20.x. + */ export const Kindle = /Kindle|Silk.*Mobile Safari/i.test(ua); export const ejecta = "ejecta" in globalThis; export const isWeixin = /MicroMessenger/i.test(ua); @@ -42,8 +92,13 @@ export const nodeJS = typeof _proc !== "undefined" && typeof _proc.release !== "undefined" && _proc.release.name === "node"; -export const isMobile = - /Mobi/i.test(ua) || iOS || android || wp || BlackBerry || Kindle || false; +// `Mobi` substring matches Firefox + Chrome + Safari mobile UAs in +// 2026 (MDN's recommended fallback); the `iOS || android` chain +// catches the few outliers and the iPad-on-Mac-UA case. Dropped +// `wp` / `BlackBerry` / `Kindle` — the underlying platforms are EOL +// and the regexes were burning cycles on every page load for +// hardware nobody ships. +export const isMobile = /Mobi/i.test(ua) || iOS || android; export const webApp = (typeof globalThis.navigator !== "undefined" && "standalone" in globalThis.navigator && diff --git a/packages/melonjs/tests/platform.spec.ts b/packages/melonjs/tests/platform.spec.ts index 295f77a98f..17a30ce1a5 100644 --- a/packages/melonjs/tests/platform.spec.ts +++ b/packages/melonjs/tests/platform.spec.ts @@ -6,6 +6,7 @@ import { chromeOS, ejecta, iOS, + isIPadOnMacUA, isMobile, isWeixin, Kindle, @@ -81,10 +82,65 @@ describe("system/platform", () => { // `isMobile` is the OR of the mobile-UA hits — verify it's not // silently true on our desktop runner (a regression in the OR // chain would page every test that branches on this). - it("isMobile === iOS || android || wp || BlackBerry || Kindle || /Mobi/.test(ua)", () => { - const expected = - /Mobi/i.test(ua) || iOS || android || wp || BlackBerry || Kindle; + // + // Note: as of #1467, `wp` / `BlackBerry` / `Kindle` are deprecated + // and NO LONGER participate in this OR chain. Their underlying + // platforms are EOL (Windows Phone 2017, BB10 2016) and the + // regexes were burning cycles for hardware nobody ships. The + // exports themselves stay around so any external consumer + // (third-party plugin, user code) keeps working through 19.x. + it("isMobile === /Mobi/.test(ua) || iOS || android", () => { + const expected = /Mobi/i.test(ua) || iOS || android; expect(isMobile).toBe(expected); }); }); + + describe("iPadOS 13+ detection (#1467)", () => { + // iPadOS 13 (Sept 2019) made Safari ship the desktop Mac UA by + // default — no `iPad` token. Pure UA regex misses every modern + // iPad. The fix layers a feature-detection check on top: + // `navigator.platform === "MacIntel"` (Apple-frozen legacy + // string, persists on Apple Silicon Macs/iPads for compat) + + // `maxTouchPoints > 1` (Macs don't have touchscreens; iPads do). + // + // These tests assert the REAL exported `isIPadOnMacUA` predicate + // from `platform.ts` — the same function the module calls at + // load time to compute `iOS`. No drift possible: a regression + // in the predicate (e.g. flipping `> 1` to `> 0`, or dropping + // the `platform === "MacIntel"` check) surfaces here. + + it("flags an Apple Silicon iPad reporting as Mac (platform=MacIntel, maxTouchPoints=5)", () => { + expect(isIPadOnMacUA({ platform: "MacIntel", maxTouchPoints: 5 })).toBe( + true, + ); + }); + + it("does not flag an actual Mac (platform=MacIntel, no touch)", () => { + expect(isIPadOnMacUA({ platform: "MacIntel", maxTouchPoints: 0 })).toBe( + false, + ); + }); + + it("does not flag a Mac with `maxTouchPoints` undefined (older Safari)", () => { + expect(isIPadOnMacUA({ platform: "MacIntel" })).toBe(false); + }); + + it("does not flag Windows touchscreen (platform=Win32, maxTouchPoints=10)", () => { + expect(isIPadOnMacUA({ platform: "Win32", maxTouchPoints: 10 })).toBe( + false, + ); + }); + + it("does not flag a missing navigator (Node/SSR)", () => { + expect(isIPadOnMacUA(undefined)).toBe(false); + }); + + it("does not flag a device reporting `maxTouchPoints === 1`", () => { + // The check uses `> 1`, not `> 0`. A hypothetical single-point + // touch device should not trip it — multi-touch is iPad-class. + expect(isIPadOnMacUA({ platform: "MacIntel", maxTouchPoints: 1 })).toBe( + false, + ); + }); + }); });