diff --git a/README.md b/README.md index 489569478..9dd8e5346 100644 --- a/README.md +++ b/README.md @@ -163,30 +163,29 @@ Browse all examples [here](https://melonjs.github.io/melonJS/examples/) ### Basic Hello World Example ```JavaScript -import * as me from "https://cdn.jsdelivr.net/npm/melonjs/+esm"; - -me.device.onReady(function () { - // initialize the display canvas once the device/browser is ready - if (!me.video.init(1218, 562, {parent : "screen", scale : "auto"})) { - alert("Your browser does not support HTML5 canvas."); - return; - } - - // set a gray background color - me.game.world.backgroundColor.parseCSS("#202020"); - - // add a font text display object - me.game.world.addChild(new me.Text(609, 281, { - font: "Arial", - size: 160, - fillStyle: "#FFFFFF", - textBaseline : "middle", - textAlign : "center", - text : "Hello World !" - })); +import { Application, Text } from "https://cdn.jsdelivr.net/npm/melonjs/+esm"; + +// create a new melonJS application +const app = new Application(1218, 562, { + parent: "screen", + scale: "auto", + backgroundColor: "#202020", }); + +// set a gray background color +app.world.backgroundColor.parseCSS("#202020"); + +// add a font text display object +app.world.addChild(new Text(609, 281, { + font: "Arial", + size: 160, + fillStyle: "#FFFFFF", + textBaseline: "middle", + textAlign: "center", + text: "Hello World !", +})); ``` -> Simple hello world using melonJS 2 +> Simple hello world using melonJS Documentation ------------------------------------------------------------------------------- diff --git a/packages/examples/src/examples/platformer/createGame.ts b/packages/examples/src/examples/platformer/createGame.ts index 136c5e468..1cd697599 100644 --- a/packages/examples/src/examples/platformer/createGame.ts +++ b/packages/examples/src/examples/platformer/createGame.ts @@ -1,5 +1,6 @@ import { DebugPanelPlugin } from "@melonjs/debug-plugin"; import { + Application, audio, device, event, @@ -9,7 +10,6 @@ import { pool, state, TextureAtlas, - video, } from "melonjs"; import { CoinEntity } from "./entities/coin.js"; import { FlyEnemyEntity, SlimeEnemyEntity } from "./entities/enemies.js"; @@ -19,63 +19,52 @@ import { PlayScreen } from "./play.js"; import { resources } from "./resources.js"; export const createGame = () => { - // init the video - if ( - !video.init(800, 600, { - parent: "screen", - scaleMethod: "flex-width", - renderer: video.AUTO, - preferWebGL1: false, - depthTest: "z-buffer", - subPixel: false, - }) - ) { - alert("Your browser does not support HTML5 canvas."); - return; - } + // create a new melonJS Application + const app = new Application(800, 600, { + parent: "screen", + scaleMethod: "flex-width", + renderer: 2, // AUTO + preferWebGL1: false, + depthTest: "z-buffer", + subPixel: false, + }); // register the debug plugin plugin.register(DebugPanelPlugin, "debugPanel"); - // initialize the "sound engine" + // initialize the sound engine audio.init("mp3,ogg"); // allow cross-origin for image/texture loading loader.setOptions({ crossOrigin: "anonymous" }); - // set all ressources to be loaded + // preload all resources loader.preload(resources, () => { - // set the "Play/Ingame" Screen Object + // set the Play screen state.set(state.PLAY, new PlayScreen()); // set the fade transition effect state.transition("fade", "#FFFFFF", 250); - // register our objects entity in the object pool + // register entity classes in the object pool pool.register("mainPlayer", PlayerEntity); pool.register("SlimeEntity", SlimeEnemyEntity); pool.register("FlyEntity", FlyEnemyEntity); pool.register("CoinEntity", CoinEntity, true); - // load the texture atlas file - // this will be used by renderable object later + // load the texture atlas gameState.texture = new TextureAtlas( loader.getJSON("texture"), loader.getImage("texture"), ); - // add some keyboard shortcuts - event.on(event.KEYDOWN, (_action, keyCode /*, edge */) => { - // change global volume setting + // keyboard shortcuts for volume and fullscreen + event.on(event.KEYDOWN, (_action, keyCode) => { if (keyCode === input.KEY.PLUS) { - // increase volume audio.setVolume(audio.getVolume() + 0.1); } else if (keyCode === input.KEY.MINUS) { - // decrease volume audio.setVolume(audio.getVolume() - 0.1); } - - // toggle fullscreen on/off if (keyCode === input.KEY.F) { if (!device.isFullscreen()) { device.requestFullscreen(); @@ -85,7 +74,7 @@ export const createGame = () => { } }); - // switch to PLAY state + // switch to the Play state state.change(state.PLAY); }); }; diff --git a/packages/examples/src/examples/platformer/entities/player.ts b/packages/examples/src/examples/platformer/entities/player.ts index 7ecfde607..7b7c9291d 100644 --- a/packages/examples/src/examples/platformer/entities/player.ts +++ b/packages/examples/src/examples/platformer/entities/player.ts @@ -2,7 +2,6 @@ import { audio, Body, collision, - game, input, level, Rect, @@ -61,9 +60,6 @@ export class PlayerEntity extends Sprite { this.multipleJump = 1; - // set the viewport to follow this renderable on both axis, and enable damping - game.viewport.follow(this, game.viewport.AXIS.BOTH, 0.1); - // enable keyboard input.bindKey(input.KEY.LEFT, "left"); input.bindKey(input.KEY.RIGHT, "right"); @@ -150,7 +146,16 @@ export class PlayerEntity extends Sprite { } /** - ** update the force applied + * called when added to the game world + */ + onActivateEvent() { + const app = this.parentApp; + // set the viewport to follow this renderable on both axis, and enable damping + app.viewport.follow(this, app.viewport.AXIS.BOTH, 0.1); + } + + /** + * update the force applied */ update(dt) { if (input.isKeyPressed("left")) { @@ -192,12 +197,13 @@ export class PlayerEntity extends Sprite { // check if we fell into a hole if (!this.inViewport && this.getBounds().top > video.renderer.height) { + const app = this.parentApp; // if yes reset the game - game.world.removeChild(this); - game.viewport.fadeIn("#fff", 150, () => { + app.world.removeChild(this); + app.viewport.fadeIn("#fff", 150, () => { audio.play("die", false); level.reload(); - game.viewport.fadeOut("#fff", 150); + app.viewport.fadeOut("#fff", 150); }); return true; } @@ -211,7 +217,7 @@ export class PlayerEntity extends Sprite { } /** - * colision handler + * collision handler */ onCollision(response, other) { switch (other.body.collisionType) { @@ -228,7 +234,7 @@ export class PlayerEntity extends Sprite { ) { // Disable collision on the x axis response.overlapV.x = 0; - // Repond to the platform (it is solid) + // Respond to the platform (it is solid) return true; } // Do not respond to the platform (pass through) @@ -286,7 +292,7 @@ export class PlayerEntity extends Sprite { }); // flash the screen - game.viewport.fadeIn("#FFFFFF", 75); + this.parentApp.viewport.fadeIn("#FFFFFF", 75); audio.play("die", false); } } diff --git a/packages/examples/src/examples/platformer/play.ts b/packages/examples/src/examples/platformer/play.ts index 28b0d5dec..cf003527b 100644 --- a/packages/examples/src/examples/platformer/play.ts +++ b/packages/examples/src/examples/platformer/play.ts @@ -1,4 +1,4 @@ -import { audio, device, game, level, plugin, Stage } from "melonjs"; +import { type Application, audio, device, level, plugin, Stage } from "melonjs"; import { VirtualJoypad } from "./entities/controls"; import UIContainer from "./entities/HUD"; import { MinimapCamera } from "./entities/minimap"; @@ -11,7 +11,7 @@ export class PlayScreen extends Stage { /** * action to perform on state change */ - override onResetEvent() { + override onResetEvent(app: Application) { // load a level level.load("map1"); @@ -27,14 +27,14 @@ export class PlayScreen extends Stage { if (typeof this.HUD === "undefined") { this.HUD = new UIContainer(); } - game.world.addChild(this.HUD); + app.world.addChild(this.HUD); // display if debugPanel is enabled or on mobile if (plugin.cache.debugPanel?.panel.visible || device.touch) { if (typeof this.virtualJoypad === "undefined") { this.virtualJoypad = new VirtualJoypad(); } - game.world.addChild(this.virtualJoypad); + app.world.addChild(this.virtualJoypad); } // play some music @@ -44,15 +44,15 @@ export class PlayScreen extends Stage { /** * action to perform on state change */ - override onDestroyEvent() { + override onDestroyEvent(app: Application) { // remove the HUD from the game world if (this.HUD) { - game.world.removeChild(this.HUD); + app.world.removeChild(this.HUD); } // remove the joypad if initially added - if (this.virtualJoypad && game.world.hasChild(this.virtualJoypad)) { - game.world.removeChild(this.virtualJoypad); + if (this.virtualJoypad && app.world.hasChild(this.virtualJoypad)) { + app.world.removeChild(this.virtualJoypad); } // stop some music diff --git a/packages/examples/src/examples/ui/ExampleUI.tsx b/packages/examples/src/examples/ui/ExampleUI.tsx index 480263259..af1dfb6ec 100644 --- a/packages/examples/src/examples/ui/ExampleUI.tsx +++ b/packages/examples/src/examples/ui/ExampleUI.tsx @@ -1,6 +1,7 @@ import { + Application as App, + type Application, ColorLayer, - game, loader, Stage, state, @@ -41,7 +42,6 @@ class ButtonUI extends UISpriteElement { fillStyle: "black", textAlign: "center", textBaseline: "middle", - offScreenCanvas: video.renderer.WebGLVersion >= 1, }); } @@ -123,7 +123,6 @@ class CheckBoxUI extends UISpriteElement { textAlign: "left", textBaseline: "middle", text: offLabel, - offScreenCanvas: true, }); this.getBounds().width += this.font.measureText().width; @@ -204,8 +203,8 @@ class UIContainer extends UIBaseElement { } class PlayScreen extends Stage { - override onResetEvent() { - game.world.addChild( + override onResetEvent(app: Application) { + app.world.addChild( new ColorLayer("background", "rgba(248, 194, 40, 1.0)"), 0, ); @@ -243,21 +242,16 @@ class PlayScreen extends Stage { panel.addChild(new ButtonUI(30, 250, "green", "Accept")); panel.addChild(new ButtonUI(230, 250, "yellow", "Cancel")); - game.world.addChild(panel, 1); + app.world.addChild(panel, 1); } } const createGame = () => { - if ( - !video.init(800, 600, { - parent: "screen", - scale: "auto", - scaleMethod: "flex-width", - }) - ) { - alert("Your browser does not support HTML5 canvas."); - return; - } + const app = new App(800, 600, { + parent: "screen", + scale: "auto", + scaleMethod: "flex-width", + }); const resources = [ { name: "UI_Assets-0", type: "image", src: `${base}img/UI_Assets-0.png` }, diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 5925ecfe8..9e75a793f 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -14,6 +14,12 @@ - Application: new `canvas` getter, `resize()`, and `destroy()` convenience methods - Application: `GAME_INIT` event now passes the Application instance as parameter - Stage: `onResetEvent(app, ...args)` now receives the Application instance as first parameter, followed by any extra arguments from `state.change()` +- Stage: `onDestroyEvent(app)` now receives the Application instance as parameter +- Container: default dimensions are now `Infinity` (no intrinsic size, no clipping) — removes dependency on `game.viewport`. `anchorPoint` is always `(0, 0)` as containers act as grouping/transform nodes +- ImageLayer: decoupled from `game` singleton — uses `parentApp` for viewport and renderer access; `resize`, `createPattern` and event listeners deferred to `onActivateEvent` +- TMXTileMap: `addTo()` resolves viewport from the container tree via `getRootAncestor().app` instead of `game.viewport` +- Input: pointer and pointerevent modules now receive the Application instance via `GAME_INIT` event instead of importing the `game` singleton +- video: `video.renderer` and `video.init()` are now deprecated — use `new Application(width, height, options)` and `app.renderer` instead. `video.renderer` is kept in sync via `VIDEO_INIT` for backward compatibility - EventEmitter: native context parameter support — `addListener(event, fn, context)` and `addListenerOnce(event, fn, context)` now accept an optional context, eliminating `.bind()` closure overhead and enabling proper `removeListener()` by original function reference - EventEmitter: `event.on()` and `event.once()` no longer create `.bind()` closures when a context is provided @@ -25,6 +31,8 @@ - Application: prevent white flash on load by setting a black background on the parent element when no background is defined - WebGLRenderer: `setBlendMode()` now tracks the `premultipliedAlpha` flag — previously only the mode name was checked, causing incorrect GL blend function when mixing PMA and non-PMA textures with the same blend mode - TMX: fix crash in `getObjects(false)` when a map contains an empty object group (Container.children lazily initialized) +- Container: fix `updateBounds()` producing NaN when container has Infinity dimensions (skip parent bounds computation for non-finite containers, derive bounds from children only) +- Container: fix circular import in `BitmapTextData` pool registration (`pool.ts` ↔ `bitmaptextdata.ts`) - EventEmitter: `removeAllListeners()` now correctly clears once-listeners (previously only cleared regular listeners) - Loader: fix undefined `crossOrigin` variable in script parser, unsafe regex match in video parser, missing error parameter in video/fontface error callbacks, `fetchData` Promise constructor antipattern and silent error swallowing diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts index 53540f21b..273780381 100644 --- a/packages/melonjs/src/application/application.ts +++ b/packages/melonjs/src/application/application.ts @@ -25,6 +25,7 @@ import { VIDEO_INIT, WINDOW_ONORIENTATION_CHANGE, WINDOW_ONRESIZE, + WINDOW_ONSCROLL, } from "../system/event.ts"; import timer from "../system/timer.ts"; import { getUriFragment } from "../utils/utils.ts"; @@ -41,9 +42,32 @@ import type { } from "./settings.ts"; /** - * An Application represents a single melonJS game, and is responsible for updating (each frame) all the related object status and draw them. + * The Application class is the main entry point for creating a melonJS game. + * It initializes the renderer, creates the game world and viewport, registers DOM event + * listeners (resize, orientation, scroll), and starts the game loop. + * + * The Application instance provides access to the core game systems: + * - {@link Application#renderer renderer} — the active Canvas or WebGL renderer + * - {@link Application#world world} — the root container for all game objects + * - {@link Application#viewport viewport} — the default camera / viewport + * + * The app instance is automatically passed to {@link Stage#onResetEvent} and + * {@link Stage#onDestroyEvent}, and is accessible from any renderable via + * {@link Renderable#parentApp parentApp}. * @category Application - * @see {@link game} + * @example + * // create a new melonJS Application + * const app = new Application(800, 600, { + * parent: "screen", + * scaleMethod: "flex-width", + * renderer: 2, // AUTO + * }); + * + * // add objects to the world + * app.world.addChild(new Sprite(0, 0, { image: "player" })); + * + * // access the viewport + * app.viewport.follow(player, app.viewport.AXIS.BOTH); */ export default class Application { /** @@ -98,7 +122,7 @@ export default class Application { * @default true * @example * // keep the default game instance running even when losing focus - * me.game.pauseOnBlur = false; + * app.pauseOnBlur = false; */ pauseOnBlur: boolean; @@ -131,15 +155,29 @@ export default class Application { // min update step size stepSize: number; + + // DOM event handlers (stored for cleanup in destroy) + private _onResize?: (e: Event) => void; + private _onOrientationChange?: (e: Event) => void; + private _onScroll?: (e: Event) => void; updateDelta: number; lastUpdateStart: number | null; updateAverageDelta: number; /** + * Create and initialize a new melonJS Application. + * This is the recommended way to start a melonJS game. * @param width - The width of the canvas viewport * @param height - The height of the canvas viewport * @param options - The optional parameters for the application and default renderer * @throws {Error} Will throw an exception if it fails to instantiate a renderer + * @example + * const app = new Application(1024, 768, { + * parent: "game-container", + * scale: "auto", + * scaleMethod: "fit", + * renderer: 2, // AUTO + * }); */ constructor( width: number, @@ -279,7 +317,24 @@ export default class Application { // make this the active game instance for modules that reference the global setDefaultGame(this); - // register to the channel + // bridge DOM events to the melonJS event system + this._onResize = (e: Event) => { + emit(WINDOW_ONRESIZE, e); + }; + this._onOrientationChange = (e: Event) => { + emit(WINDOW_ONORIENTATION_CHANGE, e); + }; + this._onScroll = (e: Event) => { + emit(WINDOW_ONSCROLL, e); + }; + globalThis.addEventListener("resize", this._onResize); + globalThis.addEventListener("orientationchange", this._onOrientationChange); + if (device.screenOrientation) { + globalThis.screen.orientation.onchange = this._onOrientationChange; + } + globalThis.addEventListener("scroll", this._onScroll); + + // react to resize/orientation changes on(WINDOW_ONRESIZE, () => { onresize(this); }); @@ -392,7 +447,7 @@ export default class Application { * Additionally the level id will also be passed to the called function. * @example * // call myFunction () everytime a level is loaded - * me.game.onLevelLoaded = this.myFunction.bind(this); + * app.onLevelLoaded = this.myFunction.bind(this); */ onLevelLoaded(): void {} @@ -467,6 +522,19 @@ export default class Application { off(STAGE_RESET, this.reset, this); /* eslint-enable @typescript-eslint/unbound-method */ + // remove DOM event listeners + if (this._onResize) { + globalThis.removeEventListener("resize", this._onResize); + globalThis.removeEventListener( + "orientationchange", + this._onOrientationChange!, + ); + globalThis.removeEventListener("scroll", this._onScroll!); + if (device.screenOrientation) { + globalThis.screen.orientation.onchange = null; + } + } + // destroy the world and all its children if (this.world) { this.world.destroy(); @@ -596,6 +664,8 @@ export default class Application { /** * The default game application instance. * Set via {@link setDefaultGame} during engine initialization. + * When using {@link Application} directly, prefer using the app instance + * (e.g. from {@link Stage#onResetEvent} or {@link Renderable#parentApp}). */ export let game: Application; diff --git a/packages/melonjs/src/application/settings.ts b/packages/melonjs/src/application/settings.ts index 5c42b2cae..37c5e0059 100644 --- a/packages/melonjs/src/application/settings.ts +++ b/packages/melonjs/src/application/settings.ts @@ -107,8 +107,8 @@ export type ApplicationSettings = { batcher?: (new (renderer: any) => Batcher) | undefined; } & ( | { - // the DOM parent element to hold the canvas in the HTML file - parent: HTMLElement; + // the DOM parent element (or its string ID) to hold the canvas in the HTML file + parent: string | HTMLElement; canvas?: never; } | { diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts index b90a61f16..14a7868a8 100644 --- a/packages/melonjs/src/camera/camera2d.ts +++ b/packages/melonjs/src/camera/camera2d.ts @@ -56,7 +56,7 @@ const targetV = new Vector2d(); * // create a minimap camera in the top-right corner showing the full level * const minimap = new Camera2d(0, 0, 180, 100); * minimap.name = "minimap"; - * minimap.screenX = game.viewport.width - 190; + * minimap.screenX = app.viewport.width - 190; * minimap.screenY = 10; * minimap.autoResize = false; * minimap.setBounds(0, 0, levelWidth, levelHeight); @@ -495,11 +495,11 @@ export default class Camera2d extends Renderable { * set the camera to follow the specified renderable.
* (this will put the camera center around the given target) * @param target - renderable or position vector to follow - * @param [axis=me.game.viewport.AXIS.BOTH] - Which axis to follow (see {@link Camera2d.AXIS}) + * @param [axis=app.viewport.AXIS.BOTH] - Which axis to follow (see {@link Camera2d.AXIS}) * @param [damping=1] - default damping value * @example * // set the camera to follow this renderable on both axis, and enable damping - * me.game.viewport.follow(this, me.game.viewport.AXIS.BOTH, 0.1); + * app.viewport.follow(this, app.viewport.AXIS.BOTH, 0.1); */ follow( target: Renderable | Vector2d | Vector3d, @@ -545,7 +545,7 @@ export default class Camera2d extends Renderable { * @param y - vertical offset * @example * // Move the camera up by four pixels - * me.game.viewport.move(0, -4); + * app.viewport.move(0, -4); */ move(x: number, y: number): void { this.moveTo(this.pos.x + x, this.pos.y + y); @@ -675,12 +675,12 @@ export default class Camera2d extends Renderable { * @param intensity - maximum offset that the screen can be moved * while shaking * @param duration - expressed in milliseconds - * @param [axis=me.game.viewport.AXIS.BOTH] - specify on which axis to apply the shake effect (see {@link Camera2d.AXIS}) + * @param [axis=app.viewport.AXIS.BOTH] - specify on which axis to apply the shake effect (see {@link Camera2d.AXIS}) * @param [onComplete] - callback once shaking effect is over * @param [force] - if true this will override the current effect * @example * // shake it baby ! - * me.game.viewport.shake(10, 500, me.game.viewport.AXIS.BOTH); + * app.viewport.shake(10, 500, app.viewport.AXIS.BOTH); */ shake( intensity: number, @@ -706,10 +706,10 @@ export default class Camera2d extends Renderable { * @param [onComplete] - callback once effect is over * @example * // fade the camera to white upon dying, reload the level, and then fade out back - * me.game.viewport.fadeIn("#fff", 150, function() { + * app.viewport.fadeIn("#fff", 150, function() { * me.audio.play("die", false); * me.level.reload(); - * me.game.viewport.fadeOut("#fff", 150); + * app.viewport.fadeOut("#fff", 150); * }); */ fadeOut( @@ -736,7 +736,7 @@ export default class Camera2d extends Renderable { * @param [onComplete] - callback once effect is over * @example * // flash the camera to white for 75ms - * me.game.viewport.fadeIn("#FFFFFF", 75); + * app.viewport.fadeIn("#FFFFFF", 75); */ fadeIn( color: Color | string, diff --git a/packages/melonjs/src/input/pointer.ts b/packages/melonjs/src/input/pointer.ts index bdabfb7b8..a32e863ff 100644 --- a/packages/melonjs/src/input/pointer.ts +++ b/packages/melonjs/src/input/pointer.ts @@ -1,8 +1,7 @@ -import { game } from "../application/application.ts"; import { Vector2d } from "../math/vector2d.ts"; import { Bounds } from "./../physics/bounds.ts"; import { globalToLocal } from "./input.ts"; -import { locked } from "./pointerevent.ts"; +import { _app, locked } from "./pointerevent.ts"; /** * a temporary vector object @@ -284,8 +283,8 @@ class Pointer extends Bounds { this.type = event.type; // get the current screen to game world offset - if (typeof game.viewport !== "undefined") { - game.viewport.localToWorld(this.gameScreenX, this.gameScreenY, tmpVec); + if (typeof _app?.viewport !== "undefined") { + _app.viewport.localToWorld(this.gameScreenX, this.gameScreenY, tmpVec); } /* Initialize the two coordinate space properties. */ diff --git a/packages/melonjs/src/input/pointerevent.ts b/packages/melonjs/src/input/pointerevent.ts index e9dbbf436..9338d6278 100644 --- a/packages/melonjs/src/input/pointerevent.ts +++ b/packages/melonjs/src/input/pointerevent.ts @@ -1,9 +1,15 @@ -import { game } from "../application/application.ts"; +import type Application from "../application/application.ts"; import { Rect } from "./../geometries/rectangle.ts"; import type { Vector2d } from "../math/vector2d.ts"; import { vector2dPool } from "../math/vector2d.ts"; import * as device from "./../system/device.js"; -import { emit, POINTERLOCKCHANGE, POINTERMOVE } from "../system/event.ts"; +import { + emit, + GAME_INIT, + on, + POINTERLOCKCHANGE, + POINTERMOVE, +} from "../system/event.ts"; import timer from "./../system/timer.ts"; import { remove } from "./../utils/array.ts"; import { throttle } from "./../utils/function.ts"; @@ -29,6 +35,15 @@ const eventHandlers: Map = new Map(); // a cache rect represeting the current pointer area let currentPointer: Rect; +/** + * reference to the active application instance + * @ignore + */ +export let _app: Application; +on(GAME_INIT, (app: Application) => { + _app = app; +}); + // some useful flags let pointerInitialized = false; @@ -123,6 +138,9 @@ function registerEventListener( */ function enablePointerEvent(): void { if (!pointerInitialized) { + if (!_app) { + throw new Error("Pointer events require an initialized Application"); + } // the current pointer area currentPointer = new Rect(0, 0, 1, 1); @@ -133,7 +151,7 @@ function enablePointerEvent(): void { if (pointerEventTarget === null || pointerEventTarget === undefined) { // default pointer event target - pointerEventTarget = game.renderer.getCanvas(); + pointerEventTarget = _app.renderer.getCanvas(); } if (device.pointerEvent) { @@ -202,7 +220,7 @@ function enablePointerEvent(): void { () => { // change the locked status accordingly locked = - globalThis.document.pointerLockElement === game.getParentElement(); + globalThis.document.pointerLockElement === _app.getParentElement(); // emit the corresponding internal event emit(POINTERLOCKCHANGE, locked); }, @@ -309,14 +327,14 @@ function dispatchEvent(normalizedEvents: Pointer[]): boolean { } // fetch valid candiates from the game world container - let candidates = game.world.broadphase.retrieve( + let candidates = _app.world.broadphase.retrieve( currentPointer, - (a: any, b: any) => game.world._sortReverseZ(a, b), + (a: any, b: any) => _app.world._sortReverseZ(a, b), undefined, ); // add the main game viewport to the list of candidates - candidates = candidates.concat([game.viewport]); + candidates = candidates.concat([_app.viewport]); for ( let c = candidates.length, candidate; @@ -594,11 +612,11 @@ export function hasRegisteredEvents(): boolean { */ export function globalToLocal(x: number, y: number, v?: Vector2d): Vector2d { v = v || vector2dPool.get(); - const rect = device.getElementBounds(game.renderer.getCanvas()); + const rect = device.getElementBounds(_app.renderer.getCanvas()); const pixelRatio = globalThis.devicePixelRatio || 1; x -= rect.left + (globalThis.pageXOffset || 0); y -= rect.top + (globalThis.pageYOffset || 0); - const scale = game.renderer.scaleRatio; + const scale = _app.renderer.scaleRatio; if (scale.x !== 1.0 || scale.y !== 1.0) { x /= scale.x; y /= scale.y; @@ -817,7 +835,7 @@ export function releaseAllPointerEvents(region: any): void { */ export function requestPointerLock(): boolean { if (device.hasPointerLockSupport) { - const element = game.getParentElement(); + const element = _app.getParentElement(); void element.requestPointerLock(); return true; } diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index c48bf57b4..270defb3a 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -150,7 +150,7 @@ export const level = { * levelContainer.currentTransform.rotate(0.05); * levelContainer.currentTransform.translate(-levelContainer.width / 2, -levelContainer.height / 2 ); * // add it to the game world - * me.game.world.addChild(levelContainer); + * app.world.addChild(levelContainer); */ load(levelId, options) { options = Object.assign( @@ -203,7 +203,7 @@ export const level = { /** * return the current level definition. * for a reference to the live instantiated level, - * rather use the container in which it was loaded (e.g. me.game.world) + * rather use the container in which it was loaded (e.g. app.world) * @name getCurrentLevel * @memberof level * @public diff --git a/packages/melonjs/src/level/tiled/TMXGroup.js b/packages/melonjs/src/level/tiled/TMXGroup.js index 9e40ae36b..0aedfa891 100644 --- a/packages/melonjs/src/level/tiled/TMXGroup.js +++ b/packages/melonjs/src/level/tiled/TMXGroup.js @@ -5,7 +5,7 @@ import { applyTMXProperties, tiledBlendMode } from "./TMXUtils.js"; /** * object group definition as defined in Tiled. - * (group definition is translated into the virtual `me.game.world` using `me.Container`) + * (group definition is translated into the virtual `app.world` using `me.Container`) * @ignore */ export default class TMXGroup { diff --git a/packages/melonjs/src/level/tiled/TMXLayer.js b/packages/melonjs/src/level/tiled/TMXLayer.js index 16212008e..7f572cb14 100644 --- a/packages/melonjs/src/level/tiled/TMXLayer.js +++ b/packages/melonjs/src/level/tiled/TMXLayer.js @@ -291,7 +291,7 @@ export default class TMXLayer extends Renderable { * @returns {Tile} corresponding tile or null if there is no defined tile at the coordinate or if outside of the layer bounds * @example * // get the TMX Map Layer called "Front layer" - * let layer = me.game.world.getChildByName("Front Layer")[0]; + * let layer = app.world.getChildByName("Front Layer")[0]; * // get the tile object corresponding to the latest pointer position * let tile = layer.getTile(me.input.pointer.x, me.input.pointer.y); */ @@ -369,7 +369,7 @@ export default class TMXLayer extends Renderable { * @param {number} x - X coordinate (in map coordinates: row/column) * @param {number} y - Y coordinate (in map coordinates: row/column) * @example - * me.game.world.getChildByType(me.TMXLayer).forEach(function(layer) { + * app.world.getChildByType(me.TMXLayer).forEach(function(layer) { * // clear all tiles at the given x,y coordinates * layer.clearTile(x, y); * }); diff --git a/packages/melonjs/src/level/tiled/TMXObject.js b/packages/melonjs/src/level/tiled/TMXObject.js index 4a97817a3..9d2dd7c94 100644 --- a/packages/melonjs/src/level/tiled/TMXObject.js +++ b/packages/melonjs/src/level/tiled/TMXObject.js @@ -36,7 +36,7 @@ function detectShape(settings) { /** * a TMX Object defintion, as defined in Tiled - * (Object definition is translated into the virtual `me.game.world` using `me.Renderable`) + * (Object definition is translated into the virtual `app.world` using `me.Renderable`) * @ignore */ export default class TMXObject { diff --git a/packages/melonjs/src/level/tiled/TMXTileMap.js b/packages/melonjs/src/level/tiled/TMXTileMap.js index 55a59e342..615cf021c 100644 --- a/packages/melonjs/src/level/tiled/TMXTileMap.js +++ b/packages/melonjs/src/level/tiled/TMXTileMap.js @@ -1,4 +1,3 @@ -import { game } from "../../application/application.ts"; import { warning } from "../../lang/console.js"; import { vector2dPool } from "../../math/vector2d.ts"; import { collision } from "../../physics/collision.js"; @@ -129,7 +128,7 @@ export default class TMXTileMap { * // create a new level object based on the TMX JSON object * let level = new me.TMXTileMap(levelId, me.loader.getTMX(levelId)); * // add the level to the game world container - * level.addTo(me.game.world, true); + * level.addTo(app.world, true); */ constructor(levelId, data) { /** @@ -378,7 +377,7 @@ export default class TMXTileMap { * // create a new level object based on the TMX JSON object * let level = new me.TMXTileMap(levelId, me.loader.getTMX(levelId)); * // add the level to the game world container - * level.addTo(me.game.world, true, true); + * level.addTo(app.world, true, true); */ addTo(container, flatten, setViewportBounds) { const _sort = container.autoSort; @@ -414,27 +413,29 @@ export default class TMXTileMap { * callback funtion for the viewport resize event * @ignore */ - function _setBounds(width, height) { - // adjust the viewport bounds if level is smaller - game.viewport.setBounds( - 0, - 0, - Math.max(levelBounds.width, width), - Math.max(levelBounds.height, height), - ); - // center the map if smaller than the current viewport - container.pos.set( - Math.max(0, ~~((width - levelBounds.width) / 2)), - Math.max(0, ~~((height - levelBounds.height) / 2)), - // don't change the container z position if defined - container.pos.z, - ); - } - if (setViewportBounds === true) { + const app = container.getRootAncestor().app; + + function _setBounds(width, height) { + // adjust the viewport bounds if level is smaller + app.viewport.setBounds( + 0, + 0, + Math.max(levelBounds.width, width), + Math.max(levelBounds.height, height), + ); + // center the map if smaller than the current viewport + container.pos.set( + Math.max(0, ~~((width - levelBounds.width) / 2)), + Math.max(0, ~~((height - levelBounds.height) / 2)), + // don't change the container z position if defined + container.pos.z, + ); + } + off(VIEWPORT_ONRESIZE, _setBounds); // force viewport bounds update - _setBounds(game.viewport.width, game.viewport.height); + _setBounds(app.viewport.width, app.viewport.height); // Replace the resize handler on(VIEWPORT_ONRESIZE, _setBounds); } diff --git a/packages/melonjs/src/particles/emitter.ts b/packages/melonjs/src/particles/emitter.ts index 47194a503..44b0a33e7 100644 --- a/packages/melonjs/src/particles/emitter.ts +++ b/packages/melonjs/src/particles/emitter.ts @@ -70,7 +70,7 @@ export default class ParticleEmitter extends Container { * }); * * // Add the emitter to the game world - * me.game.world.addChild(emitter); + * app.world.addChild(emitter); * * // Launch all particles one time and stop, like an explosion * emitter.burstParticles(); @@ -80,7 +80,7 @@ export default class ParticleEmitter extends Container { * * // At the end, remove emitter from the game world * // call this in onDestroyEvent function - * me.game.world.removeChild(emitter); + * app.world.removeChild(emitter); */ constructor(x: number, y: number, settings: Record = {}) { // call the super constructor diff --git a/packages/melonjs/src/physics/collision.js b/packages/melonjs/src/physics/collision.js index 4ca520217..9d61ec786 100644 --- a/packages/melonjs/src/physics/collision.js +++ b/packages/melonjs/src/physics/collision.js @@ -111,7 +111,7 @@ export const collision = { * // starting point relative to the initial position * new me.Vector2d(0, 0), * // ending point - * new me.Vector2d(me.game.viewport.width, me.game.viewport.height) + * new me.Vector2d(app.viewport.width, app.viewport.height) * ]); * * // check for collition diff --git a/packages/melonjs/src/physics/detector.js b/packages/melonjs/src/physics/detector.js index 6dddb1b0f..a51b265f7 100644 --- a/packages/melonjs/src/physics/detector.js +++ b/packages/melonjs/src/physics/detector.js @@ -258,7 +258,7 @@ class Detector { * // starting point relative to the initial position * new Vector2d(0, 0), * // ending point - * new Vector2d(me.game.viewport.width, me.game.viewport.height) + * new Vector2d(app.viewport.width, app.viewport.height) * ]); * * // check for collition diff --git a/packages/melonjs/src/physics/world.js b/packages/melonjs/src/physics/world.js index d0ee0cd9f..cbd6c022c 100644 --- a/packages/melonjs/src/physics/world.js +++ b/packages/melonjs/src/physics/world.js @@ -26,8 +26,8 @@ export default class World extends Container { /** * @param {number} [x=0] - position of the container (accessible via the inherited pos.x property) * @param {number} [y=0] - position of the container (accessible via the inherited pos.y property) - * @param {number} [width=game.viewport.width] - width of the container - * @param {number} [height=game.viewport.height] - height of the container + * @param {number} [width=Infinity] - width of the world container + * @param {number} [height=Infinity] - height of the world container */ constructor(x = 0, y = 0, width = Infinity, height = Infinity) { // call the super constructor @@ -52,7 +52,7 @@ export default class World extends Container { * @default "builtin" * @example * // disable builtin physic - * me.game.world.physic = "none"; + * app.world.physic = "none"; */ this.physic = "builtin"; diff --git a/packages/melonjs/src/plugin/plugin.ts b/packages/melonjs/src/plugin/plugin.ts index 1a08f41fd..159cf8301 100644 --- a/packages/melonjs/src/plugin/plugin.ts +++ b/packages/melonjs/src/plugin/plugin.ts @@ -40,11 +40,11 @@ export class BasePlugin { * @param name - target function * @param fn - replacement function * @example - * // redefine the me.game.update function with a new one - * me.plugin.patch(me.game, "update", function () { + * // redefine the app.update function with a new one + * me.plugin.patch(app, "update", function () { * // display something in the console * console.log("duh"); - * // call the original me.game.update function + * // call the original app.update function * this._patched(); * }); */ diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index c13fbfbb6..7db5d191e 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -1,4 +1,3 @@ -import { game } from "../application/application.ts"; import { colorPool } from "../math/color.ts"; import Body from "../physics/body.js"; import state from "../state/state.ts"; @@ -42,32 +41,27 @@ let globalFloatingCounter = 0; */ /** - * Container represents a collection of child objects + * Container represents a collection of child objects. + * When no explicit dimensions are given, width and height default to Infinity, + * meaning the container has no intrinsic size, no clipping, and acts as a pure + * grouping/transform node (similar to PixiJS or Phaser containers). + * In this case, anchorPoint is treated as (0, 0) since there is no meaningful + * center for an infinite area. Bounds are then derived entirely from children + * when {@link Container#enableChildBoundsUpdate} is enabled. * @category Container */ export default class Container extends Renderable { /** * @param {number} [x=0] - position of the container (accessible via the inherited pos.x property) * @param {number} [y=0] - position of the container (accessible via the inherited pos.y property) - * @param {number} [width=game.viewport.width] - width of the container - * @param {number} [height=game.viewport.height] - height of the container + * @param {number} [width=Infinity] - width of the container. Defaults to Infinity (no intrinsic size, no clipping). + * @param {number} [height=Infinity] - height of the container. Defaults to Infinity (no intrinsic size, no clipping). + * @param {boolean} [root=false] - internal flag, true for the world root container + * @ignore root */ - constructor(x = 0, y = 0, width, height, root = false) { + constructor(x = 0, y = 0, width = Infinity, height = Infinity, root = false) { // call the super constructor - super( - x, - y, - typeof width === "undefined" - ? typeof game.viewport !== "undefined" - ? game.viewport.width - : Infinity - : width, - typeof height === "undefined" - ? typeof game.viewport !== "undefined" - ? game.viewport.height - : Infinity - : height, - ); + super(x, y, width, height); /** * keep track of pending sort @@ -156,6 +150,10 @@ export default class Container extends Renderable { // enable collision and event detection this.isKinematic = false; + // container anchorPoint is always (0, 0) — children position from the + // container's origin (top-left), matching the convention used by other engines + // (PixiJS, Phaser). This also avoids Infinity * 0.5 = Infinity issues + // when the container has no explicit size. this.anchorPoint.set(0, 0); // subscribe on the canvas resize event @@ -569,8 +567,13 @@ export default class Container extends Renderable { updateBounds(absolute = true) { const bounds = this.getBounds(); - // call parent method - super.updateBounds(absolute); + if (this.isFinite()) { + // call parent method only when container has finite dimensions + super.updateBounds(absolute); + } else if (this.enableChildBoundsUpdate === true) { + // clear bounds so child aggregation starts fresh + bounds.clear(); + } if (this.enableChildBoundsUpdate === true) { this.forEach((child) => { diff --git a/packages/melonjs/src/renderable/imagelayer.js b/packages/melonjs/src/renderable/imagelayer.js index 603fe762f..78cccb12a 100644 --- a/packages/melonjs/src/renderable/imagelayer.js +++ b/packages/melonjs/src/renderable/imagelayer.js @@ -1,4 +1,3 @@ -import { game } from "../application/application.ts"; import { vector2dPool } from "../math/vector2d.ts"; import { LEVEL_LOADED, @@ -33,7 +32,7 @@ export default class ImageLayer extends Sprite { * @param {number|Vector2d} [settings.anchorPoint=<0.0,0.0>] - Define how the image is anchored to the viewport bound. By default, its upper-left corner is anchored to the viewport bounds upper left corner. * @example * // create a repetitive background pattern on the X axis using the citycloud image asset - * me.game.world.addChild(new me.ImageLayer(0, 0, { + * app.world.addChild(new me.ImageLayer(0, 0, { * image:"citycloud", * repeat :"repeat-x" * }), 1); @@ -83,9 +82,6 @@ export default class ImageLayer extends Sprite { } this.repeat = settings.repeat || "repeat"; - - // on context lost, all previous textures are destroyed - on(ONCONTEXT_RESTORED, this.createPattern, this); } /** @@ -123,15 +119,23 @@ export default class ImageLayer extends Sprite { this.repeatY = true; break; } - this.resize(game.viewport.width, game.viewport.height); - this.createPattern(); } // called when the layer is added to the game world or a container onActivateEvent() { - // register to the viewport change notification + if (!this.parentApp) { + throw new Error( + "ImageLayer requires a parent Application (must be added to an app's world container)", + ); + } + const viewport = this.parentApp.viewport; + // set the initial size to match the viewport + this.resize(viewport.width, viewport.height); + this.createPattern(); + // register to the viewport change notification and context restore on(VIEWPORT_ONCHANGE, this.updateLayer, this); on(VIEWPORT_ONRESIZE, this.resize, this); + on(ONCONTEXT_RESTORED, this.createPattern, this); // force a first refresh when the level is loaded on(LEVEL_LOADED, this.updateLayer, this); // in case the level is not added to the root container, @@ -159,10 +163,10 @@ export default class ImageLayer extends Sprite { * @ignore */ createPattern() { - const renderer = this.parentApp?.renderer ?? game.renderer; - if (renderer) { - this._pattern = renderer.createPattern(this.image, this._repeat); - } + this._pattern = this.parentApp.renderer.createPattern( + this.image, + this._repeat, + ); } /** @@ -173,7 +177,7 @@ export default class ImageLayer extends Sprite { const rx = this.ratio.x; const ry = this.ratio.y; - const viewport = game.viewport; + const viewport = this.parentApp.viewport; if (rx === 0 && ry === 0) { // static image @@ -263,7 +267,7 @@ export default class ImageLayer extends Sprite { x = this.pos.x + ax * (bw - width); y = this.pos.y + ay * (bh - height); } else { - // parallax — compute position from the current viewport, not game.viewport + // parallax — compute position from the current viewport passed to draw() x = ax * (rx - 1) * (bw - viewport.width) + this.offset.x - @@ -300,6 +304,7 @@ export default class ImageLayer extends Sprite { off(VIEWPORT_ONCHANGE, this.updateLayer, this); off(VIEWPORT_ONRESIZE, this.resize, this); off(LEVEL_LOADED, this.updateLayer, this); + off(ONCONTEXT_RESTORED, this.createPattern, this); } /** @@ -309,7 +314,6 @@ export default class ImageLayer extends Sprite { destroy() { vector2dPool.release(this.ratio); this.ratio = undefined; - off(ONCONTEXT_RESTORED, this.createPattern, this); super.destroy(); } } diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index 58bbbb5a8..6368bbeb0 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -104,7 +104,7 @@ export default class Renderable extends Rect { * this.isKinematic = false; * * // set the display to follow our position on both axis - * me.game.viewport.follow(this.pos, me.game.viewport.AXIS.BOTH); + * app.viewport.follow(this.pos, app.viewport.AXIS.BOTH); * } * * ... @@ -116,7 +116,7 @@ export default class Renderable extends Rect { /** * (G)ame (U)nique (Id)entifier"
* a GUID will be allocated for any renderable object added
- * to an object container (including the `me.game.world` container) + * to an object container (including the `app.world` container) * @type {string} */ this.GUID = undefined; diff --git a/packages/melonjs/src/renderable/text/bitmaptext.js b/packages/melonjs/src/renderable/text/bitmaptext.js index f8bbb43a0..f22e2cccf 100644 --- a/packages/melonjs/src/renderable/text/bitmaptext.js +++ b/packages/melonjs/src/renderable/text/bitmaptext.js @@ -37,7 +37,7 @@ export default class BitmapText extends Renderable { * // either call the draw function from your Renderable draw function * myFont.draw(renderer, "Hello!", 0, 0); * // or just add it to the world container - * me.game.world.addChild(myFont); + * app.world.addChild(myFont); */ constructor(x, y, settings) { // call the parent constructor diff --git a/packages/melonjs/src/renderable/text/bitmaptextdata.ts b/packages/melonjs/src/renderable/text/bitmaptextdata.ts index 789ef3282..026c7f180 100644 --- a/packages/melonjs/src/renderable/text/bitmaptextdata.ts +++ b/packages/melonjs/src/renderable/text/bitmaptextdata.ts @@ -1,4 +1,4 @@ -import { createPool } from "../../pool.ts"; +import { createPool } from "../../system/pool.ts"; import Glyph from "./glyph.ts"; // bitmap constants diff --git a/packages/melonjs/src/state/stage.js b/packages/melonjs/src/state/stage.js index 59d6a675c..d963e9610 100644 --- a/packages/melonjs/src/state/stage.js +++ b/packages/melonjs/src/state/stage.js @@ -53,7 +53,7 @@ export default class Stage { * // set a dark ambient light * this.ambientLight.parseCSS("#1117"); * // make the light follow the mouse - * me.input.registerPointerEvent("pointermove", me.game.viewport, (event) => { + * me.input.registerPointerEvent("pointermove", app.viewport, (event) => { * whiteLight.centerOn(event.gameX, event.gameY); * }); */ @@ -181,7 +181,7 @@ export default class Stage { * destroy function * @ignore */ - destroy() { + destroy(app) { // clear all cameras this.cameras.clear(); // clear all lights @@ -190,7 +190,7 @@ export default class Stage { }); this.lights.clear(); // notify the object - this.onDestroyEvent.apply(this, arguments); + this.onDestroyEvent(app); } /** @@ -215,11 +215,12 @@ export default class Stage { * called by the state manager before switching to another state * @name onDestroyEvent * @memberof Stage + * @param {Application} [app] - the current application instance */ - onDestroyEvent() { + onDestroyEvent(app) { // execute onDestroyEvent function if given through the constructor if (typeof this.settings.onDestroyEvent === "function") { - this.settings.onDestroyEvent.apply(this, arguments); + this.settings.onDestroyEvent.call(this, app); } } } diff --git a/packages/melonjs/src/state/state.ts b/packages/melonjs/src/state/state.ts index 64203927a..7a85ec3d3 100644 --- a/packages/melonjs/src/state/state.ts +++ b/packages/melonjs/src/state/state.ts @@ -120,7 +120,7 @@ function _switchState(stateId: number): void { // call the stage destroy method if (_stages[_state]) { // just notify the object - _stages[_state].stage.destroy(); + _stages[_state].stage.destroy(_app); } if (_stages[stateId]) { @@ -352,7 +352,7 @@ const state = { * class MenuScreen extends me.Stage { * onResetEvent() { * // Load background image - * me.game.world.addChild( + * app.world.addChild( * new me.ImageLayer(0, 0, { * image : "bg", * z: 0 // z-index @@ -360,7 +360,7 @@ const state = { * ); * * // Add a button - * me.game.world.addChild( + * app.world.addChild( * new MenuButton(350, 200, { "image" : "start" }), * 1 // z-index * ); diff --git a/packages/melonjs/src/system/bootstrap.ts b/packages/melonjs/src/system/bootstrap.ts index 41ca21c56..2401a30bc 100644 --- a/packages/melonjs/src/system/bootstrap.ts +++ b/packages/melonjs/src/system/bootstrap.ts @@ -27,12 +27,10 @@ export let initialized = false; /** * Initialize the melonJS library. - * This is called automatically in two cases: - * - On DOMContentLoaded, unless {@link skipAutoInit} is set to true - * - By {@link Application.init} when creating a new game instance - * + * This is called automatically by the {@link Application} constructor. * Multiple calls are safe — boot() is idempotent. - * @see {@link skipAutoInit} + * When using {@link Application} directly, calling boot() manually is not needed. + * @see {@link Application} */ export function boot() { // don't do anything if already initialized (should not happen anyway) diff --git a/packages/melonjs/src/system/device.js b/packages/melonjs/src/system/device.js index 8b2951249..e8a90dcdd 100644 --- a/packages/melonjs/src/system/device.js +++ b/packages/melonjs/src/system/device.js @@ -388,6 +388,7 @@ export let autoFocus = true; * me.device.onReady(function () { * game.onload(); * }); + * @deprecated since 18.3.0 — no longer needed when using {@link Application} as entry point. * @category Application */ export function onReady(fn) { @@ -771,10 +772,10 @@ export function focus() { * @returns {boolean} 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", me.game.viewport, function() { + * me.input.registerPointerEvent("pointerleave", app.viewport, function() { * if (me.device.watchAccelerometer() === true) { * // Success - * me.input.releasePointerEvent("pointerleave", me.game.viewport); + * me.input.releasePointerEvent("pointerleave", app.viewport); * } else { * // ... fail at enabling the device accelerometer event * } @@ -828,10 +829,10 @@ export function unwatchAccelerometer() { * @returns {boolean} 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", me.game.viewport, function() { + * me.input.registerPointerEvent("pointerleave", app.viewport, function() { * if (me.device.watchDeviceOrientation() === true) { * // Success - * me.input.releasePointerEvent("pointerleave", me.game.viewport); + * me.input.releasePointerEvent("pointerleave", app.viewport); * } else { * // ... fail at enabling the device orientation event * } diff --git a/packages/melonjs/src/system/legacy_pool.js b/packages/melonjs/src/system/legacy_pool.js index b69947a42..36658ce8c 100644 --- a/packages/melonjs/src/system/legacy_pool.js +++ b/packages/melonjs/src/system/legacy_pool.js @@ -119,8 +119,8 @@ class ObjectPool { * // ... * // when we want to destroy existing object, the remove * // function will ensure the object can then be reallocated later - * me.game.world.removeChild(enemy); - * me.game.world.removeChild(bullet); + * app.world.removeChild(enemy); + * app.world.removeChild(bullet); */ pull(name, ...args) { const className = this.objectClass[name]; diff --git a/packages/melonjs/src/video/video.js b/packages/melonjs/src/video/video.js index 1fc55fd80..b9ca5c0b7 100644 --- a/packages/melonjs/src/video/video.js +++ b/packages/melonjs/src/video/video.js @@ -2,13 +2,7 @@ import { game } from "../application/application.ts"; import { defaultApplicationSettings } from "../application/defaultApplicationSettings.ts"; import { initialized } from "../system/bootstrap.ts"; import * as device from "./../system/device.js"; -import { - emit, - VIDEO_INIT, - WINDOW_ONORIENTATION_CHANGE, - WINDOW_ONRESIZE, - WINDOW_ONSCROLL, -} from "../system/event.ts"; +import { on, VIDEO_INIT } from "../system/event.ts"; /** * @namespace video @@ -27,13 +21,19 @@ export { AUTO, CANVAS, WEBGL } from "../const"; /** * A reference to the active Canvas or WebGL renderer. - * Only available after calling {@link video.init}. - * When using {@link Application} directly, use `app.renderer` instead. * @memberof video * @type {CanvasRenderer|WebGLRenderer} + * @deprecated since 18.3.0 — use {@link Application#renderer app.renderer} instead. + * @see Application#renderer */ export let renderer = null; +// backward compatibility: keep video.renderer in sync +// when Application is used directly instead of video.init() +on(VIDEO_INIT, (r) => { + renderer = r; +}); + /** * Initialize the "video" system (create a canvas based on the given arguments, and the related renderer).
* @memberof video @@ -41,8 +41,18 @@ export let renderer = null; * @param {number} height - The height of the canvas viewport * @param {ApplicationSettings} [options] - optional parameters for the renderer * @returns {boolean} false if initialization failed (canvas not supported) + * @deprecated since 18.3.0 — use {@link Application} constructor instead: + * `const app = new Application(width, height, options)` + * @see Application * @example - * // init the video with a 640x480 canvas + * // using the new Application entry point (recommended) + * const app = new Application(640, 480, { + * parent : "screen", + * scale : "auto", + * scaleMethod : "fit" + * }); + * + * // legacy usage (still supported) * me.video.init(640, 480, { * parent : "screen", * renderer : me.video.AUTO, @@ -68,53 +78,7 @@ export function init(width, height, options) { return false; } - // set the public renderer reference - renderer = game.renderer; - - //add a channel for the onresize/onorientationchange event - globalThis.addEventListener( - "resize", - (e) => { - emit(WINDOW_ONRESIZE, e); - }, - false, - ); - - // Screen Orientation API - globalThis.addEventListener( - "orientationchange", - (e) => { - emit(WINDOW_ONORIENTATION_CHANGE, e); - }, - false, - ); - - // pre-fixed implementation on mozzila - globalThis.addEventListener( - "onmozorientationchange", - (e) => { - emit(WINDOW_ONORIENTATION_CHANGE, e); - }, - false, - ); - - if (device.screenOrientation === true) { - globalThis.screen.orientation.onchange = (e) => { - emit(WINDOW_ONORIENTATION_CHANGE, e); - }; - } - - // Automatically update relative canvas position on scroll - globalThis.addEventListener( - "scroll", - (e) => { - emit(WINDOW_ONSCROLL, e); - }, - false, - ); - - // notify the video has been initialized - emit(VIDEO_INIT, game.renderer); + // DOM event listeners and VIDEO_INIT are now handled by Application.init() return true; } @@ -157,6 +121,8 @@ export function createCanvas(width, height, returnOffscreenCanvas = false) { * return a reference to the parent DOM element holding the main canvas * @memberof video * @returns {HTMLElement} the HTML parent element + * @deprecated since 18.3.0 — use {@link Application#getParentElement app.getParentElement()} instead. + * @see Application#getParentElement */ export function getParent() { return game.getParentElement(); diff --git a/packages/melonjs/tests/container.spec.js b/packages/melonjs/tests/container.spec.js index 8231b3865..a8059c267 100644 --- a/packages/melonjs/tests/container.spec.js +++ b/packages/melonjs/tests/container.spec.js @@ -970,4 +970,183 @@ describe("Container", () => { expect(child.visibleInAllCameras).toEqual(true); }); }); + + describe("default dimensions", () => { + it("should default to Infinity when no dimensions are provided", () => { + const c = new Container(); + expect(c.width).toEqual(Infinity); + expect(c.height).toEqual(Infinity); + }); + + it("should default to (0, 0) position when no position is provided", () => { + const c = new Container(); + expect(c.pos.x).toEqual(0); + expect(c.pos.y).toEqual(0); + }); + + it("should use explicit dimensions when provided", () => { + const c = new Container(10, 20, 200, 150); + expect(c.pos.x).toEqual(10); + expect(c.pos.y).toEqual(20); + expect(c.width).toEqual(200); + expect(c.height).toEqual(150); + }); + + it("should accept children when dimensions are Infinity", () => { + const c = new Container(); + const child = new Renderable(50, 50, 32, 32); + c.addChild(child); + expect(c.getChildren().length).toEqual(1); + }); + + it("should not clip children when dimensions are Infinity", () => { + const c = new Container(); + c.clipping = true; + const farChild = new Renderable(99999, 99999, 10, 10); + c.addChild(farChild); + // draw() guards clipRect with bounds.isFinite(), so even with + // clipping enabled, Infinity bounds prevent actual clipping + const bounds = c.getBounds(); + expect(bounds.isFinite()).toEqual(false); + // child bounds should still be valid and reachable + const childBounds = farChild.getBounds(); + expect(childBounds.isFinite()).toEqual(true); + expect(childBounds.left).toBeGreaterThan(0); + }); + + it("getBounds() should return valid bounds with Infinity dimensions", () => { + const c = new Container(); + const bounds = c.getBounds(); + expect(bounds).toBeDefined(); + expect(typeof bounds.width).toEqual("number"); + expect(typeof bounds.height).toEqual("number"); + }); + + it("getBounds() should grow to fit children with explicit dimensions", () => { + const c = new Container(0, 0, 100, 100); + c.enableChildBoundsUpdate = true; + const child = new Renderable(10, 20, 50, 60); + c.addChild(child); + c.updateBounds(); + const bounds = c.getBounds(); + expect(bounds.width).toBeGreaterThanOrEqual(50); + expect(bounds.height).toBeGreaterThanOrEqual(60); + }); + + it("getChildByName should work with Infinity-sized container", () => { + const c = new Container(); + const child = new Renderable(0, 0, 32, 32); + child.name = "testChild"; + c.addChild(child); + const found = c.getChildByName("testChild"); + expect(found.length).toEqual(1); + expect(found[0]).toBe(child); + }); + + it("sort should work with Infinity-sized container", () => { + const c = new Container(); + c.autoSort = true; + const child1 = new Renderable(0, 0, 10, 10); + child1.pos.z = 5; + const child2 = new Renderable(0, 0, 10, 10); + child2.pos.z = 1; + c.addChild(child1); + c.addChild(child2); + c.sort(); + expect(c.getChildAt(0).pos.z).toBeLessThanOrEqual(c.getChildAt(1).pos.z); + }); + + it("isFinite() should return false for Infinity-sized container", () => { + const c = new Container(); + expect(c.isFinite()).toEqual(false); + }); + + it("isFinite() should return true for explicitly-sized container", () => { + const c = new Container(0, 0, 100, 100); + expect(c.isFinite()).toEqual(true); + }); + + it("getBounds().isFinite() should return false for Infinity-sized container", () => { + const c = new Container(); + const bounds = c.getBounds(); + expect(bounds.isFinite()).toEqual(false); + }); + + it("getBounds().isFinite() should return true for explicitly-sized container", () => { + const c = new Container(0, 0, 200, 150); + const bounds = c.getBounds(); + expect(bounds.isFinite()).toEqual(true); + }); + + it("updateBounds should not produce NaN for Infinity-sized container", () => { + const c = new Container(); + c.enableChildBoundsUpdate = true; + const child = new Renderable(10, 10, 50, 50); + c.addChild(child); + const bounds = c.updateBounds(); + expect(Number.isNaN(bounds.width)).toEqual(false); + expect(Number.isNaN(bounds.height)).toEqual(false); + }); + + it("updateBounds should return finite child bounds for Infinity container", () => { + const c = new Container(); + c.enableChildBoundsUpdate = true; + const child = new Renderable(10, 10, 50, 50); + c.addChild(child); + const bounds = c.updateBounds(); + expect(bounds.isFinite()).toEqual(true); + expect(bounds.width).toBeGreaterThanOrEqual(50); + expect(bounds.height).toBeGreaterThanOrEqual(50); + }); + + it("clipping should be skipped for Infinity-sized container", () => { + const c = new Container(); + // clipping requires finite bounds — verify the guard condition + expect(c.clipping).toEqual(false); + c.clipping = true; + const bounds = c.getBounds(); + // draw() checks bounds.isFinite() before clipping + expect(bounds.isFinite()).toEqual(false); + }); + + it("clipping preconditions should be met for finite container", () => { + const c = new Container(0, 0, 200, 150); + c.clipping = true; + const bounds = c.getBounds(); + // all three guard conditions in draw() should pass + expect(c.root).toEqual(false); + expect(c.clipping).toEqual(true); + expect(bounds.isFinite()).toEqual(true); + expect(bounds.width).toEqual(200); + expect(bounds.height).toEqual(150); + }); + + it("clipping defaults to false", () => { + expect(new Container().clipping).toEqual(false); + expect(new Container(0, 0, 100, 100).clipping).toEqual(false); + }); + + it("nested Infinity containers should not produce NaN bounds", () => { + const parent = new Container(); + const child = new Container(); + child.enableChildBoundsUpdate = true; + const renderable = new Renderable(5, 5, 20, 20); + child.addChild(renderable); + parent.addChild(child); + parent.enableChildBoundsUpdate = true; + const bounds = parent.updateBounds(); + expect(Number.isNaN(bounds.width)).toEqual(false); + expect(Number.isNaN(bounds.height)).toEqual(false); + }); + + it("anchorPoint should always be (0,0) for containers", () => { + const infinite = new Container(); + expect(infinite.anchorPoint.x).toEqual(0); + expect(infinite.anchorPoint.y).toEqual(0); + + const finite = new Container(0, 0, 200, 100); + expect(finite.anchorPoint.x).toEqual(0); + expect(finite.anchorPoint.y).toEqual(0); + }); + }); }); diff --git a/packages/melonjs/tests/eventEmitter.spec.ts b/packages/melonjs/tests/eventEmitter.spec.ts index 06dc9ceef..cda159745 100644 --- a/packages/melonjs/tests/eventEmitter.spec.ts +++ b/packages/melonjs/tests/eventEmitter.spec.ts @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it, test, vi } from "vitest"; -import { boot, event, video } from "../src/index.js"; +import { Application, event } from "../src/index.js"; import { EventEmitter } from "../src/system/eventEmitter"; test("addListener()", () => { @@ -302,11 +302,9 @@ test("listener without context has undefined this", () => { // --------------------------------------------------------------- describe("event.ts public API", () => { beforeAll(() => { - boot(); - video.init(64, 64, { + new Application(64, 64, { parent: "screen", scale: "auto", - renderer: video.AUTO, }); }); diff --git a/packages/melonjs/tests/font.spec.js b/packages/melonjs/tests/font.spec.js index 06eaa00ad..cb6f09043 100644 --- a/packages/melonjs/tests/font.spec.js +++ b/packages/melonjs/tests/font.spec.js @@ -17,7 +17,6 @@ describe("Font : Text", () => { size: 8, fillStyle: "white", text: "test", - offScreenCanvas: false, }); }); diff --git a/packages/melonjs/tests/imagelayer.spec.js b/packages/melonjs/tests/imagelayer.spec.js new file mode 100644 index 000000000..ef3af9225 --- /dev/null +++ b/packages/melonjs/tests/imagelayer.spec.js @@ -0,0 +1,83 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { boot, game, ImageLayer, video } from "../src/index.js"; + +describe("ImageLayer", () => { + let testImage; + + beforeAll(async () => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.AUTO, + }); + + // create a small canvas to use as the image source + testImage = document.createElement("canvas"); + testImage.width = 64; + testImage.height = 64; + }); + + describe("createPattern", () => { + it("should create pattern when added to game world", () => { + const layer = new ImageLayer(0, 0, { + image: testImage, + name: "test", + repeat: "repeat", + }); + game.world.addChild(layer); + expect(layer._pattern).toBeDefined(); + game.world.removeChildNow(layer); + }); + }); + + describe("parentApp usage", () => { + it("should access viewport via parentApp when in world", () => { + const layer = new ImageLayer(0, 0, { + image: testImage, + name: "test", + repeat: "no-repeat", + }); + game.world.addChild(layer); + expect(layer.parentApp).toBeDefined(); + expect(layer.parentApp.viewport).toBe(game.viewport); + game.world.removeChildNow(layer); + }); + + it("should resize to viewport dimensions on activate", () => { + const layer = new ImageLayer(0, 0, { + image: testImage, + name: "test", + repeat: "no-repeat", + }); + game.world.addChild(layer); + expect(layer.width).toEqual(game.viewport.width); + expect(layer.height).toEqual(game.viewport.height); + game.world.removeChildNow(layer); + }); + + it("repeat-x should set width to Infinity on activate", () => { + const layer = new ImageLayer(0, 0, { + image: testImage, + name: "test", + repeat: "repeat-x", + }); + game.world.addChild(layer); + expect(layer.width).toEqual(Infinity); + expect(layer.height).toEqual(game.viewport.height); + game.world.removeChildNow(layer); + }); + + it("repeat-y should set height to Infinity on activate", () => { + const layer = new ImageLayer(0, 0, { + image: testImage, + name: "test", + repeat: "repeat-y", + }); + game.world.addChild(layer); + expect(layer.width).toEqual(game.viewport.width); + expect(layer.height).toEqual(Infinity); + game.world.removeChildNow(layer); + }); + }); +});