From bce9d665915ac94da87003da47ae3f01f7c107c4 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 9 Oct 2026 16:38:13 +0800 Subject: [PATCH 1/2] Pool: retire the legacy name-keyed object pool from engine internals (#1688) The engine no longer uses `me.pool` for anything. Zero internal `pull` and zero internal `push`: the only two mentions left in `src/` are comments, and `legacy_pool` is imported by `index.ts` for the export and by `TMXObjectFactory.js` for the register bridge. All four of the issue's items: 1. `push(obj, throwOnError = false)`. It threw by default for any class that was never registered, and nothing at the call site made that visible, so the exception aborted whatever was running. Flipped rather than split, because the class is deprecated now and a second method would teach a new spelling on a retiring surface. 2. `atlas.js` and `TMXTileMap.js` construct their classes directly. That needed the module cycle broken first: `sprite.js` imported `loader.js` and `atlas.js`, and `class NineSliceSprite extends Sprite` reads `Sprite` at module-evaluation time, so importing either from inside the cycle threw. The ten cache read accessors moved to `loader/cache.js`, and the two `instanceof TextureAtlas` tests became `instanceof Texture2d && isAtlas`. 3. The Tiled name registry is split from the recycling pool. Built-in classes are DEFAULTS: they fill a name nothing else claimed, and a game's own class takes the name over whenever it registers. They keep their `me.`-prefixed aliases, which used to arrive only as a side effect of `pool.register`. 4. `me.pool` is deprecated, with a one-off notice per method naming both replacements. `Container` now returns a child to the typed pool that BUILT it, which is the question a generic caller has to ask: `createPool` stamps the owning pool on everything it builds. `Text` and `ColorLayer` gained typed pools, and with them two defects a recycled instance had: the settings-conditional fields leaked across a reuse, and every reset built a new canvas and metrics while stranding the previous canvas and its GPU texture. Also in here because the review found them: - `RenderTarget#toImageData()`, so `toBlob`/`toDataURL`/`toImageBitmap` stop throwing on WebGPU. Synchronous readback is not portable. - `scripts/check-declarations.ts`, gated in `pnpm types`. Nothing compiled against `build/*.d.ts`, so a whole class of defect was invisible: five of this change's own bugs were only found by looking there. - `registerTiledObjectClass` before the first map load was silently discarded. - 12 public members reached TypeScript as `any`, and `Body#collisionMask` was stripped from the published types while `PhysicsBody` required it. - The `trigger_level_change` flake: `_onTick` takes an absolute `performance.now()` stamp and ignores a delta outside `(0, 1000)`, so a hardcoded `1000` was a valid tick only while the page was under a second old. Examples move off the legacy pool too, and the old space-invaders example is removed rather than converted. Closes #1688 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../examples/afterBurner/GameController.ts | 84 +-- .../src/examples/isometricRpg/createGame.ts | 12 +- .../examples/platformer-matter/createGame.ts | 14 +- .../src/examples/platformer/createGame.ts | 14 +- .../spaceInvaders/ExampleSpaceInvaders.tsx | 297 --------- .../spaceInvaders/assets/bgm/.placeholder | 2 - .../spaceInvaders/assets/fnt/LICENSE.txt | 94 --- .../spaceInvaders/assets/img/.placeholder | 2 - .../spaceInvaders/assets/img/player.png | Bin 215 -> 0 bytes .../spaceInvaders/assets/img/ships.png | Bin 3058 -> 0 bytes .../spaceInvaders/assets/map/.placeholder | 2 - .../spaceInvaders/assets/sfx/.placeholder | 2 - .../src/examples/waterOverworld/entities.ts | 17 +- .../src/examples/waterOverworld/play.ts | 5 +- .../src/examples/whac-a-mole/effects.ts | 4 +- .../examples/src/examples/whac-a-mole/mole.ts | 6 +- packages/examples/src/main.tsx | 13 - packages/melonjs/CHANGELOG.md | 20 + packages/melonjs/package.json | 2 +- .../melonjs/scripts/check-declarations.ts | 203 ++++++ .../melonjs-camera-and-drawing/SKILL.md | 33 +- .../melonjs/skills/melonjs-events/SKILL.md | 6 +- .../skills/melonjs-performance/SKILL.md | 61 +- .../skills/melonjs-scenes-and-state/SKILL.md | 6 +- .../melonjs-sprites-and-animation/SKILL.md | 11 +- .../melonjs/skills/melonjs-tilemaps/SKILL.md | 48 +- .../melonjs/src/camera/effects/fade_effect.ts | 5 +- .../melonjs/src/camera/effects/mask_effect.ts | 4 +- packages/melonjs/src/index.ts | 7 +- packages/melonjs/src/level/gltf/GLTFModel.js | 3 +- .../src/level/tiled/TMXObjectFactory.js | 110 +++- .../melonjs/src/level/tiled/TMXTileMap.js | 7 +- .../tiled/renderer/TMXObliqueRenderer.js | 1 + packages/melonjs/src/loader/cache.js | 402 ++++++++++++ packages/melonjs/src/loader/loader.js | 600 +++--------------- packages/melonjs/src/loader/parsers/gltf.js | 36 ++ packages/melonjs/src/particles/emitter.ts | 32 +- .../melonjs/src/physics/broadphase/octree.ts | 16 +- .../src/physics/broadphase/quadtree.ts | 14 +- packages/melonjs/src/physics/builtin/body.js | 8 +- packages/melonjs/src/pool.ts | 6 + packages/melonjs/src/renderable/colorlayer.js | 32 +- packages/melonjs/src/renderable/container.js | 12 +- packages/melonjs/src/renderable/mesh.js | 2 +- packages/melonjs/src/renderable/renderable.js | 47 ++ packages/melonjs/src/renderable/sprite.js | 14 +- .../melonjs/src/renderable/text/bitmaptext.js | 2 +- packages/melonjs/src/renderable/text/text.js | 71 ++- packages/melonjs/src/renderable/trigger.js | 1 + packages/melonjs/src/system/bootstrap.ts | 34 +- packages/melonjs/src/system/legacy_pool.js | 64 +- packages/melonjs/src/system/pool.ts | 97 ++- .../melonjs/src/video/effects/tintPulse.js | 8 - packages/melonjs/src/video/effects/wave.js | 8 - packages/melonjs/src/video/gpu/batcher.js | 11 +- .../video/rendertarget/canvasrendertarget.js | 16 + .../src/video/rendertarget/rendertarget.ts | 41 +- .../video/rendertarget/webglrendertarget.js | 16 + .../video/rendertarget/webgpurendertarget.js | 17 +- packages/melonjs/src/video/texture/atlas.js | 22 +- .../melonjs/src/video/texture/texture2d.ts | 19 + .../video/webgl/batchers/lit_quad_batcher.js | 2 + .../melonjs/tests/anchorpoint-presets.spec.js | 16 +- packages/melonjs/tests/helpers/pixels.js | 5 +- .../tests/legacy-pool-deprecation.spec.js | 73 +++ packages/melonjs/tests/legacy_pool.spec.js | 24 +- packages/melonjs/tests/loader.spec.js | 185 +++++- packages/melonjs/tests/mesh.spec.js | 83 +++ .../melonjs/tests/pool-autoreturn.spec.js | 116 ++++ packages/melonjs/tests/renderTarget.spec.js | 19 + .../tests/text-colorlayer-pool.spec.js | 327 ++++++++++ packages/melonjs/tests/texture.spec.js | 118 ++++ packages/melonjs/tests/texture2d.spec.js | 76 +++ .../tests/tiled-builtin-override.spec.js | 76 +++ packages/melonjs/tests/tmxtilemap.spec.js | 257 +++++++- .../tests/trigger_level_change.spec.js | 25 +- packages/melonjs/tests/tweenpool.spec.js | 88 +++ .../tests/webgpu_render_target.spec.js | 40 +- 78 files changed, 3038 insertions(+), 1235 deletions(-) delete mode 100644 packages/examples/src/examples/spaceInvaders/ExampleSpaceInvaders.tsx delete mode 100644 packages/examples/src/examples/spaceInvaders/assets/bgm/.placeholder delete mode 100644 packages/examples/src/examples/spaceInvaders/assets/fnt/LICENSE.txt delete mode 100644 packages/examples/src/examples/spaceInvaders/assets/img/.placeholder delete mode 100644 packages/examples/src/examples/spaceInvaders/assets/img/player.png delete mode 100644 packages/examples/src/examples/spaceInvaders/assets/img/ships.png delete mode 100644 packages/examples/src/examples/spaceInvaders/assets/map/.placeholder delete mode 100644 packages/examples/src/examples/spaceInvaders/assets/sfx/.placeholder create mode 100644 packages/melonjs/scripts/check-declarations.ts create mode 100644 packages/melonjs/tests/legacy-pool-deprecation.spec.js create mode 100644 packages/melonjs/tests/pool-autoreturn.spec.js create mode 100644 packages/melonjs/tests/text-colorlayer-pool.spec.js create mode 100644 packages/melonjs/tests/tiled-builtin-override.spec.js create mode 100644 packages/melonjs/tests/tweenpool.spec.js diff --git a/packages/examples/src/examples/afterBurner/GameController.ts b/packages/examples/src/examples/afterBurner/GameController.ts index 758a3c0726..24949d9c08 100644 --- a/packages/examples/src/examples/afterBurner/GameController.ts +++ b/packages/examples/src/examples/afterBurner/GameController.ts @@ -18,12 +18,13 @@ import { audio, type Camera3d, ChromaticAberrationEffect, + createPool, GlowEffect, input, Light3d, math, ParticleEmitter, - pool, + type Pool, Renderable, type Renderer, ScanlineEffect, @@ -144,20 +145,36 @@ const _sightScreen = new Vector2d(); const _edgeScreen = new Vector2d(); const _enemyScreen = new Vector2d(); -// ─── Pool keys for `me.pool` ─────────────────────────────────────────── -// One-time registered subclasses of Sprite, built on the fly inside the -// `GameController` constructor (the texture isn't available before that). -// `pool.pull(name, x, y)` reuses an existing instance (calling its -// `onResetEvent`) before falling back to construction; `world.removeChild` -// automatically returns the sprite to the pool, no manual release call. -const POOL_PLAYER_BULLET = "AfterBurnerPlayerBullet"; -const POOL_ENEMY_BULLET = "AfterBurnerEnemyBullet"; -const POOL_CONTRAIL_NODE = "AfterBurnerContrailNode"; +// ─── Pooled sprites ─────────────────────────────────────────────────── +// The three recycled Sprite subclasses are built on the fly inside the +// `GameController` constructor, because the texture isn't available before +// that, so their pools are fields rather than module constants. +// `somePool.get(x, y)` reuses an existing instance (calling its +// `onResetEvent`) before falling back to construction, and +// `world.removeChild` returns the sprite to the pool that built it, with no +// manual release call. +type PooledSprite = Sprite & { onResetEvent(x: number, y: number): void }; +type SpritePool = Pool; + +/** wrap a `(x, y)` Sprite subclass in a pool that recycles it */ +function poolOf( + Constructor: new (x: number, y: number) => PooledSprite, +): SpritePool { + return createPool((x: number, y: number) => { + const instance = new Constructor(x, y); + return { + instance, + reset: (x: number, y: number) => { + instance.onResetEvent(x, y); + }, + }; + }); +} /** * Build a `Sprite` subclass with the given texture + RGB tint pre-applied. * Both the constructor and `onResetEvent` take `(x, y)`, so the pool can - * call either on `pull(name, x, y)` without the call site caring whether + * call either on `get(x, y)` without the call site caring whether * this is a fresh instance or a recycled one. */ function buildBulletClass( @@ -223,6 +240,10 @@ export class GameController extends Renderable { enemyBullets: EnemyBulletMover[] = []; score = 0; gameOver = false; + // built in the constructor, once the textures exist + playerBulletPool!: SpritePool; + enemyBulletPool!: SpritePool; + contrailPool!: SpritePool; // `dt`-driven countdown timers. Each frame we subtract the engine- // delivered `dt`; when the value crosses 0 the corresponding event // (spawn enemy / fire bullet / spawn contrail node) is allowed and @@ -354,7 +375,7 @@ export class GameController extends Renderable { this.muzzleEmitter = this._makeMuzzleEmitter(); this.initFlashLights(app); - this.registerPools(); + this.createPools(); this.hud = new HUD(app); this.updateCamera(); @@ -379,26 +400,20 @@ export class GameController extends Renderable { } /** - * Register the three pooled Sprite subclasses with `me.pool`. After - * this, `pool.pull(name, x, y)` recycles instances and - * `world.removeChild(sprite)` auto-returns them. Re-registering on - * each example mount overwrites the prior entry, no leak. + * Build the three pools. `get(x, y)` recycles an instance and + * `world.removeChild(sprite)` returns it, because a pool stamps itself on + * everything it builds. One set of pools per mount, which goes with the + * controller. */ - private registerPools(): void { - pool.register( - POOL_PLAYER_BULLET, + private createPools(): void { + this.playerBulletPool = poolOf( buildBulletClass(this.bulletTexture, TINT_BULLET_RGB), - true, ); - pool.register( - POOL_ENEMY_BULLET, + this.enemyBulletPool = poolOf( buildBulletClass(this.bulletTexture, TINT_ENEMY_BULLET_RGB), - true, ); - pool.register( - POOL_CONTRAIL_NODE, + this.contrailPool = poolOf( buildContrailClass(this.contrailTexture, this.app.renderer), - true, ); } @@ -446,7 +461,7 @@ export class GameController extends Renderable { const oy = CONTRAIL_OFFSET_Y; const sx = this.player.pos.x + ox * cosR - oy * sinR; const sy = this.player.pos.y + ox * sinR + oy * cosR; - const sprite = pool.pull(POOL_CONTRAIL_NODE, sx, sy) as Sprite; + const sprite = this.contrailPool.get(sx, sy); // Spawn at the plane's own depth — the trail then advances // TOWARD the camera each frame, so node 0 is co-planar with // the plane and node N is in front of it (closer to camera = @@ -547,15 +562,10 @@ export class GameController extends Renderable { } spawnBullet(): void { - // `pool.pull` reuses an existing sprite (running its - // `onResetEvent`) before allocating a new one — the additive - // blend mode + gold tint are pre-baked into the registered - // subclass. - const b = pool.pull( - POOL_PLAYER_BULLET, - this.player.pos.x, - this.player.pos.y, - ) as Sprite; + // `get` reuses an existing sprite (running its `onResetEvent`) + // before allocating a new one — the additive blend mode and gold tint + // are pre-baked into the pooled subclass. + const b = this.playerBulletPool.get(this.player.pos.x, this.player.pos.y); // `addChild(child, z)` atomically sets the depth at insertion — // no window where the world's sort key is stale. this.app.world.addChild(b, PLAYER_Z + 40); @@ -1033,7 +1043,7 @@ export class GameController extends Renderable { const dz = this.player.depth - ez; const len = Math.hypot(dx, dy, dz) || 1; const inv = ENEMY_BULLET_SPEED / len; - const b = pool.pull(POOL_ENEMY_BULLET, ex, ey) as Sprite; + const b = this.enemyBulletPool.get(ex, ey); this.app.world.addChild(b, ez); this.enemyBullets.push({ sprite: b, diff --git a/packages/examples/src/examples/isometricRpg/createGame.ts b/packages/examples/src/examples/isometricRpg/createGame.ts index d7c36a9900..68c1b0e031 100644 --- a/packages/examples/src/examples/isometricRpg/createGame.ts +++ b/packages/examples/src/examples/isometricRpg/createGame.ts @@ -4,7 +4,13 @@ * See `packages/examples/LICENSE.md` for full license + asset credits. */ import { DebugPanelPlugin } from "@melonjs/debug-plugin"; -import { Application, loader, plugin, pool, state } from "melonjs"; +import { + Application, + loader, + plugin, + registerTiledObjectClass, + state, +} from "melonjs"; import { PlayerEntity } from "./PlayerEntity.js"; import { PlayScreen } from "./play.js"; import { resources } from "./resources.js"; @@ -27,8 +33,8 @@ export const createGame = async () => { // set the fade transition effect state.transition("fade", "#FFFFFF", 250); - // register our objects entity in the object pool - pool.register("mainPlayer", PlayerEntity); + // name the player class so the Tiled map can place it + registerTiledObjectClass("mainPlayer", PlayerEntity); // switch to PLAY state state.change(state.PLAY); diff --git a/packages/examples/src/examples/platformer-matter/createGame.ts b/packages/examples/src/examples/platformer-matter/createGame.ts index 6cbc809a38..0a1261827b 100644 --- a/packages/examples/src/examples/platformer-matter/createGame.ts +++ b/packages/examples/src/examples/platformer-matter/createGame.ts @@ -14,9 +14,9 @@ import { input, loader, plugin, - pool, Rect, type Renderable, + registerTiledObjectClass, state, TextureAtlas, video, @@ -102,13 +102,13 @@ export const createGame = async () => { // set the fade transition effect state.transition("fade", "#FFFFFF", 250); - // register entity classes in the object pool - pool.register("mainPlayer", PlayerEntity); - pool.register("SlimeEntity", SlimeEnemyEntity); - pool.register("FlyEntity", FlyEnemyEntity); - pool.register("CoinEntity", CoinEntity, true); + // name the entity classes a Tiled map can place + registerTiledObjectClass("mainPlayer", PlayerEntity); + registerTiledObjectClass("SlimeEntity", SlimeEnemyEntity); + registerTiledObjectClass("FlyEntity", FlyEnemyEntity); + registerTiledObjectClass("CoinEntity", CoinEntity); // override the built-in trigger with star mask transition - pool.register("me.Trigger", LevelTrigger, true); + registerTiledObjectClass("me.Trigger", LevelTrigger); // load the texture atlas gameState.texture = new TextureAtlas( diff --git a/packages/examples/src/examples/platformer/createGame.ts b/packages/examples/src/examples/platformer/createGame.ts index 094237ae70..4953db4b11 100644 --- a/packages/examples/src/examples/platformer/createGame.ts +++ b/packages/examples/src/examples/platformer/createGame.ts @@ -11,7 +11,7 @@ import { input, loader, plugin, - pool, + registerTiledObjectClass, state, TextureAtlas, video, @@ -52,13 +52,13 @@ export const createGame = async () => { // set the fade transition effect state.transition("fade", "#FFFFFF", 250); - // register entity classes in the object pool - pool.register("mainPlayer", PlayerEntity); - pool.register("SlimeEntity", SlimeEnemyEntity); - pool.register("FlyEntity", FlyEnemyEntity); - pool.register("CoinEntity", CoinEntity, true); + // name the entity classes a Tiled map can place + registerTiledObjectClass("mainPlayer", PlayerEntity); + registerTiledObjectClass("SlimeEntity", SlimeEnemyEntity); + registerTiledObjectClass("FlyEntity", FlyEnemyEntity); + registerTiledObjectClass("CoinEntity", CoinEntity); // override the built-in trigger with star mask transition - pool.register("me.Trigger", LevelTrigger, true); + registerTiledObjectClass("me.Trigger", LevelTrigger); // load the texture atlas gameState.texture = new TextureAtlas( diff --git a/packages/examples/src/examples/spaceInvaders/ExampleSpaceInvaders.tsx b/packages/examples/src/examples/spaceInvaders/ExampleSpaceInvaders.tsx deleted file mode 100644 index 748d098a85..0000000000 --- a/packages/examples/src/examples/spaceInvaders/ExampleSpaceInvaders.tsx +++ /dev/null @@ -1,297 +0,0 @@ -/** - * melonJS — Space Invaders clone example. - * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. - * See `packages/examples/LICENSE.md` for full license + asset credits. - */ -import { - Application, - Body, - Container, - collision, - game, - input, - loader, - math, - pool, - Rect, - Renderable, - ScaleMethods, - Sprite, - Stage, - state, - timer, - video, -} from "melonjs"; -import { createExampleComponent } from "../utils"; -import playerImg from "./assets/img/player.png"; -import shipsImg from "./assets/img/ships.png"; - -// ---- Constants ---- - -const LASER_WIDTH = 5; -const LASER_HEIGHT = 28; - -// ---- Resources ---- - -const resources = [ - { name: "player", type: "image", src: playerImg }, - { name: "ships", type: "image", src: shipsImg }, -]; - -// ---- Laser ---- - -class Laser extends Renderable { - body: Body; - - constructor(x: number, y: number) { - super(x, y, LASER_WIDTH, LASER_HEIGHT); - - this.body = new Body(this); - this.body.addShape(new Rect(0, 0, this.width, this.height)); - this.body.vel.set(0, -7); - this.body.force.set(0, -3); - this.body.setMaxVelocity(3, 7); - this.body.collisionType = collision.types.PROJECTILE_OBJECT; - this.body.ignoreGravity = true; - - this.alwaysUpdate = true; - } - - onResetEvent(x: number, y: number) { - this.pos.set(x, y); - } - - update(dt: number): boolean { - if (this.pos.y + this.height <= 0) { - game.world.removeChild(this); - } - return super.update(dt); - } - - onCollision(_response: object, other: { body: Body }): boolean { - if (other.body.collisionType === collision.types.ENEMY_OBJECT) { - game.world.removeChild(this); - return false; - } - return false; - } - - draw(renderer: { - getColor: () => string; - setColor: (c: string) => void; - fillRect: (x: number, y: number, w: number, h: number) => void; - }) { - const color = renderer.getColor(); - renderer.setColor("#5EFF7E"); - renderer.fillRect(this.pos.x, this.pos.y, this.width, this.height); - renderer.setColor(color); - } -} - -// ---- Enemy ---- - -class EnemyEntity extends Sprite { - body: Body; - - constructor(x: number, y: number) { - super(x, y, { - image: "ships", - framewidth: 32, - frameheight: 32, - }); - - this.body = new Body(this); - this.body.addShape(new Rect(0, 0, this.width, this.height)); - this.body.collisionType = collision.types.ENEMY_OBJECT; - this.body.ignoreGravity = true; - - this.addAnimation("idle", [math.random(0, 4)], 1); - this.setCurrentAnimation("idle"); - } - - onCollision(_response: object, other: { body: Body }): boolean { - if (other.body.collisionType === collision.types.PROJECTILE_OBJECT) { - (this.ancestor as Container).removeChild(this); - return false; - } - return false; - } -} - -// ---- Enemy Manager ---- - -class EnemyManager extends Container { - static COLS = 9; - static ROWS = 4; - - vel: number; - timer: number; - - constructor() { - super(32, 32, EnemyManager.COLS * 64 - 32, EnemyManager.ROWS * 64 - 32); - - this.enableChildBoundsUpdate = true; - this.vel = 16; - this.timer = -1; - - this.onChildChange = () => { - // guard on PLAY being current — this also fires while the world - // tears the manager down during a stage switch, when the current - // stage is still the default loading screen - if (this.children.length === 0 && state.isCurrent(state.PLAY)) { - (state.current() as PlayScreen).reset(); - } - }; - } - - createEnemies() { - for (let i = 0; i < EnemyManager.COLS; i++) { - for (let j = 0; j < EnemyManager.ROWS; j++) { - const enemy = new EnemyEntity(i * 64, j * 64); - this.addChild(enemy); - } - } - } - - onActivateEvent() { - this.timer = timer.setInterval(() => { - const bounds = this.getBounds(); - - if ( - (this.vel > 0 && bounds.right + this.vel >= game.viewport.width) || - (this.vel < 0 && bounds.left + this.vel <= 0) - ) { - this.vel *= -1; - this.pos.y += 16; - - if (this.vel > 0) { - this.vel += 5; - } else { - this.vel -= 5; - } - - const currentState = state.current(); - if (currentState instanceof PlayScreen) { - currentState.checkIfLoss(bounds.bottom); - } - } else { - this.pos.x += this.vel; - } - }, 250); - } - - onDeactivateEvent() { - timer.clearInterval(this.timer); - } -} - -// ---- Player ---- - -class PlayerEntity extends Sprite { - velx: number; - maxX: number; - - constructor() { - const image = loader.getImage("player") as HTMLImageElement; - - super( - game.viewport.width / 2 - image.width / 2, - game.viewport.height - image.height - 20, - { image: image, width: 32, height: 32 }, - ); - - this.velx = 450; - this.maxX = game.viewport.width - this.width; - } - - update(dt: number): boolean { - super.update(dt); - - if (input.isKeyPressed("left")) { - this.pos.x -= (this.velx * dt) / 1000; - } - - if (input.isKeyPressed("right")) { - this.pos.x += (this.velx * dt) / 1000; - } - - if (input.isKeyPressed("shoot")) { - game.world.addChild( - pool.pull( - "laser", - this.getBounds().centerX - LASER_WIDTH / 2, - this.getBounds().top, - ) as Renderable, - ); - } - - this.pos.x = math.clamp(this.pos.x, 32, this.maxX); - - return true; - } -} - -// ---- Play Screen ---- - -class PlayScreen extends Stage { - player!: PlayerEntity; - enemyManager!: EnemyManager; - - onResetEvent() { - game.world.backgroundColor.parseCSS("#000000"); - - this.player = new PlayerEntity(); - game.world.addChild(this.player, 1); - - this.enemyManager = new EnemyManager(); - this.enemyManager.createEnemies(); - game.world.addChild(this.enemyManager, 2); - - input.bindKey(input.KEY.LEFT, "left"); - input.bindKey(input.KEY.RIGHT, "right"); - input.bindKey(input.KEY.A, "left"); - input.bindKey(input.KEY.D, "right"); - input.bindKey(input.KEY.SPACE, "shoot", true); - } - - onDestroyEvent() { - input.unbindKey(input.KEY.LEFT); - input.unbindKey(input.KEY.RIGHT); - input.unbindKey(input.KEY.A); - input.unbindKey(input.KEY.D); - input.unbindKey(input.KEY.SPACE); - } - - checkIfLoss(y: number) { - if (y >= this.player.pos.y) { - this.reset(); - } - } -} - -// ---- Game entry point ---- - -const createGame = async () => { - try { - const app = new Application(800, 600, { - parent: "screen", - scale: "auto", - scaleMethod: ScaleMethods.FlexWidth, - renderer: video.AUTO, - }); - await app.init(); - } catch { - alert("Your browser does not support HTML5 canvas."); - return; - } - - loader.preload(resources, () => { - state.set(state.PLAY, new PlayScreen()); - - pool.register("laser", Laser, true); - - state.change(state.PLAY); - }); -}; - -export const ExampleSpaceInvaders = createExampleComponent(createGame); diff --git a/packages/examples/src/examples/spaceInvaders/assets/bgm/.placeholder b/packages/examples/src/examples/spaceInvaders/assets/bgm/.placeholder deleted file mode 100644 index 60fc579a73..0000000000 --- a/packages/examples/src/examples/spaceInvaders/assets/bgm/.placeholder +++ /dev/null @@ -1,2 +0,0 @@ -docs will be placed here when built - diff --git a/packages/examples/src/examples/spaceInvaders/assets/fnt/LICENSE.txt b/packages/examples/src/examples/spaceInvaders/assets/fnt/LICENSE.txt deleted file mode 100644 index 141e49f4d0..0000000000 --- a/packages/examples/src/examples/spaceInvaders/assets/fnt/LICENSE.txt +++ /dev/null @@ -1,94 +0,0 @@ -Copyright (c) 2011, Cody "CodeMan38" Boisclair (cody@zone38.net), -with Reserved Font Name "Press Start". - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/packages/examples/src/examples/spaceInvaders/assets/img/.placeholder b/packages/examples/src/examples/spaceInvaders/assets/img/.placeholder deleted file mode 100644 index 60fc579a73..0000000000 --- a/packages/examples/src/examples/spaceInvaders/assets/img/.placeholder +++ /dev/null @@ -1,2 +0,0 @@ -docs will be placed here when built - diff --git a/packages/examples/src/examples/spaceInvaders/assets/img/player.png b/packages/examples/src/examples/spaceInvaders/assets/img/player.png deleted file mode 100644 index f2516da0fdd17f0a64e517a877e6f590221db8fd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 215 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJd7dtgArY-_rx@}zIS9D^7g(S0 z{I7mdKI;Vst0GD%3$^ZZW diff --git a/packages/examples/src/examples/spaceInvaders/assets/img/ships.png b/packages/examples/src/examples/spaceInvaders/assets/img/ships.png deleted file mode 100644 index e1d00f5ba125606157501f8cb0b37032d22f1b68..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3058 zcmV=+lX2?eB0B~u=vQHB!r%MH(cnV##CE{63jXtNy1wY9+-$)9R%=X1mE5w?R#E{BeU!nu>Ch{mOHNVnD2*( zpnyNIzEV%xjZ$MDPlYoL1i;#Pi%v-~EHjpc~qio!4$T zUHI+H5*q3~ZX66CXSt)^^R!hA&zs%2G%Jx z&le)+XXzh2O{kHUju@Kje~*-xE?ds^9Q+_Wx$2rtR{-?h#A?@JRS?oL!(p)ntU|7g z3Q6YHQ86h2BT#cD|ZP%nO%E2~uD*pju;<5$1Ol^id89j|xH0jackFbu*HMaD6d z9YoRM6=8l?WUZnoL)pQyGA6ce+-Wr_;>a9xKyD=7N^c|_B=qwwC^Eh!%)f_$Wh5-` zfR7TA?FIoq7a|i4FMx=HpPfFEJ}OA;0HCOIMd6!IPz9~t+M!IfRYS4^k-Qcf{TY2N z{;bohS(w9<@d2=0pRJbhT2PNC6-0oWa;8jgZfK*qp{;D5sSv>01}9pqxR>-CDS{`E zGEnbCbtjI18E!ozW~k(#nW}NAF?F%!wKVbg+4Ed0!j-<#R%cjLxEQ)vpD4wPpdrf< zPv{-CyxY>m<7FM-|H~`nUVfXpO@BU?)>u1}++YqjWeIZQ{k?yn7dK^aB~4@P%(3*L zu74%<>8tR*&=FZPA(rJQ3Ig0PAxaQdx5q&|Kd}@A*?0M;YBS7)KE6(A?C9h?y+s3mwyH z-yA8nKmYnHT>8z>1P90n=-4_oP;eK{o?MX_X3xB$yfvd0ESybnl@2{PTE~UZi?wPIuaX3ZMQu6+i8u5!j)hq zc-XjRL?$k921k}4fL5* z&3Lcy1V(=NdZmg2QF%|I^EBg_Og3k~@|O$0^NWfFs_Z0HYW7w@6Gc~%B*A_?mkmyi z&f|8)jmWuS)URx5tA2=_8^*4<5wFH2$b~=p(xQ!tFE1wGAbxl(no%@*$p22e5SXWv z2rF{HJ&QKKXVpGnt&SMS;M}wT@2y_KgU2e;G(30=@2y@^xBVf0HazMC>H8}ZQ~>$Z zo|hQbZ_wlw??)O_kO{tpaStVtHz}4@U9)+&4T7&4BmQV-$nAXtH|-{Va(mwZK(l@p zYuDZVsYgDj-1I`NWgnSvDXM+A0;+x3a4BcAkH~e;^(GNjayr^}^lP_!QqvUrVd7KV zLbz#{Ftq*mEZi7-VERboV}8KL{D2RiAHahREx5rt(C+a>*+}4S;5!5V5BLGDzrEOa z0PT*_li`0IKY(?$OR)L^Q?CfK7`dGdng7H50LxlRFNf3l@73c6w656lMHSr^S75Pl z-Q6_gbiTRwfvWHWc&N4m(E~u~n->^Ce!z54Wcj%n zb}K8_SBD>fZKAyKMBl5x4`^Mn@$-u80Ey1GB-(7o6+8x^v&GJQ?()C*_Qmr9keEl# zar2ev0xc`IbfWhAAXREP06h={+5?1~FJyJPbMbm6R(sM!EIKT%D>&rq|4oPS8(ITw!~ z(0bRF)vES;h$O!+h^?0%6i-(6{$NcJKj6zPUv4m%8v*J~_KqP^4^^{XMb~4^0RW4A zb!*?&VtzpL%8ebO<`59;P~V$XM?=RvOgah_oAnuZ_e#iTtNI_Bq{Fx};-jPV0R%+|`x zs2l=|qoz0G`~a)6BLB0j@or-+jW=CgWc(;h+NJTPt8p7^tEld!Md38~V4MQ!InAXm zhJk3cn4Z&ok!t&|(<;Ldr;?>iJ-TP+l`-Z4h3Ia;1p2fhhD&e0{stOzJFm&$_#@*3{mxD1ExE zhVZ^$`M-Z#rDhi5Pt(WWdp`5YPJoQfytd=)j(GflWhZ{h(YmXO(loFPynLLdTd%>H zQM_3UhjR?Pe0`rZO-AkYyzVKbo(mq|8C+7zH{_6dj<#D#d zMGnv@PFs3rT_0E1DbX|Qdj9171LFyi@riD5rRPkZAHZTBuK792k}}zcA?*I^N6#nY z2M9bYFo_IM6AUNObrL3(R}IusIX^(9JRKbl^QfPkA8=;ZnnyvODg5XObeukzP{U$$ zoIV)+pA8_8QIW6o?_N_mKLB=?|16dB1Jv?$(37L@FU-D;Mt=a;Ci{=EQMfjs(I4Q2 z+0kIG@T2}+Yc7r-U^obrzOPrQoFCA)yX!pz4~%w&p3AOdZsr8-XDefgw4Z&QxtSC6 zTy`D6Q9qTsXnp`2GA|+WjVZsWehX_Zjvvswt82G~uL8cSQOnALvq${5_!bfiA^QKf<>M6Y&H72S`5jS!tGL!~g&Q07*qoM6N<$f { - me.pool.register("spriteTP", SpriteTP); - me.pool.register("collisionTP", CollisionTP); - me.pool.register("cloud", Cloud); - me.pool.register("foodie", Foodie); - me.pool.register("portal", Portal); - me.pool.register("cookingArea", CookingArea); - me.pool.register("male", Male); - me.pool.register("waterTextureObj", WaterTextureObj); + me.registerTiledObjectClass("spriteTP", SpriteTP); + me.registerTiledObjectClass("collisionTP", CollisionTP); + me.registerTiledObjectClass("cloud", Cloud); + me.registerTiledObjectClass("foodie", Foodie); + me.registerTiledObjectClass("portal", Portal); + me.registerTiledObjectClass("cookingArea", CookingArea); + me.registerTiledObjectClass("male", Male); }; diff --git a/packages/examples/src/examples/waterOverworld/play.ts b/packages/examples/src/examples/waterOverworld/play.ts index b6aa61ddb6..47dfadfe31 100644 --- a/packages/examples/src/examples/waterOverworld/play.ts +++ b/packages/examples/src/examples/waterOverworld/play.ts @@ -4,6 +4,7 @@ * See `packages/examples/LICENSE.md` for full license + asset credits. */ import * as me from "melonjs"; +import { WaterTextureObj } from "./entities.js"; export class WaterOverworldStage extends me.Stage { override onResetEvent() { @@ -16,11 +17,11 @@ export class WaterOverworldStage extends me.Stage { // the refracting pond, over the scene (z 20), scaled like the // original demo me.game.world.addChild( - me.pool.pull("waterTextureObj", 480, 301, { + new WaterTextureObj(480, 301, { inspectors: { scale: { x: 2.032, y: 2.032 }, }, - }) as me.Renderable, + }), 20, ); } diff --git a/packages/examples/src/examples/whac-a-mole/effects.ts b/packages/examples/src/examples/whac-a-mole/effects.ts index 2081022f7b..98e9afd9ca 100644 --- a/packages/examples/src/examples/whac-a-mole/effects.ts +++ b/packages/examples/src/examples/whac-a-mole/effects.ts @@ -7,7 +7,7 @@ import { type Camera2d, ChromaticAberrationEffect, DropShadowEffect, - pool, + getPool, type Renderer, type Sprite, Tween, @@ -72,7 +72,7 @@ export function triggerChromaticBurst( ): void { fx.setOffset(peak); const driver = { offset: peak }; - const tween = pool.pull("me.Tween", driver) as Tween; + const tween = getPool("tween").get(driver); tween.updateWhenPaused = true; tween .to({ offset: 0 }, { duration: durationMs }) diff --git a/packages/examples/src/examples/whac-a-mole/mole.ts b/packages/examples/src/examples/whac-a-mole/mole.ts index b83f90e546..1deff3122b 100644 --- a/packages/examples/src/examples/whac-a-mole/mole.ts +++ b/packages/examples/src/examples/whac-a-mole/mole.ts @@ -7,8 +7,8 @@ import { audio, type ChromaticAberrationEffect, game, + getPool, input, - pool, Sprite, save, Tween, @@ -117,7 +117,7 @@ export class MoleEntity extends Sprite { */ display() { const finalpos = this.initialPos - 140; - this.displayTween = pool.pull("me.Tween", this.pos) as Tween; + this.displayTween = getPool("tween").get(this.pos); this.displayTween.to({ y: finalpos }, { duration: 200 }); this.displayTween.easing(Tween.Easing.Quadratic.Out); this.displayTween.onComplete(this.onDisplayed.bind(this)); @@ -140,7 +140,7 @@ export class MoleEntity extends Sprite { */ hide() { const finalpos = this.initialPos; - this.displayTween = pool.pull("me.Tween", this.pos) as Tween; + this.displayTween = getPool("tween").get(this.pos); this.displayTween.to({ y: finalpos }, { duration: 200 }); this.displayTween.easing(Tween.Easing.Quadratic.In); this.displayTween.onComplete(this.onHidden.bind(this)); diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index 865133d34a..775202a499 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -218,11 +218,6 @@ const ExamplePlinkoPlanck = lazy(() => default: m.ExamplePlinkoPlanck, })), ); -const ExampleSpaceInvaders = lazy(() => - import("./examples/spaceInvaders/ExampleSpaceInvaders").then((m) => ({ - default: m.ExampleSpaceInvaders, - })), -); const ExampleSpine = lazy(() => import("./examples/spine/ExampleSpine").then((m) => ({ default: m.ExampleSpine, @@ -608,14 +603,6 @@ const examples: { description: "Neon-cyberpunk plinko driven by @melonjs/planck-adapter — click to drop balls, all-procedural rendering.", }, - { - component: , - label: "Space Invaders", - path: "space-invaders", - sourceDir: "spaceInvaders", - description: - "Classic space invaders game with player movement, shooting mechanics, and enemy wave patterns.", - }, { component: , label: "Shader Effects", diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 4ff76b22a7..569dbf5074 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,11 +3,31 @@ ## [20.9.0] (melonJS 2) - _unreleased_ ### Added +- **`RenderTarget#toImageData()`**: read back a render target's pixels as a promise. The portable readback, and the one backend-agnostic code should use: `toBlob()`, `toDataURL()` and `toImageBitmap()` are all built on it +- **`Texture2d#isAtlas`**: whether a texture carries named regions addressable with `getRegion()`. `false` on every texture but a `TextureAtlas`, so a game holding a `Texture2d` of unknown kind can ask without a type test - **Spatial audio placed in world coordinates**: `audio.play(name, { follow: renderable })` tracks a sound to a renderable every frame, `{ at: { x, y } }` pins one to a fixed world point, and `{ stopWithTarget: true }` ends it when that renderable is destroyed. The numbers are world pixels with y measured down, the same ones already in `pos`, so a game converts nothing by hand. `audio.unfollow(id)` detaches a sound and leaves it playing where it is - **A movable listener**: `audio.setListener(target)` puts the ear on a renderable and keeps it there, while `audio.listener(x, y, z)` and `audio.listenerOrientation(fx, fy, fz, ux, uy, uz)` place it by hand and read it back when called with no arguments. Source positions become absolute world coordinates instead of offsets from the player, and a `Camera3d` target contributes orientation as well, taken from its own basis. It is opt-in end to end: until a game asks for a listener no frame handler is installed, and every existing `position` / `stereo` / `panner` call behaves exactly as it did - **Panner defaults shaped for pixels**: a placed sound now starts from `refDistance: 240`, `maxDistance: 10000`, `distanceModel: "inverse"` and `panningModel: "equalpower"`, so it carries across a screen instead of going near silent a few tiles out. `audio.setSpatialDefaults()` and `audio.getSpatialDefaults()` change what new voices inherit +- **`getPool("text")` and `getPool("colorLayer")`**: typed pools for the two renderables whose only recycling route was the legacy name-keyed pool. `getPool("text").get(x, y, settings)` hands back a label with its measurement cache already allocated, and `release()` gives it back +- **`createPool` is exported**, along with the `Pool` and `CreatePoolOptions` types it names, so a pool for one of your own classes no longer needs a deep import and a field holding one can be declared. `getPool()` only reaches the pools the engine ships, so this is what a game moving off `pool.register(name, Class, true)` needs + +### Changed +- `pool.push(obj)` reports a refusal by returning `false` rather than throwing. It threw by default for any class that was never registered, or registered without recycling, and nothing at the call site made that visible: the exception aborted whatever was running, which in both of the engine's own uses removed in 20.7.0 was a `destroy()` that had already recycled other state, leaving a half torn down object to die later somewhere unrelated. Pass `pool.push(obj, true)` where a missed registration is a bug you want to hear about immediately ([#1688](https://github.com/melonjs/melonJS/issues/1688)) +- Tiled tile images, atlas sprites and Tiled image layers are built from their classes directly rather than looked up by name. `TextureAtlas#createSpriteFromName` and the Tiled image layer loader used to resolve their class through `pool.pull("me.Sprite")` and `pool.pull("ImageLayer")`, which never pooled anything: neither class is registered for recycling, so the call was a plain construction behind a string key. Re-registering one of those BUILT-IN names to substitute your own class, as in `pool.register("Sprite", MySprite)`, therefore no longer changes what they produce. Registering your own names is unaffected, and so is substituting a built-in for Tiled OBJECTS, which goes through the Tiled object factory ([#1688](https://github.com/melonjs/melonJS/issues/1688)) +- The engine no longer registers its own classes in the legacy object pool, so `pool.pull("Sprite")`, `pool.pull("Text")`, `pool.pull("Tween")`, `pool.pull("Particle")` and the other nine built-in names throw instead of handing back an instance. Nine of the thirteen had recycling switched off, which made the call a plain construction behind a string key; the four that had it on all have a typed pool, `getPool("tween")`, `getPool("particle")`, `getPool("text")` and `getPool("colorLayer")`. Call the constructor, or the typed pool where you want the recycling ([#1688](https://github.com/melonjs/melonJS/issues/1688)) +- A child removed from a container goes back to the typed pool that built it, and is destroyed when no pool did. An object carries its own pool now rather than being matched by name against a global registry, so `pool.register(name, Class, true)` no longer makes a class recyclable on removal, and `pool.push()` on a Tiled object declines it rather than pooling it, since nothing stamps the name it was looked up under any more. A built-in `Text` or `ColorLayer` removed from a container is therefore destroyed where it used to be pushed to the legacy pool: pass `keepalive` to `removeChild()` if you mean to add it back ([#1688](https://github.com/melonjs/melonJS/issues/1688)) + +### Deprecated +- `pool`, the legacy name-keyed object pool. `register()`, `pull()` and `push()` each print a notice once, naming both replacements, because the class was doing two unrelated jobs and a caller only ever wanted one of them: `getPool()` and `createPool()` pool instances, and `registerTiledObjectClass()` lets a Tiled map name a class. Nothing in the engine calls it any more ([#1688](https://github.com/melonjs/melonJS/issues/1688)) ### Fixed +- Rendering: `toBlob()`, `toDataURL()` and `toImageBitmap()` threw on a WebGPU render target. `RenderTarget` required every backend to implement a SYNCHRONOUS `getImageData()`, and WebGPU cannot: reading a texture there means mapping a buffer, which only completes asynchronously. The three methods inherited a base implementation built on that synchronous call, so all three failed on one of the three backends. The contract is a promise now, which every backend can honour +- Rendering: the `RenderTarget` contract no longer requires synchronous readback. `getImageData()` remains on the canvas and WebGL targets, which can do it, and is no longer part of the shared interface; `WebGPURenderTarget#readPixels()` is removed, since it was that backend's own spelling of `toImageData()` and was never part of the render target API +- Tiled: `registerTiledObjectClass("Trigger", MyTrigger)` called before the first map load was silently discarded. The built-in classes were queued and flushed when the first object was built, overwriting whatever a game had registered under one of their names, and the game got the built-in. They are applied as defaults now, so a name a game has taken stays taken, and a built-in name can be taken over at any point rather than only before the first map load: a `Stage` registering its classes in `onResetEvent`, with a level already loaded, used to hit the duplicate-registration error instead of winning +- Text: a recycled label inherited everything a game drives through the base class, since the settings only name `Text`'s own fields. A damage number tweened to `alpha` 0 came back invisible, one scaled up came back large, one tinted came back tinted. The alpha, tint, blend mode, transform, anchor, flip and the behaviour flags are restored before the new settings are applied, and `ColorLayer` does the same +- Text: a recycled label built a new canvas and a new metrics cache every time, which stranded the previous canvas and its GPU texture, since only `destroy()` frees those. They are kept across a recycle now, which is most of what makes a pooled label worth pooling +- Text: a recycled label inherited the previous label's `fillStyle`, `strokeStyle` and `floating`. Those three were written only when the setting was supplied, which a freshly constructed label never shows because it starts from the constructor defaults, and `getPool("text")` makes the case reachable +- Typings: another twelve public members reached TypeScript as `any`, because the generated declarations named types they never imported, among them the mesh shadow light, the `Trigger` and `GLTFModel` vectors and the quadtree and octree container types. `Body#collisionMask` was stripped from the published types altogether while the `PhysicsBody` interface it implements requires it, and `BitmapText#resize()` was typed as returning the base class rather than itself - Audio: `stereo()` and `position()` drive the same per-instance panner node, and the first of the two to be called decided what kind of node it was, which made the other a silent no-op for the life of that voice. The node now follows whichever was called last, so the order no longer matters - Audio: `audio.panner(name, attributes)` with no playback id wrote only the voices already playing and never the clip's own defaults, so a call before the first `play()` changed nothing at all, the getter read back construction-time values forever, and every later voice inherited those. It writes the group now, and merges rather than replaces, so setting one attribute leaves the others alone - Audio: sound effects kept playing while the window did not have focus, because pausing the game only ever reached the current music track. A looping effect such as an engine hum or an alarm played on over whatever the player had switched to. The mix is muted on blur and restored on focus, which covers `tone()` and `noise()` and anything a game hangs off `getMasterGain()` as well as ordinary clips, and it is gated by the existing `pauseOnBlur` and `stopOnBlur` settings so a game that deliberately keeps running in the background keeps its audio too diff --git a/packages/melonjs/package.json b/packages/melonjs/package.json index e9cfabebc4..13e8c7671a 100644 --- a/packages/melonjs/package.json +++ b/packages/melonjs/package.json @@ -82,7 +82,7 @@ "serve": "serve docs", "prepublishOnly": "pnpm dist:publish", "clean": "tsx scripts/clean.ts", - "types": "tsc --project tsconfig.build.json && tsx scripts/strip-internal.ts", + "types": "tsc --project tsconfig.build.json && tsx scripts/strip-internal.ts && tsx scripts/check-declarations.ts", "test:types": "tsc", "doc:css": "cat scripts/docs/brand.css scripts/docs/copy-page.css > scripts/docs/.docs.css" } diff --git a/packages/melonjs/scripts/check-declarations.ts b/packages/melonjs/scripts/check-declarations.ts new file mode 100644 index 0000000000..26193e1f01 --- /dev/null +++ b/packages/melonjs/scripts/check-declarations.ts @@ -0,0 +1,203 @@ +/** + * Type-check the PUBLISHED declarations, and guard them against internal leaks. + * + * `pnpm lint` and `tsc -p tsconfig.json` both check the SOURCE, where + * `@internal`, `@ignore` and `@import` carry no meaning at all. Everything + * those tags control happens in the emitted `.d.ts`, which until now nothing + * compiled against, so a whole class of defect was invisible: + * + * - a public member reaching TypeScript as `any`, because the declaration + * named a type it never imported; + * - a member stripped from the published types while the interface it + * implements still requires it; + * - an `@internal` function becoming public, because an unrelated edit + * orphaned the JSDoc block carrying the tag; + * - a type a public function returns that a consumer cannot name, because the + * module defining it is not part of the entry point. + * + * Every one of those shipped at least once. This compiles a fixture against + * `build/index.d.ts` and fails the build on any of them. + */ +import { execFileSync } from "node:child_process"; +import { + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const pkgRoot = resolve(here, ".."); +const buildDir = resolve(pkgRoot, "build"); +const checkDir = resolve(buildDir, ".declcheck"); + +const problems: string[] = []; + +/** + * A consumer's file. Each statement stands for a defect that shipped, so a + * failure here names the thing that regressed rather than a line number. + */ +const fixture = ` +import { + BitmapText, + Body, + CanvasRenderTarget, + createPool, + getPool, + loader, + type PhysicsBody, + Rect, + Renderable, + type RenderTarget, + Texture2d, +} from "../index.js"; + +// \`createPool\` is public, and a game needs it to move off the legacy pool +const bulletPool = createPool((x: number, y: number) => { + const instance = new Renderable(x, y, 4, 4); + return { instance, reset: (x: number, y: number) => instance.pos.set(x, y) }; +}); +export const bullet: Renderable = bulletPool.get(1, 2); +bulletPool.release(bullet); + +// the typed pools the engine ships, through the public key +export const label = getPool("text").get(0, 0, { font: "Arial", size: 12, text: "hi" }); + +// a texture of unknown kind can be asked whether it has named regions +export function regionAware(texture: Texture2d): boolean { + return texture.isAtlas; +} + +// the portable readback is a promise on the BASE class, so backend-agnostic +// code can call it without naming a backend +export async function grab(target: RenderTarget): Promise { + return target.toImageData(); +} + +// and the canvas target still answers synchronously +export function grabSync(target: CanvasRenderTarget): ImageData { + return target.getImageData(0, 0, 1, 1); +} + +// \`Body\` has to satisfy the interface it implements: \`collisionMask\` was +// stripped from the published types while \`PhysicsBody\` required it +export function asPhysicsBody(r: Renderable): PhysicsBody { + const body: PhysicsBody = new Body(r, new Rect(0, 0, 8, 8)); + return body; +} + +// a public function's return type must be nameable by a consumer +export function nodeCount(name: string): number { + const data: loader.GLTFData | null = loader.getGLTF(name); + return data === null ? 0 : data.nodes.length; +} + +// chaining returns the subclass, not the base +export function chained(t: BitmapText): BitmapText { + return t.resize(2).resize(3); +} +`; + +// inside `build/` so the fixture resolves `../index.js` the way a consumer +// resolves the package, and removed again before anything publishes: `build` +// is in package.json's `files` +mkdirSync(checkDir, { recursive: true }); +writeFileSync(join(checkDir, "fixture.ts"), fixture); + +try { + execFileSync( + "npx", + [ + "tsc", + "--noEmit", + "--strict", + "--target", + "es2022", + "--module", + "esnext", + "--moduleResolution", + "bundler", + "--lib", + "es2022,dom", + "--skipLibCheck", + "--ignoreConfig", + join(checkDir, "fixture.ts"), + ], + { cwd: pkgRoot, stdio: "pipe", encoding: "utf8" }, + ); +} catch (error) { + const output = (error as { stdout?: string; stderr?: string }).stdout ?? ""; + problems.push( + "the published declarations do not type-check against a consumer:\n" + + output.trim(), + ); +} + +rmSync(checkDir, { recursive: true, force: true }); + +/** + * Names that must never reach a `.d.ts`. + * + * `strip-internal.ts` reads `@internal` off the JSDoc block ATTACHED to a + * declaration, so inserting anything between the block and the declaration + * silently un-tags it. These are the engine internals most likely to leak + * that way: a parser entry point, the pool registry's innards, the cache + * helpers and the recycle hooks. + */ +const mustNotLeak = [ + "preloadGLTF", + "getRegisteredPools", + "registerBuiltinTiledClass", + "releaseToOwningPool", + "resetRenderableState", + "CACHED_TYPES", + "deleteAsset", + "hasAsset", + "assetNames", +]; + +/** every emitted declaration file */ +const declarations: string[] = []; +const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== ".declcheck") { + walk(full); + } + } else if (entry.name.endsWith(".d.ts")) { + declarations.push(full); + } + } +}; +walk(buildDir); + +for (const file of declarations) { + // comments are prose: `@internal` is often MENTIONED in a doc block that + // documents something else, so only code lines count + const code = readFileSync(file, "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); + for (const name of mustNotLeak) { + if (new RegExp(`\\b${name}\\b`).test(code)) { + problems.push( + `${file.slice(pkgRoot.length + 1)} declares the internal \`${name}\`. ` + + "Its `@internal` tag is most likely orphaned: check that nothing " + + "was inserted between the JSDoc block and the declaration.", + ); + } + } +} + +if (problems.length > 0) { + console.error("\ncheck-declarations failed:\n"); + for (const problem of problems) { + console.error(` - ${problem}\n`); + } + process.exit(1); +} + +console.log( + `check-declarations: ${declarations.length} declaration file(s) checked, consumer fixture compiles`, +); diff --git a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md index 3cc05dd9fa..9c068ba138 100644 --- a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md +++ b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md @@ -1,6 +1,6 @@ --- name: melonjs-camera-and-drawing -description: "Use this skill for camera control and immediate-mode drawing in melonJS — following a target, viewport bounds, shake and fade, secondary cameras, and drawing shapes, lines and gradients inside a custom draw(). Covers world versus screen coordinate conversion, clipping and masking, and baking with CanvasRenderTarget. Triggers on: Camera2d, viewport, follow, setBounds, shake, fadeIn, fadeOut, worldToLocal, localToWorld, colorMatrix, renderer.fill, renderer.stroke, Rect, Ellipse, Polygon, Line, Gradient, clipRect, mask, CanvasRenderTarget, NoiseTexture2d, lookAt, setBasis." +description: "Use this skill for camera control and immediate-mode drawing in melonJS — following a target, viewport bounds, shake and fade, secondary cameras, and drawing shapes, lines and gradients inside a custom draw(). Covers world versus screen coordinate conversion, clipping and masking, baking with CanvasRenderTarget, and reading pixels back. Triggers on: Camera2d, viewport, follow, setBounds, shake, fadeIn, fadeOut, worldToLocal, localToWorld, colorMatrix, renderer.fill, renderer.stroke, Rect, Ellipse, Polygon, Line, Gradient, clipRect, mask, CanvasRenderTarget, NoiseTexture2d, lookAt, setBasis, toImageData, getImageData, toBlob, toDataURL, screenshot, read pixels." license: MIT --- @@ -253,6 +253,36 @@ throws on a zero dimension, instead of `document.createElement("canvas")`. `renderer.toFrameTexture()` is the related tool for capturing the *current frame* into a texture for a shader to sample. +## Reading pixels back + +To grab what a render target holds, as pixels or as a file: + +```js +const pixels = await rt.toImageData(); // ImageData, any backend +const blob = await rt.toBlob(); // Blob, "image/png" by default +const url = await rt.toDataURL(); // data: URL string +const bitmap = await rt.toImageBitmap(); // ImageBitmap +``` + +All four are promises, and that is not decoration. **WebGPU cannot read a +texture back synchronously** — it has to map a buffer, which only completes on +a later tick. So `toImageData()` is the portable readback and the other three +are built on it. + +There is also a synchronous `getImageData()` on `CanvasRenderTarget` and +`WebGLRenderTarget`, because those two genuinely can. It is **not** part of the +`RenderTarget` contract and it **throws on a WebGPU target**. Reach for it only +in code that already knows which backend it is on; anything backend-agnostic +should await `toImageData()`. + +```js +// fine in canvas-only or WebGL-only code +const data = rt.getImageData(0, 0, w, h); + +// the version that works wherever the game runs +const data = await rt.toImageData(0, 0, w, h); +``` + ## Symptom → cause | symptom | cause | @@ -267,6 +297,7 @@ into a texture for a shader to sample. | clipping in the wrong place inside a container | container clips in local coordinates, after its own translate | | `container.clipping = true` does nothing | container has no explicit size, so `width`/`height` are `Infinity` | | baked target draws nothing / throws | passed the `CanvasRenderTarget`, not its `.canvas` | +| `getImageData()` throws on one machine but not another | that one is on the WebGPU renderer, which cannot read back synchronously — `await toImageData()` instead | | frame rate drops with many static draws | bake into a `CanvasRenderTarget` instead | | a 3D camera cannot be given an arbitrary up | `Camera3d#lookAt(target, up)` or `setBasis(right, up, forward)`; see `melonjs-3d` | diff --git a/packages/melonjs/skills/melonjs-events/SKILL.md b/packages/melonjs/skills/melonjs-events/SKILL.md index 4573f6311e..7ceb810777 100644 --- a/packages/melonjs/skills/melonjs-events/SKILL.md +++ b/packages/melonjs/skills/melonjs-events/SKILL.md @@ -112,9 +112,9 @@ onDeactivateEvent() { ``` **Pool-recycled objects never fire `onDestroyEvent` on removal** — `removeChildNow` -tries `pool.push(child)` first and only calls `destroy()` if that fails, so a -class registered with `pool.register(name, Class, true)` goes back to the pool -instead. Anything you clean up there will not run. `destroy()` is also what calls +first tries to return the child to the pool that built it, and only calls +`destroy()` when nothing owns it. So anything taken from a `createPool` goes +back to that pool instead. Anything you clean up there will not run. `destroy()` is also what calls `releaseAllPointerEvents(this)`, which is why a pooled object must release its own pointer registrations in `onDeactivateEvent`. diff --git a/packages/melonjs/skills/melonjs-performance/SKILL.md b/packages/melonjs/skills/melonjs-performance/SKILL.md index 61e2e39937..09d616c8c0 100644 --- a/packages/melonjs/skills/melonjs-performance/SKILL.md +++ b/packages/melonjs/skills/melonjs-performance/SKILL.md @@ -59,16 +59,28 @@ Anything spawned frequently — bullets, particles, pickups, damage numbers — should be pooled rather than allocated: ```js -pool.register("bullet", Bullet, true); // third arg = enable recycling -const b = pool.pull("bullet", x, y); // recycled: onResetEvent(x, y) - // fresh: new Bullet(x, y) -world.removeChild(b); // returns to the pool automatically +import { createPool } from "melonjs"; + +const bulletPool = createPool((x, y) => { + const instance = new Bullet(x, y); + return { instance, reset: (x, y) => instance.onResetEvent(x, y) }; +}); + +const b = bulletPool.get(x, y); // recycled: reset(x, y) + // fresh: new Bullet(x, y) +world.removeChild(b); // returns to bulletPool automatically ``` -`register` also publishes the class as a Tiled object factory under that name, -so an object with a matching class or name in a `.tmx` map instantiates it. Set -`pool.autoRegisterTiled = false` around the call for classes that should stay -programmatic. +The container returns a child to whichever pool built it, so removal is enough; +you only call `release()` yourself for something that was never added to the +world. + +**`me.pool` is the older, string-keyed version and is deprecated since +18.0.0.** `pool.register(name, Class, true)` / `pool.pull(name)` still work and +print a one-off console notice, but a recycled object is no longer returned on +removal, so pooling through it now costs an allocation per spawn. To register a +class so a Tiled map can name it, use `registerTiledObjectClass` — that is a +separate registry and is not deprecated. Two rules that cause subtle bugs when missed: @@ -76,10 +88,11 @@ Two rules that cause subtle bugs when missed: alpha, tint, scale, animation, velocity. Anything you forget carries into the next use, which looks like a random visual glitch. - **Pooled objects do not fire `onDestroyEvent` on removal.** Removal always - calls `onDeactivateEvent`, then tries `pool.push`; only when that *fails* does - it fall through to `destroy()`, which is what calls `onDestroyEvent`. So a - successfully recycled object never sees it. Pair event subscriptions with - `onActivateEvent` / `onDeactivateEvent` instead, or you leak handlers. + calls `onDeactivateEvent`, then tries to return the object to the pool that + built it; only when nothing owns it does it fall through to `destroy()`, + which is what calls `onDestroyEvent`. So a successfully recycled object never + sees it. Pair event subscriptions with `onActivateEvent` / + `onDeactivateEvent` instead, or you leak handlers. - **`removeChild` is DEFERRED, which breaks a pool you re-lend in the same frame.** The removal is queued and runs after the update and draw stack has @@ -98,14 +111,24 @@ Two rules that cause subtle bugs when missed: usual cause of a pooled effect that flickers out one frame after it is recycled. -Engine classes are poolable too: `pool.pull("Tween", target)`. The canonical -names are unprefixed — `Entity`, `Collectable`, `Trigger`, `Light2d`, -`Particle`, `Sprite`, `NineSliceSprite`, `Renderable`, `Text`, `BitmapText`, -`ImageLayer`, `Tween`, `ColorLayer`. +**The engine's own classes are NOT in this pool.** They were registered in it +once, which bought nothing: most had recycling off, so `pool.pull("Sprite")` +was a plain construction behind a string key. The ones worth recycling have +typed pools instead, reached by key and fully typed: + +```js +const tween = getPool("tween").get(target); +const label = getPool("text").get(x, y, { font: "Arial", size: 12 }); +getPool("text").release(label); // yours to release, as with particles +``` + +`getPool` covers `vector2d`, `vector3d`, `point`, `matrix2d`, `matrix3d`, +`bounds`, `color`, `polygon`, `line`, `rectangle`, `roundedRectangle`, +`ellipse`, `tween`, `particle`, `text`, `colorLayer` and `bitmapTextData`. -`pool.register` additionally aliases every name under an `me.` prefix, pointing -at the same entry, so `pool.pull("me.Tween")` resolves identically — and the -same alias is registered with the Tiled object factory, which is why a map +`pool.register` still aliases every name you give it under an `me.` prefix, +pointing at the same entry, so `pool.pull("me.Bullet")` resolves identically — +and the same alias reaches the Tiled object factory, which is why a map authored against melonJS 1.x still finds its classes. Prefer the unprefixed name in new code; do not "correct" an `me.`-prefixed one, it is not broken. diff --git a/packages/melonjs/skills/melonjs-scenes-and-state/SKILL.md b/packages/melonjs/skills/melonjs-scenes-and-state/SKILL.md index 1e4b901efd..2473a512f2 100644 --- a/packages/melonjs/skills/melonjs-scenes-and-state/SKILL.md +++ b/packages/melonjs/skills/melonjs-scenes-and-state/SKILL.md @@ -257,9 +257,9 @@ instances.** All four, or it is not a tween. ```js -import { Tween, pool } from "melonjs"; +import { getPool, Tween } from "melonjs"; -const t = pool.pull("Tween", sprite.pos) // registered name is "Tween" +const t = getPool("tween").get(sprite.pos) // typed pool, release when done .to({ x: 300 }, { duration: 500 }) // options object, not a number .easing(Tween.Easing.Quadratic.Out) .onComplete(() => { /* … */ }) @@ -289,7 +289,7 @@ callback is invoked with the tweened object as `this` and the eased progress ```js const fade = { level: 1 }; -pool.pull("Tween", fade) +getPool("tween").get(fade) .to({ level: 0 }, { duration: 400 }) .easing(Tween.Easing.Quadratic.Out) .onUpdate(function () { diff --git a/packages/melonjs/skills/melonjs-sprites-and-animation/SKILL.md b/packages/melonjs/skills/melonjs-sprites-and-animation/SKILL.md index 3dd45ce0b6..84fafb5e42 100644 --- a/packages/melonjs/skills/melonjs-sprites-and-animation/SKILL.md +++ b/packages/melonjs/skills/melonjs-sprites-and-animation/SKILL.md @@ -141,9 +141,14 @@ The insets default to a quarter of the frame. Frequently spawned sprites should be pooled: ```js -pool.register("bullet", Bullet, true); // true = recyclable -const b = pool.pull("bullet", x, y); // new Bullet(x, y), or onResetEvent(x, y) on a reused one -world.removeChild(b); // returns it to the pool automatically +import { createPool } from "melonjs"; + +const bulletPool = createPool((x, y) => { // me.pool is deprecated + const instance = new Bullet(x, y); + return { instance, reset: (x, y) => instance.onResetEvent(x, y) }; +}); +const b = bulletPool.get(x, y); // new Bullet(x, y), or reset(x, y) on a reused one +world.removeChild(b); // returns it to bulletPool automatically ``` Two consequences: diff --git a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md index ad0a06b5f3..184d51ed84 100644 --- a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md +++ b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md @@ -1,6 +1,6 @@ --- name: melonjs-tilemaps -description: "Use this skill for Tiled maps in melonJS — loading TMX/TSX levels, spawning entities from Tiled objects, collision shapes authored in Tiled, isometric and hexagonal maps, and image layers. Covers the pool.register name contract, camera bounds, compressed maps needing the inflate plugin, and the level director API. Triggers on: Tiled, TMX, TSX, tilemap, level.load, level.load async, await level.load, tileset, ImageLayer, isometric, hexagonal, staggered, pool.register, Collectable, Trigger, object layer, collision layer, parallax." +description: "Use this skill for Tiled maps in melonJS — loading TMX/TSX levels, spawning entities from Tiled objects, collision shapes authored in Tiled, isometric and hexagonal maps, and image layers. Covers registerTiledObjectClass and the pool.register name contract, camera bounds, compressed maps needing the inflate plugin, and the level director API. Triggers on: Tiled, TMX, TSX, tilemap, level.load, level.load async, await level.load, tileset, ImageLayer, isometric, hexagonal, staggered, pool.register, Collectable, Trigger, object layer, collision layer, parallax, registerTiledObjectClass, registerTiledObjectFactory." license: MIT --- @@ -72,19 +72,30 @@ Tiled **class**, then its **name**, then the structural fallbacks `"text"`, `"tile"` and `"shape"`. ```js -pool.register("mainPlayer", PlayerEntity); // ← matches Tiled class OR name +import { registerTiledObjectClass, registerTiledObjectFactory } from "melonjs"; + +registerTiledObjectClass("Enemy", Enemy); // ← matches Tiled class OR name +registerTiledObjectFactory("Spine", (settings, map) => { /* → Renderable */ }); level.load("map1"); ``` -`pool.register(className, classObj, recycling)` also registers the class as a -Tiled object factory (and again under an `me.`-prefixed alias), unless you set -`pool.autoRegisterTiled = false`. The dedicated entry points are: +These are the entry points to use. The registry is Tiled's own, held by the +object factory, and has nothing to do with object pooling. -```js -import { registerTiledObjectClass, registerTiledObjectFactory } from "melonjs"; +`pool.register(className, classObj)` **also** works and registers a Tiled +factory as a side effect, under the name and again under an `me.`-prefixed +alias, unless you set `pool.autoRegisterTiled = false`. That is the older +spelling, kept working for maps and games written against it, and it now +prints a one-off deprecation notice: `me.pool` is deprecated since 18.0.0. -registerTiledObjectClass("Enemy", Enemy); // new Enemy(x, y, settings) -registerTiledObjectFactory("Spine", (settings, map) => { /* → Renderable */ }); +Its third argument no longer buys anything here. It used to make Tiled +objects recyclable; they are constructed directly now, and the container +returns children to typed pools rather than to the name-keyed one. To pool a +class you spawn constantly, give it a `createPool` and register it for Tiled +separately: + +```js +registerTiledObjectClass("mainPlayer", PlayerEntity); ``` Register **before** loading. With no match the object falls through to the @@ -94,11 +105,24 @@ not error, it just has none of your behaviour. `registerTiledObjectClass` **throws** if you register a *different* constructor under a name already taken (re-registering the same one is a no-op). `registerTiledObjectFactory` overwrites and only `console.warn`s, so it is the -one to use for overriding a built-in such as `"shape"`. +one to use for replacing something already registered. + +**Overriding a built-in name always works.** The engine's own classes are +DEFAULTS: they fill a name nothing else has claimed, and yours takes it over +whenever you register, before or after a map has loaded. + +```js +registerTiledObjectClass("Trigger", MyTrigger); // whenever you like +``` + +The throw is for a clash between two of *your* classes under one name. For +that case `registerTiledObjectFactory` overwrites and only warns. melonJS pre-registers `Renderable`, `Sprite`, `NineSliceSprite`, `Text`, -`BitmapText`, `ImageLayer`, `ColorLayer`, `Light2d`, `Collectable` and `Trigger` -as Tiled classes, so `Collectable` and `Trigger` work out of the box. +`BitmapText`, `ImageLayer`, `ColorLayer`, `Light2d`, `Collectable`, `Trigger` +and `Entity` as Tiled classes, so `Collectable` and `Trigger` work out of the +box. Each is registered under its `me.`-prefixed alias as well, so a map +authored against melonJS 1.x finds them where it looks. A `Trigger` forwards a **fixed list** of settings to `level.load()` — not whatever you pass it. Today that list is `container`, `onLoaded`, `flatten`, diff --git a/packages/melonjs/src/camera/effects/fade_effect.ts b/packages/melonjs/src/camera/effects/fade_effect.ts index 694b179eb5..ccd114cd5e 100644 --- a/packages/melonjs/src/camera/effects/fade_effect.ts +++ b/packages/melonjs/src/camera/effects/fade_effect.ts @@ -115,9 +115,8 @@ export default class FadeEffect extends CameraEffect { // Guarded and cleared so a second destroy() is a no-op rather than a // throw. `removePostEffect()` destroys the effect it removes, so a // caller that also destroys it explicitly hits this path, and an - // unguarded re-release throws "Instance is already in pool" from the - // pool rather than from anything the caller can see. Same defect and - // same fix as `Body.destroy()` in 20.0.0. + // unguarded second pass releases a tween this effect no longer owns. + // Same defect and same fix as `Body.destroy()` in 20.0.0. if (this.tween !== undefined) { this.tween.stop(); tweenPool.release(this.tween); diff --git a/packages/melonjs/src/camera/effects/mask_effect.ts b/packages/melonjs/src/camera/effects/mask_effect.ts index a091a5d032..89b7b49717 100644 --- a/packages/melonjs/src/camera/effects/mask_effect.ts +++ b/packages/melonjs/src/camera/effects/mask_effect.ts @@ -182,8 +182,8 @@ export default class MaskEffect extends CameraEffect { } override destroy(): void { - // see FadeEffect.destroy(): guarded so a second call is a no-op instead - // of throwing "Instance is already in pool" out of the pool + // see FadeEffect.destroy(): guarded so a second call is a no-op rather + // than releasing a tween this effect no longer owns if (this.tween !== undefined) { this.tween.stop(); tweenPool.release(this.tween); diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 0a8753da9b..0cd9444c3b 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -176,7 +176,10 @@ export { AABB3d } from "./physics/broadphase/aabb3d.ts"; export { default as BuiltinAdapter } from "./physics/builtin/builtin-adapter.ts"; export { collision } from "./physics/collision.js"; export * as plugin from "./plugin/plugin.ts"; -export { getPool } from "./pool.ts"; +// `createPool` is public because the deprecation path needs it: a game moving +// off `pool.register(name, Class, true)` has to be able to build a pool for +// its own class. `getPool` only reaches the ones the engine ships. +export { createPool, getPool } from "./pool.ts"; export type { AnimationOptions, AnimationOptionsInput, @@ -192,6 +195,8 @@ export type { } from "./renderable/ui/progressbar.js"; export * as device from "./system/device.js"; export * as event from "./system/event.ts"; +// and the two types it names, so a game can declare a field holding one +export type { CreatePoolOptions, Pool } from "./system/pool.ts"; export * as utils from "./utils/utils.ts"; export * from "./version.ts"; export type { diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index c00c662051..946e19f0bb 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -15,6 +15,7 @@ import { sampleChannel } from "./gltf_sampler.js"; /** * additional import for TypeScript * @import { AnimationOptionsInput } from "../../renderable/animation.ts"; + * @import {Vector3d} from "../../math/vector3d.ts"; * @import {Bounds} from "../../physics/bounds.ts"; * @import Camera2d from "../../camera/camera2d.ts"; * @import CanvasRenderer from "../../video/canvas/canvas_renderer.js"; @@ -95,7 +96,7 @@ const _localScratch = new Array(16); */ export default class GLTFModel extends Container { /** - * @param {import("../../loader/loader.js").GLTFData} data - the parsed glTF descriptor, as returned by {@link loader.getGLTF} + * @param {import("../../loader/parsers/gltf.js").GLTFData} data - the parsed glTF descriptor, as returned by {@link loader.getGLTF} * @param {object} [options] * @param {number} [options.scale=1] - pixels per glTF unit (uniform scene scale) * @param {boolean} [options.rightHanded=true] - glTF Y-up → engine Y-down via a rotation (no mirror) diff --git a/packages/melonjs/src/level/tiled/TMXObjectFactory.js b/packages/melonjs/src/level/tiled/TMXObjectFactory.js index 9875e2e716..8d58399d86 100644 --- a/packages/melonjs/src/level/tiled/TMXObjectFactory.js +++ b/packages/melonjs/src/level/tiled/TMXObjectFactory.js @@ -28,6 +28,19 @@ const registeredClasses = new Map(); */ let factoriesInitialized = false; +/** + * Names whose current factory came from a built-in class. + * + * A built-in is a DEFAULT, so a game's own class takes the name over whenever + * it is registered, and not only before the first map load. Without this the + * outcome depended on the ordering: a `Stage` registering its classes in + * `onResetEvent`, with an earlier level already loaded, hit the + * duplicate-registration error rather than winning. + * @ignore + * @internal + */ +const builtinNames = new Set(); + /** * Return a default shape (polygon) for the given dimensions, * or the existing shapes if already defined in settings. @@ -151,27 +164,40 @@ export function registerTiledObjectFactory(type, factory) { * registerTiledObjectClass("Enemy", Enemy); * * @example - * // equivalent to pool.register (which auto-registers for Tiled too) + * // the older spelling: `pool.register` registers a Tiled factory as a side + * // effect, and additionally makes the class RECYCLABLE, which this registry + * // alone does not do * pool.register("CoinEntity", CoinEntity, true); - * // CoinEntity is now available both in the pool AND as a Tiled object factory */ export function registerTiledObjectClass(name, Constructor) { const existing = registeredClasses.get(name); + let displacesBuiltin = false; if (typeof existing !== "undefined") { if (existing === Constructor) { // same class already registered — no-op return; } - throw new Error( - "a different class is already registered for Tiled type: " + name, - ); + if (!builtinNames.has(name)) { + throw new Error( + "a different class is already registered for Tiled type: " + name, + ); + } + // only a default sat here, so the game's class takes the name over + builtinNames.delete(name); + displacesBuiltin = true; } registeredClasses.set(name, Constructor); - registerTiledObjectFactory(name, (settings) => { + const factory = (settings) => { const obj = new Constructor(settings.x, settings.y, settings); obj.pos.z = settings.z; return obj; - }); + }; + if (displacesBuiltin) { + // expected, so no "overriding Tiled object factory" warning + factories.set(name, factory); + } else { + registerTiledObjectFactory(name, factory); + } } /** @@ -181,6 +207,19 @@ export function registerTiledObjectClass(name, Constructor) { */ const pendingClasses = []; +/** + * Built-in classes queued at boot, kept apart from `pendingClasses`. + * + * They are DEFAULTS: a game that registers its own class for one of these + * names must win, and before this was separated it lost. Built-ins are queued + * at boot and flushed at the first map load, while `registerTiledObjectClass` + * writes immediately, so the built-in landed second and silently replaced the + * game's class with no error at the call and none at load. + * @ignore + * @internal + */ +const pendingBuiltins = []; + /** * Queue a class for registration as a Tiled object factory. * Registrations are applied when initFactories() runs (on first @@ -192,10 +231,31 @@ const pendingClasses = []; */ export function registerBuiltinTiledClass(name, Constructor) { if (factoriesInitialized) { - // already initialized, register immediately - registerTiledObjectClass(name, Constructor); + applyBuiltinTiledClass(name, Constructor); } else { - pendingClasses.push([name, Constructor]); + pendingBuiltins.push([name, Constructor]); + } +} + +/** + * Register one built-in class as a default, under its name and its `me.` alias. + * + * The alias is the 1.x spelling: a map authored then has objects classed + * `me.Trigger`, and the engine has always answered to both. It used to arrive + * by side effect, since `pool.register` registered a Tiled factory for the + * prefixed name as well, and the built-ins went through that call. + * @param {string} name - the Tiled class or name to match + * @param {Function} Constructor - class constructor with signature (x, y, settings) + * @ignore + * @internal + */ +function applyBuiltinTiledClass(name, Constructor) { + for (const key of [name, "me." + name]) { + // a default fills a gap, it never overwrites + if (!factories.has(key)) { + registerTiledObjectClass(key, Constructor); + builtinNames.add(key); + } } } @@ -217,7 +277,16 @@ function initFactories() { registerTiledObjectFactory("shape", createShapeObject); } - // apply pending class-based factories + // Built-ins first, and only where the game has not already claimed the + // name. Same rule as the structural factories above: a default fills a + // gap, it never overwrites. + for (const [name, Constructor] of pendingBuiltins) { + applyBuiltinTiledClass(name, Constructor); + } + pendingBuiltins.length = 0; + + // then everything registered through `pool.register`, in call order, so a + // game's registration still displaces the built-in of the same name for (const entry of pendingClasses) { const [name, Constructor, factoryFn] = entry; if (typeof factoryFn === "function") { @@ -263,20 +332,27 @@ export function createTMXObject(settings, map) { return factory(settings, map); } -// wire pool.register() to automatically register Tiled object factories -// uses pool.pull instead of new Constructor to preserve object recycling -setPoolRegisterCallback((className, classObj, poolInstance) => { +// Wire `pool.register()` to register a Tiled object factory too, so the older +// one-call idiom keeps working. +// +// It constructs directly. It used to call `pool.pull`, to keep Tiled objects +// recycled, but that stopped meaning anything once `Container` released to +// the typed pools instead of pushing here: nothing refills the legacy pool, +// so every `pull` took its construction branch anyway. The only thing it did +// that `new` does not is stamp `className` on the instance, which was read by +// `pool.push` and by nothing else. +setPoolRegisterCallback((className, Constructor) => { const factoryFn = (settings) => { - const obj = poolInstance.pull(className, settings.x, settings.y, settings); + const obj = new Constructor(settings.x, settings.y, settings); obj.pos.z = settings.z; return obj; }; if (factoriesInitialized) { - registeredClasses.set(className, classObj); + registeredClasses.set(className, Constructor); registerTiledObjectFactory(className, factoryFn); } else { // queue as a raw factory (not via registerTiledObjectClass which uses new Constructor) - pendingClasses.push([className, classObj, factoryFn]); + pendingClasses.push([className, Constructor, factoryFn]); } }); diff --git a/packages/melonjs/src/level/tiled/TMXTileMap.js b/packages/melonjs/src/level/tiled/TMXTileMap.js index b4ea0638dd..0ccc2a628d 100644 --- a/packages/melonjs/src/level/tiled/TMXTileMap.js +++ b/packages/melonjs/src/level/tiled/TMXTileMap.js @@ -2,8 +2,8 @@ import { warning } from "../../lang/console.js"; import { vector2dPool } from "../../math/vector2d.ts"; import { collision } from "../../physics/collision.js"; import Container from "../../renderable/container.js"; +import ImageLayer from "../../renderable/imagelayer.js"; import { off, on, VIEWPORT_ONRESIZE } from "../../system/event.ts"; -import pool from "../../system/legacy_pool.js"; import { checkVersion } from "../../utils/utils.ts"; import { COLLISION_GROUP, TILED_SUPPORTED_VERSION } from "./constants.js"; import { getNewTMXRenderer } from "./renderer/autodetect.js"; @@ -81,8 +81,7 @@ function readImageLayer(map, data, z) { const oy = +(data.offsety ?? data.y ?? 0) + poy * ratioY; // create the layer - const imageLayer = pool.pull( - "ImageLayer", + const imageLayer = new ImageLayer( ox, oy, Object.assign( @@ -385,7 +384,7 @@ export default class TMXTileMap { if (this.background_image) { // add a new image layer this.layers.push( - pool.pull("ImageLayer", 0, 0, { + new ImageLayer(0, 0, { name: "background_image", image: this.background_image, z: zOrder++, diff --git a/packages/melonjs/src/level/tiled/renderer/TMXObliqueRenderer.js b/packages/melonjs/src/level/tiled/renderer/TMXObliqueRenderer.js index 9712335a11..e20c402516 100644 --- a/packages/melonjs/src/level/tiled/renderer/TMXObliqueRenderer.js +++ b/packages/melonjs/src/level/tiled/renderer/TMXObliqueRenderer.js @@ -6,6 +6,7 @@ import TMXOrthogonalRenderer from "./TMXOrthogonalRenderer.js"; /** * additional import for TypeScript * @import {Bounds} from "../../../physics/bounds.ts"; + * @import TMXTileMap from "../TMXTileMap.js"; */ /** * an Oblique Map Renderer (Tiled 1.12+) diff --git a/packages/melonjs/src/loader/cache.js b/packages/melonjs/src/loader/cache.js index 2e603debf5..8e23d93ec8 100644 --- a/packages/melonjs/src/loader/cache.js +++ b/packages/melonjs/src/loader/cache.js @@ -1,3 +1,17 @@ +import { getBasename } from "../utils/file.ts"; + +/** + * additional imports for TypeScript + * + * Type-only, so they add no runtime edge and this module stays importable + * from anywhere. Without them the unions below silently degrade to `any` in + * the generated declarations, which `tsc` does not report. + * @import {CompressedImage} from "./parsers/compressed_textures/compressed_image.js"; + * @import {GLTFData} from "./parsers/gltf.js"; + * @import GLShader from "../video/webgl/glshader.js"; + * @import ShaderEffect from "../video/effects/shadereffect.js"; + */ + /** * where all preloaded content is cached */ @@ -33,3 +47,391 @@ export const gltfList = {}; // precompiled ShaderEffect (compiled at load time; video.init is an // inherent precondition of the preload flow) export const shaderList = {}; + +/** + * Which cache holds each asset type. + * + * The mapping belongs here rather than in a `switch` in `loader.js`, so that + * adding a cache is one edit in one file. Several types share a cache, which + * is the point of the indirection: `tmx` and `tsx` are the same store, as are + * `gltf` and `glb`. + */ +const cacheByType = { + binary: binList, + image: imgList, + json: jsonList, + tmx: tmxList, + tsx: tmxList, + video: videoList, + obj: objList, + gltf: gltfList, + glb: gltfList, + mtl: mtlList, + shader: shaderList, + fontface: fontList, +}; + +/** + * Every asset type that has a cache, in sweep order. + * + * Derived from the registry above rather than restated, so a new cache is + * swept by `unloadAll` the moment it is registered. The aliases are in here + * too (`tsx` beside `tmx`, `glb` beside `gltf`), which costs one empty pass + * each and keeps the list honest. + * @type {readonly string[]} + * @internal + * @ignore + */ +export const CACHED_TYPES = Object.freeze(Object.keys(cacheByType)); + +/** + * Whether an asset of this type is cached under this name. + * + * The `in` test the delete below uses, exposed on its own so a caller that + * needs the answer without removing anything does not have to list every key. + * @param {string} type - the asset type, as used in the resource descriptor + * @param {string} name - the asset name + * @returns {boolean} true if there is an entry + * @internal + * @ignore + */ +export function hasAsset(type, name) { + const cache = cacheByType[type]; + return cache !== undefined && name in cache; +} + +/** + * Delete one asset from the cache that holds its type. + * + * Named for the `delete` on {@link TextureCache}, which is the engine's other + * cache; the suffix is only there because `delete` is a reserved word and so + * cannot name a free function. + * + * The plain half of unloading: no destruction, no side effects, just the + * entry. Anything that owns a resource — a `ShaderEffect`'s GL program, a + * registered `FontFace` — is released by the caller BEFORE calling this, + * because that is policy and this module holds none. + * @param {string} type - the asset type, as used in the resource descriptor + * @param {string} name - the asset name + * @returns {boolean} true if an entry was removed, false if there was none + * @internal + * @ignore + */ +export function deleteAsset(type, name) { + const cache = cacheByType[type]; + if (cache === undefined || !(name in cache)) { + return false; + } + delete cache[name]; + return true; +} + +/** + * The names of every asset cached under a type. + * + * Returned as a snapshot array, so a caller can unload every one of them + * without mutating the object it is iterating. + * @param {string} type - the asset type + * @returns {string[]} the cached names, or an empty array for an unknown type + * @internal + * @ignore + */ +export function assetNames(type) { + const cache = cacheByType[type]; + return cache === undefined ? [] : Object.keys(cache); +} + +/* + * The read accessors for the caches above. + * + * They live beside the data they read, and that is the whole reason: a + * module holding only caches and their getters needs nothing from the rest + * of the engine, so anything may import it. `loader.js` re-exports every one + * of them, so `loader.getImage(...)` and its siblings are unchanged. + * + * `unload` / `unloadAll` deliberately stay in `loader.js`: they destroy + * assets and call into the audio parser, which is a lifecycle operation + * rather than a cache read, and importing it here would undo the above. + */ + +/** + * return the specified TMX/TSX object + * @param {string} elt - name of the tmx/tsx element ("map1"); + * @returns {object} requested element or null if not found + * @category Assets + */ +export function getTMX(elt) { + // force as string + elt = "" + elt; + if (elt in tmxList) { + return tmxList[elt]; + } + return null; +} + +/** + * return the specified Binary object + * @param {string} elt - name of the binary object ("ymTrack"); + * @returns {object} requested element or null if not found + * @category Assets + */ +export function getBinary(elt) { + // force as string + elt = "" + elt; + if (elt in binList) { + return binList[elt]; + } + return null; +} + +/** + * return the specified Image Object + * @param {string} image - name of the Image element ("tileset-platformer"); + * @returns {HTMLImageElement|CompressedImage|null} requested element or null if not found + * @category Assets + */ +export function getImage(image) { + // force as string and extract the base name + image = getBasename("" + image); + if (image in imgList) { + // return the corresponding Image object + return imgList[image]; + } + return null; +} + +/** + * return the specified JSON Object + * @param {string} elt - name of the json file + * @returns {JSON} + * @category Assets + */ +export function getJSON(elt) { + // force as string + elt = "" + elt; + if (elt in jsonList) { + return jsonList[elt]; + } + return null; +} + +/** + * return the specified OBJ model data + * @param {string} elt - name of the OBJ file (as specified in the preload list) + * @returns {object} parsed OBJ data with `vertices` (Float32Array), `uvs` (Float32Array), `indices` (Uint16Array), and `vertexCount` (number), or null if not found + * @category Assets + * @example + * // 1. preload the OBJ model and its texture + * me.loader.preload([ + * { name: "cube", type: "obj", src: "models/cube.obj" }, + * { name: "cube", type: "image", src: "models/cube_texture.png" }, + * ], () => { + * // 2. create a Mesh using the preloaded model name + * const mesh = new me.Mesh(400, 300, { + * model: "cube", // references the preloaded OBJ + * texture: "cube", // references the preloaded image + * width: 200, + * height: 200, + * }); + * me.game.world.addChild(mesh); + * + * // 3. or access the raw parsed data directly + * const data = me.loader.getOBJ("cube"); + * // data.vertices — Float32Array of x,y,z positions + * // data.uvs — Float32Array of u,v texture coordinates + * // data.indices — Uint16Array of triangle vertex indices + * // data.vertexCount — number of unique vertices + * // data.groups — usemtl material groups ({materialName, start, count} index ranges) + * }); + */ +export function getOBJ(elt) { + // force as string + elt = "" + elt; + if (elt in objList) { + return objList[elt]; + } + return null; +} + +/** + * return the parsed glTF/GLB scene descriptor for the given asset name. + * + * The descriptor is `{ nodes, cameras, lights, bounds, graph, animations }`: + * - `nodes` — one entry per mesh primitive, each carrying its accumulated + * `world` transform (16 floats, column-major), `vertices`, `normals`, + * `uvs`, `indices`, `vertexCount`, a decoded baseColor `image` (or `null`), + * and a `doubleSided` flag. + * - `cameras` — glTF cameras, each with its `world` transform + perspective + * parameters. + * - `lights` — parsed `KHR_lights_punctual` lights (`type`, `color`, + * `intensity`, `range`, spot cone angles, world-space + * `direction`/`position`, `name`); empty without the extension. The + * level director instantiates directional, point and spot lights + * automatically (see {@link level.load} options). + * - `bounds` — world-space `{ min, max }` (glTF units), handy for framing. + * + * Most code never needs this: a preloaded glTF/GLB auto-registers with the + * {@link level} director, so the whole scene loads into a container in one + * call via `me.level.load(name)` — exactly like a Tiled map. Reach for + * `getGLTF` only when you want to inspect the raw descriptor (e.g. to frame + * a `Camera3d` from the embedded camera). + * @param {string} elt - name of the glTF/GLB file (as specified in the preload list) + * @returns {GLTFData|null} the parsed scene descriptor, or `null` if not found + * @category Assets + * @example + * me.loader.preload( + * [{ name: "diorama", type: "glb", src: "scenes/diorama.glb" }], + * () => { + * // load the whole scene into the world (view under a Camera3d) + * me.level.load("diorama", { scale: 32 }); + * + * // ...or inspect the raw descriptor for custom framing + * const scene = me.loader.getGLTF("diorama"); + * const { min, max } = scene.bounds; + * }, + * ); + */ +export function getGLTF(elt) { + elt = "" + elt; + if (elt in gltfList) { + return gltfList[elt]; + } + return null; +} + +/** + * Return the precompiled `ShaderEffect` for the given "shader" asset — + * compiled once during preloading, ready to assign to a renderable or + * camera `shader` property. + * + * **This returns a SHARED instance**: the *same* `ShaderEffect` object on + * every call, owned by the loader (its `shared` flag is `true`). That means: + * - it is safe to assign to any number of renderables — none of their + * cleanup paths will auto-destroy it, only {@link loader.unload} / + * {@link loader.unloadAll} free it (and its GL program); + * - all of them share ONE set of uniform values — `setUniform` on it + * affects every renderable using the shader. + * + * When a renderable needs its **own** uniform values, make a private, + * caller-owned copy with `ShaderEffect.clone()` — the clone's `shared` flag + * is reset to `false`, so it is auto-destroyed with the renderable it is + * assigned to, like any hand-constructed effect. + * + * A shader asset declared as a **complete program** — a + * `{vertex, fragment}` GLSL pair and/or a full `wgsl` module (see the + * example) — compiles into a raw {@link GLShader} instead, carrying one + * realization per GPU backend (`isWebGL` / `isWebGPU`): the type the + * hosted paths take directly (a `Mesh` custom shader, + * `renderer.customShader`, a custom batcher). Same shared-instance + * semantics, and `GLShader.clone()` likewise yields a caller-owned copy. + * + * Degradation is never fatal: a fragment-body asset without a body in the + * active renderer's language (or on Canvas) is an inert `ShaderEffect` + * stub, and a complete-program asset without a realization for the active + * backend is an inert `GLShader` — assigning either just keeps the + * built-in rendering. Note that shader assets require an initialized + * Application (`await app.init()`) — an inherent precondition of the + * preload flow, since the loading screen itself needs the renderer. + * @param {string} elt - name of the shader asset (as specified in the preload list) + * @returns {ShaderEffect|GLShader|null} the shared, precompiled shader, or `null` if not found + * @category Assets + * @example + * me.loader.preload([ + * // from a file (or data: URI) + * { name: "waterRipple", type: "shader", src: "shaders/waterRipple.frag" }, + * // or inline GLSL via the `data` field + * { name: "flash", type: "shader", data: ` + * uniform float uIntensity; + * vec4 apply(vec4 color, vec2 uv) { return mix(color, vec4(1.0), uIntensity); } + * ` }, + * // or a complete program — a {vertex, fragment} GLSL pair and/or a + * // full WGSL module → one GLShader carrying both realizations; the + * // active renderer hosts the one it speaks + * { name: "toonMesh", type: "shader", src: { + * vertex: "shaders/toon.vert", + * fragment: "shaders/toon.frag", + * wgsl: "shaders/toon.wgsl", + * } }, + * ], () => { + * // one shared program — same uniform state for every user + * mySprite.addPostEffect(me.loader.getShader("waterRipple")); + * // private copy with its own uniforms (caller-owned, shared = false) + * boss.addPostEffect(me.loader.getShader("flash").clone()); + * // a complete program hosts on a mesh, replacing the built-in shading + * myMesh.addPostEffect(me.loader.getShader("toonMesh")); + * }); + */ +export function getShader(elt) { + elt = "" + elt; + if (elt in shaderList) { + return shaderList[elt]; + } + return null; +} + +/** + * return the specified MTL material data + * @param {string} elt - name of the MTL file (as specified in the preload list) + * @returns {object} map of material names to properties (`Kd`, `d`, `map_Kd`), or null if not found + * @category Assets + * @example + * // 1. preload OBJ + MTL + texture + * me.loader.preload([ + * { name: "fox", type: "obj", src: "models/fox.obj" }, + * { name: "fox", type: "mtl", src: "models/fox.mtl" }, + * { name: "colormap", type: "image", src: "models/colormap.png" }, + * ], () => { + * // 2. create a Mesh with material — texture, tint, opacity auto-applied + * const mesh = new me.Mesh(400, 300, { + * model: "fox", + * material: "fox", + * texture: "colormap", + * width: 200, + * height: 200, + * }); + * + * // 3. or access the raw material data directly + * const materials = me.loader.getMTL("fox"); + * // materials["colormap"].Kd — [r, g, b] diffuse color (0-1 range) + * // materials["colormap"].d — opacity (0-1) + * // materials["colormap"].Ke — [r, g, b] emissive color (glow, applied as Mesh.emissive) + * // materials["colormap"].map_Kd — resolved texture URL + * }); + */ +export function getMTL(elt) { + elt = "" + elt; + if (elt in mtlList) { + return mtlList[elt]; + } + return null; +} + +/** + * return the specified Video Object + * @param {string} elt - name of the video file + * @returns {HTMLVideoElement} + * @category Assets + */ +export function getVideo(elt) { + // force as string + elt = "" + elt; + if (elt in videoList) { + return videoList[elt]; + } + return null; +} + +/** + * return the specified FontFace Object + * @param {string} elt - name of the font file + * @returns {FontFace} + * @category Assets + */ +export function getFont(elt) { + // force as string + elt = "" + elt; + if (elt in fontList) { + return fontList[elt]; + } + return null; +} diff --git a/packages/melonjs/src/loader/loader.js b/packages/melonjs/src/loader/loader.js index 7bcd8fb336..aed207816b 100644 --- a/packages/melonjs/src/loader/loader.js +++ b/packages/melonjs/src/loader/loader.js @@ -8,18 +8,13 @@ import { LOADER_PROGRESS, on, } from "../system/event.ts"; -import { getBasename } from "../utils/file.ts"; import { - binList, - fontList, - gltfList, - imgList, - jsonList, - mtlList, - objList, - shaderList, - tmxList, - videoList, + assetNames, + CACHED_TYPES, + deleteAsset, + getFont, + getShader, + hasAsset, } from "./cache.js"; import { preloadAseprite } from "./parsers/aseprite.js"; import { preloadAudio, unloadAllAudio, unloadAudio } from "./parsers/audio.js"; @@ -35,12 +30,41 @@ import { preloadShader } from "./parsers/shader.js"; import { preloadTMX } from "./parsers/tmx.js"; import { preloadVideo } from "./parsers/video.js"; +// the cache read accessors live with the caches themselves; re-exported +// here so `loader.getImage(...)` and its siblings are unchanged +export { + getBinary, + getFont, + getGLTF, + getImage, + getJSON, + getMTL, + getOBJ, + getShader, + getTMX, + getVideo, +} from "./cache.js"; + /** * additional import for TypeScript * @import {CompressedImage} from "./parsers/compressed_textures/compressed_image.js"; * @import GLShader from "../video/webgl/glshader.js"; * @import ShaderEffect from "../video/effects/shadereffect.js"; */ + +/** + * The parsed glTF/GLB scene descriptors, re-exported from the parser that + * defines them so `loader.GLTFData` keeps naming a type. + * + * `getGLTF` is reached as `loader.getGLTF`, and the parser module is not part + * of the public entry point, so without these the return type of a public + * function was a type a consumer could not name. + * @typedef {import("./parsers/gltf.js").GLTFData} GLTFData + */ +/** + * One mesh primitive out of a parsed glTF/GLB scene. + * @typedef {import("./parsers/gltf.js").GLTFNode} GLTFNode + */ /** * a small class to manage loading of stuff and manage resources * @namespace loader @@ -701,124 +725,61 @@ export function load(asset, onload, onerror) { */ export function unload(asset) { switch (asset.type) { - case "binary": - if (!(asset.name in binList)) { - return false; - } - - delete binList[asset.name]; - return true; - - case "image": - if (!(asset.name in imgList)) { - return false; - } - delete imgList[asset.name]; - return true; - - case "json": - if (!(asset.name in jsonList)) { - return false; - } - - delete jsonList[asset.name]; - return true; - case "js": - // ?? + // nothing is cached for a script: it ran at load time return true; - case "fontface": - // membership guard like every other type — FontFaceSet.delete is - // WebIDL-typed and THROWS on undefined instead of returning false - if (!(asset.name in fontList)) { + case "audio": + return unloadAudio(asset.name); + + case "fontface": { + // the FontFace has to leave the document's own set as well, and + // the membership guard comes first because `FontFaceSet.delete` + // is silent about a face it does not have + if (!hasAsset("fontface", asset.name)) { return false; } if ( typeof globalThis.document !== "undefined" && typeof globalThis.document.fonts !== "undefined" ) { - globalThis.document.fonts.delete(fontList[asset.name]); - delete fontList[asset.name]; - return true; + globalThis.document.fonts.delete(getFont(asset.name)); + return deleteAsset("fontface", asset.name); } return false; - - case "tmx": - case "tsx": - if (!(asset.name in tmxList)) { - return false; - } - - delete tmxList[asset.name]; - return true; - - case "audio": - return unloadAudio(asset.name); - - case "video": - if (!(asset.name in videoList)) { - return false; - } - - delete videoList[asset.name]; - return true; - - case "obj": - if (!(asset.name in objList)) { - return false; - } - - delete objList[asset.name]; - return true; - - case "gltf": - case "glb": - if (!(asset.name in gltfList)) { - return false; - } - - delete gltfList[asset.name]; - return true; - - case "mtl": - if (!(asset.name in mtlList)) { - return false; - } - - delete mtlList[asset.name]; - return true; - - case "aseprite": { - // aseprite asset populates both imgList and jsonList under the same key - const hadImage = asset.name in imgList; - const hadJson = asset.name in jsonList; - if (!hadImage && !hadJson) { - return false; - } - delete imgList[asset.name]; - delete jsonList[asset.name]; - return true; } case "shader": { - const effect = shaderList[asset.name]; - if (typeof effect === "undefined") { - return false; - } + // the `false` for a shader that was never loaded comes from + // `deleteAsset` below; `getShader` returns null for a missing key, + // so there is nothing to guard against here + const effect = getShader(asset.name); // the loader owns the shared ShaderEffect/GLShader — actually free // the GL program, don't just drop the cache entry. A `null` entry // is a {vertex, fragment} pair loaded under the Canvas renderer // (no GL program to free). effect?.destroy(); - delete shaderList[asset.name]; - return true; + return deleteAsset("shader", asset.name); + } + + case "aseprite": { + // an aseprite asset populates both the image and the json cache + // under the same key, and either one alone counts as present + const hadImage = deleteAsset("image", asset.name); + const hadJson = deleteAsset("json", asset.name); + return hadImage || hadJson; } default: - throw new Error( - "unload : unknown or invalid resource type : " + asset.type, - ); + // every other type is one entry in one cache. An unrecognised type + // throws, as it always has: `load` throws for one too, and a typo + // that is loud going in should not be silent coming out + if (!CACHED_TYPES.includes(asset.type)) { + throw new Error( + "unload : unknown or invalid resource type : " + asset.type, + ); + } + return deleteAsset(asset.type, asset.name); } } @@ -828,426 +789,15 @@ export function unload(asset) { * @category Assets */ export function unloadAll() { - let name; - - // unload all binary resources - for (name in binList) { - if (binList.hasOwnProperty(name)) { - unload({ - name: name, - type: "binary", - }); + // Driven off the cache registry itself, so a new asset type is swept by + // registering its cache and nothing else. `tsx` and `glb` alias caches + // their siblings already cleared, which costs one empty pass each. + for (const type of CACHED_TYPES) { + for (const name of assetNames(type)) { + unload({ name, type }); } } - // unload all image resources - for (name in imgList) { - if (imgList.hasOwnProperty(name)) { - unload({ - name: name, - type: "image", - }); - } - } - - // unload all tmx resources - for (name in tmxList) { - if (tmxList.hasOwnProperty(name)) { - unload({ - name: name, - type: "tmx", - }); - } - } - - // unload all json resources - for (name in jsonList) { - if (jsonList.hasOwnProperty(name)) { - unload({ - name: name, - type: "json", - }); - } - } - - // unload all video resources - for (name in videoList) { - if (videoList.hasOwnProperty(name)) { - unload({ - name: name, - type: "video", - }); - } - } - - // unload all font resources - for (name in fontList) { - if (fontList.hasOwnProperty(name)) { - unload({ - name: name, - type: "fontface", - }); - } - } - - // unload all OBJ resources - for (name in objList) { - if (objList.hasOwnProperty(name)) { - unload({ - name: name, - type: "obj", - }); - } - } - - // unload all MTL resources - for (name in mtlList) { - if (mtlList.hasOwnProperty(name)) { - unload({ - name: name, - type: "mtl", - }); - } - } - - // unload all glTF/GLB scene resources - for (name in gltfList) { - if (gltfList.hasOwnProperty(name)) { - unload({ - name: name, - type: "glb", - }); - } - } - - // unload all shader resources (destroys their shared GL programs) - for (name in shaderList) { - if (shaderList.hasOwnProperty(name)) { - unload({ - name: name, - type: "shader", - }); - } - } - - // unload all audio resources + // and the audio, which keeps its own store unloadAllAudio(); } - -/** - * return the specified TMX/TSX object - * @param {string} elt - name of the tmx/tsx element ("map1"); - * @returns {object} requested element or null if not found - * @category Assets - */ -export function getTMX(elt) { - // force as string - elt = "" + elt; - if (elt in tmxList) { - return tmxList[elt]; - } - return null; -} - -/** - * return the specified Binary object - * @param {string} elt - name of the binary object ("ymTrack"); - * @returns {object} requested element or null if not found - * @category Assets - */ -export function getBinary(elt) { - // force as string - elt = "" + elt; - if (elt in binList) { - return binList[elt]; - } - return null; -} - -/** - * return the specified Image Object - * @param {string} image - name of the Image element ("tileset-platformer"); - * @returns {HTMLImageElement|CompressedImage|null} requested element or null if not found - * @category Assets - */ -export function getImage(image) { - // force as string and extract the base name - image = getBasename("" + image); - if (image in imgList) { - // return the corresponding Image object - return imgList[image]; - } - return null; -} - -/** - * return the specified JSON Object - * @param {string} elt - name of the json file - * @returns {JSON} - * @category Assets - */ -export function getJSON(elt) { - // force as string - elt = "" + elt; - if (elt in jsonList) { - return jsonList[elt]; - } - return null; -} - -/** - * return the specified OBJ model data - * @param {string} elt - name of the OBJ file (as specified in the preload list) - * @returns {object} parsed OBJ data with `vertices` (Float32Array), `uvs` (Float32Array), `indices` (Uint16Array), and `vertexCount` (number), or null if not found - * @category Assets - * @example - * // 1. preload the OBJ model and its texture - * me.loader.preload([ - * { name: "cube", type: "obj", src: "models/cube.obj" }, - * { name: "cube", type: "image", src: "models/cube_texture.png" }, - * ], () => { - * // 2. create a Mesh using the preloaded model name - * const mesh = new me.Mesh(400, 300, { - * model: "cube", // references the preloaded OBJ - * texture: "cube", // references the preloaded image - * width: 200, - * height: 200, - * }); - * me.game.world.addChild(mesh); - * - * // 3. or access the raw parsed data directly - * const data = me.loader.getOBJ("cube"); - * // data.vertices — Float32Array of x,y,z positions - * // data.uvs — Float32Array of u,v texture coordinates - * // data.indices — Uint16Array of triangle vertex indices - * // data.vertexCount — number of unique vertices - * // data.groups — usemtl material groups ({materialName, start, count} index ranges) - * }); - */ -export function getOBJ(elt) { - // force as string - elt = "" + elt; - if (elt in objList) { - return objList[elt]; - } - return null; -} - -/** - * One mesh primitive out of a parsed glTF/GLB scene. - * - * Spelled out rather than left as `object` so the geometry can be read from - * TypeScript — feeding `vertices`/`uvs`/`normals`/`indices` straight into a - * {@link Mesh} or {@link InstancedMesh} is the whole point of exposing it. - * @typedef {object} GLTFNode - * @property {number[]} world - accumulated world transform, 16 floats, column-major - * @property {Float32Array} vertices - positions, x,y,z triplets - * @property {Float32Array} normals - per-vertex normals - * @property {Float32Array} uvs - texture coordinates, u,v pairs - * @property {Uint16Array|Uint32Array} indices - triangle vertex indices - * @property {number} vertexCount - number of vertices - * @property {HTMLImageElement|null} image - decoded baseColor texture, or `null` - * @property {number[]} [baseColorFactor] - material baseColor factor, `[r, g, b, a]` - * @property {Uint32Array} [colors] - per-vertex colour, packed RGBA8 - * @property {string} [textureRepeat] - wrap mode derived from the glTF sampler - * @property {string} [textureFilter] - magnification filter derived from the glTF sampler - * @property {number} [alphaCutoff] - cutout threshold from `alphaMode: "MASK"` - * @property {number[]} [emissive] - emissive factor, `[r, g, b]` - * @property {boolean} [unlit] - the material carried `KHR_materials_unlit` - * @property {boolean} [doubleSided] - the material is double-sided - * @property {string} [name] - the source node's name - */ - -/** - * a parsed glTF/GLB scene descriptor, as returned by {@link loader.getGLTF} - * @typedef {object} GLTFData - * @property {GLTFNode[]} nodes - one entry per mesh primitive - * @property {Array<{world: number[], type?: string, perspective?: {yfov?: number, aspectRatio?: number, znear?: number, zfar?: number}, orthographic?: object}>} cameras - glTF cameras, each with its `world` transform + the glTF camera parameters (`perspective` for perspective cameras, `orthographic` otherwise) - * @property {object[]} lights - parsed `KHR_lights_punctual` lights (`type`, `color`, `intensity`, `range`, `innerConeAngle`/`outerConeAngle` for spots, world-space `direction`/`position`, `name`) - * @property {{min: number[], max: number[]}} bounds - world-space scene bounds in glTF units - * @property {object[]} graph - the full node graph (every node's TRS/matrix + children), for custom traversal - * @property {object[]} animations - parsed node animations (consumed by `GLTFModel` playback) - */ - -/** - * return the parsed glTF/GLB scene descriptor for the given asset name. - * - * The descriptor is `{ nodes, cameras, lights, bounds, graph, animations }`: - * - `nodes` — one entry per mesh primitive, each carrying its accumulated - * `world` transform (16 floats, column-major), `vertices`, `normals`, - * `uvs`, `indices`, `vertexCount`, a decoded baseColor `image` (or `null`), - * and a `doubleSided` flag. - * - `cameras` — glTF cameras, each with its `world` transform + perspective - * parameters. - * - `lights` — parsed `KHR_lights_punctual` lights (`type`, `color`, - * `intensity`, `range`, spot cone angles, world-space - * `direction`/`position`, `name`); empty without the extension. The - * level director instantiates directional, point and spot lights - * automatically (see {@link level.load} options). - * - `bounds` — world-space `{ min, max }` (glTF units), handy for framing. - * - * Most code never needs this: a preloaded glTF/GLB auto-registers with the - * {@link level} director, so the whole scene loads into a container in one - * call via `me.level.load(name)` — exactly like a Tiled map. Reach for - * `getGLTF` only when you want to inspect the raw descriptor (e.g. to frame - * a `Camera3d` from the embedded camera). - * @param {string} elt - name of the glTF/GLB file (as specified in the preload list) - * @returns {GLTFData|null} the parsed scene descriptor, or `null` if not found - * @category Assets - * @example - * me.loader.preload( - * [{ name: "diorama", type: "glb", src: "scenes/diorama.glb" }], - * () => { - * // load the whole scene into the world (view under a Camera3d) - * me.level.load("diorama", { scale: 32 }); - * - * // ...or inspect the raw descriptor for custom framing - * const scene = me.loader.getGLTF("diorama"); - * const { min, max } = scene.bounds; - * }, - * ); - */ -export function getGLTF(elt) { - elt = "" + elt; - if (elt in gltfList) { - return gltfList[elt]; - } - return null; -} - -/** - * Return the precompiled `ShaderEffect` for the given "shader" asset — - * compiled once during preloading, ready to assign to a renderable or - * camera `shader` property. - * - * **This returns a SHARED instance**: the *same* `ShaderEffect` object on - * every call, owned by the loader (its `shared` flag is `true`). That means: - * - it is safe to assign to any number of renderables — none of their - * cleanup paths will auto-destroy it, only {@link loader.unload} / - * {@link loader.unloadAll} free it (and its GL program); - * - all of them share ONE set of uniform values — `setUniform` on it - * affects every renderable using the shader. - * - * When a renderable needs its **own** uniform values, make a private, - * caller-owned copy with `ShaderEffect.clone()` — the clone's `shared` flag - * is reset to `false`, so it is auto-destroyed with the renderable it is - * assigned to, like any hand-constructed effect. - * - * A shader asset declared as a **complete program** — a - * `{vertex, fragment}` GLSL pair and/or a full `wgsl` module (see the - * example) — compiles into a raw {@link GLShader} instead, carrying one - * realization per GPU backend (`isWebGL` / `isWebGPU`): the type the - * hosted paths take directly (a `Mesh` custom shader, - * `renderer.customShader`, a custom batcher). Same shared-instance - * semantics, and `GLShader.clone()` likewise yields a caller-owned copy. - * - * Degradation is never fatal: a fragment-body asset without a body in the - * active renderer's language (or on Canvas) is an inert `ShaderEffect` - * stub, and a complete-program asset without a realization for the active - * backend is an inert `GLShader` — assigning either just keeps the - * built-in rendering. Note that shader assets require an initialized - * Application (`await app.init()`) — an inherent precondition of the - * preload flow, since the loading screen itself needs the renderer. - * @param {string} elt - name of the shader asset (as specified in the preload list) - * @returns {ShaderEffect|GLShader|null} the shared, precompiled shader, or `null` if not found - * @category Assets - * @example - * me.loader.preload([ - * // from a file (or data: URI) - * { name: "waterRipple", type: "shader", src: "shaders/waterRipple.frag" }, - * // or inline GLSL via the `data` field - * { name: "flash", type: "shader", data: ` - * uniform float uIntensity; - * vec4 apply(vec4 color, vec2 uv) { return mix(color, vec4(1.0), uIntensity); } - * ` }, - * // or a complete program — a {vertex, fragment} GLSL pair and/or a - * // full WGSL module → one GLShader carrying both realizations; the - * // active renderer hosts the one it speaks - * { name: "toonMesh", type: "shader", src: { - * vertex: "shaders/toon.vert", - * fragment: "shaders/toon.frag", - * wgsl: "shaders/toon.wgsl", - * } }, - * ], () => { - * // one shared program — same uniform state for every user - * mySprite.addPostEffect(me.loader.getShader("waterRipple")); - * // private copy with its own uniforms (caller-owned, shared = false) - * boss.addPostEffect(me.loader.getShader("flash").clone()); - * // a complete program hosts on a mesh, replacing the built-in shading - * myMesh.addPostEffect(me.loader.getShader("toonMesh")); - * }); - */ -export function getShader(elt) { - elt = "" + elt; - if (elt in shaderList) { - return shaderList[elt]; - } - return null; -} - -/** - * return the specified MTL material data - * @param {string} elt - name of the MTL file (as specified in the preload list) - * @returns {object} map of material names to properties (`Kd`, `d`, `map_Kd`), or null if not found - * @category Assets - * @example - * // 1. preload OBJ + MTL + texture - * me.loader.preload([ - * { name: "fox", type: "obj", src: "models/fox.obj" }, - * { name: "fox", type: "mtl", src: "models/fox.mtl" }, - * { name: "colormap", type: "image", src: "models/colormap.png" }, - * ], () => { - * // 2. create a Mesh with material — texture, tint, opacity auto-applied - * const mesh = new me.Mesh(400, 300, { - * model: "fox", - * material: "fox", - * texture: "colormap", - * width: 200, - * height: 200, - * }); - * - * // 3. or access the raw material data directly - * const materials = me.loader.getMTL("fox"); - * // materials["colormap"].Kd — [r, g, b] diffuse color (0-1 range) - * // materials["colormap"].d — opacity (0-1) - * // materials["colormap"].Ke — [r, g, b] emissive color (glow, applied as Mesh.emissive) - * // materials["colormap"].map_Kd — resolved texture URL - * }); - */ -export function getMTL(elt) { - elt = "" + elt; - if (elt in mtlList) { - return mtlList[elt]; - } - return null; -} - -/** - * return the specified Video Object - * @param {string} elt - name of the video file - * @returns {HTMLVideoElement} - * @category Assets - */ -export function getVideo(elt) { - // force as string - elt = "" + elt; - if (elt in videoList) { - return videoList[elt]; - } - return null; -} - -/** - * return the specified FontFace Object - * @param {string} elt - name of the font file - * @returns {FontFace} - * @category Assets - */ -export function getFont(elt) { - // force as string - elt = "" + elt; - if (elt in fontList) { - return fontList[elt]; - } - return null; -} diff --git a/packages/melonjs/src/loader/parsers/gltf.js b/packages/melonjs/src/loader/parsers/gltf.js index 3dd16a04ae..129ccd8132 100644 --- a/packages/melonjs/src/loader/parsers/gltf.js +++ b/packages/melonjs/src/loader/parsers/gltf.js @@ -1095,6 +1095,42 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { }; } +/** + * One mesh primitive out of a parsed glTF/GLB scene. + * + * Spelled out rather than left as `object` so the geometry can be read from + * TypeScript — feeding `vertices`/`uvs`/`normals`/`indices` straight into a + * {@link Mesh} or {@link InstancedMesh} is the whole point of exposing it. + * @typedef {object} GLTFNode + * @property {number[]} world - accumulated world transform, 16 floats, column-major + * @property {Float32Array} vertices - positions, x,y,z triplets + * @property {Float32Array} normals - per-vertex normals + * @property {Float32Array} uvs - texture coordinates, u,v pairs + * @property {Uint16Array|Uint32Array} indices - triangle vertex indices + * @property {number} vertexCount - number of vertices + * @property {HTMLImageElement|null} image - decoded baseColor texture, or `null` + * @property {number[]} [baseColorFactor] - material baseColor factor, `[r, g, b, a]` + * @property {Uint32Array} [colors] - per-vertex colour, packed RGBA8 + * @property {string} [textureRepeat] - wrap mode derived from the glTF sampler + * @property {string} [textureFilter] - magnification filter derived from the glTF sampler + * @property {number} [alphaCutoff] - cutout threshold from `alphaMode: "MASK"` + * @property {number[]} [emissive] - emissive factor, `[r, g, b]` + * @property {boolean} [unlit] - the material carried `KHR_materials_unlit` + * @property {boolean} [doubleSided] - the material is double-sided + * @property {string} [name] - the source node's name + */ + +/** + * a parsed glTF/GLB scene descriptor, as returned by {@link loader.getGLTF} + * @typedef {object} GLTFData + * @property {GLTFNode[]} nodes - one entry per mesh primitive + * @property {Array<{world: number[], type?: string, perspective?: {yfov?: number, aspectRatio?: number, znear?: number, zfar?: number}, orthographic?: object}>} cameras - glTF cameras, each with its `world` transform + the glTF camera parameters (`perspective` for perspective cameras, `orthographic` otherwise) + * @property {object[]} lights - parsed `KHR_lights_punctual` lights (`type`, `color`, `intensity`, `range`, `innerConeAngle`/`outerConeAngle` for spots, world-space `direction`/`position`, `name`) + * @property {{min: number[], max: number[]}} bounds - world-space scene bounds in glTF units + * @property {object[]} graph - the full node graph (every node's TRS/matrix + children), for custom traversal + * @property {object[]} animations - parsed node animations (consumed by `GLTFModel` playback) + */ + /** * parse/preload a glTF/GLB file * @param {loader.Asset} data - asset data diff --git a/packages/melonjs/src/particles/emitter.ts b/packages/melonjs/src/particles/emitter.ts index 39fb59af40..ab2a3cb508 100644 --- a/packages/melonjs/src/particles/emitter.ts +++ b/packages/melonjs/src/particles/emitter.ts @@ -6,7 +6,7 @@ import timer from "../system/timer.ts"; import type CanvasRenderer from "../video/canvas/canvas_renderer.js"; import CanvasRenderTarget from "../video/rendertarget/canvasrendertarget.js"; import type WebGLRenderer from "../video/webgl/webgl_renderer.js"; -import Particle, { particlePool } from "./particle.ts"; +import { particlePool } from "./particle.ts"; import defaultEmitterSettings, { type ParticleEmitterSettings, } from "./settings.ts"; @@ -757,36 +757,6 @@ export default class ParticleEmitter extends Container { return this.isDirty; } - /** - * Hand the live particles back to `particlePool` instead of letting the - * generic child teardown dispose of them. - * - * A {@link Particle} is not registered with the legacy `pool.register` - * registry, so the inherited `removeChildNow(child)` finds it unrecyclable - * and calls `destroy()` on it — which releases its `pos` and leaves the - * instance permanently out of `particlePool`, counted as in use and never - * handed out again. An emitter torn down mid-burst (a level change, a - * game over, a `world.reset()`) leaks its whole cloud that way. - * - * `keepalive=true` plus an explicit `release` is the same pairing a - * particle uses when it dies of old age; see {@link Particle#update}. - * @ignore - * @internal - */ - override clearChildren(): void { - const children = this.getChildren(); - for (let i = children.length; i-- > 0; ) { - const particle = children[i]; - if (particle instanceof Particle && !particle.isPersistent) { - this.removeChildNow(particle, true); - particlePool.release(particle); - } - } - // anything the loop above left, and the pending sort this container - // may still owe - super.clearChildren(); - } - /** * Destroy function * @ignore diff --git a/packages/melonjs/src/physics/broadphase/octree.ts b/packages/melonjs/src/physics/broadphase/octree.ts index 17dc2daa47..cab6d6a932 100644 --- a/packages/melonjs/src/physics/broadphase/octree.ts +++ b/packages/melonjs/src/physics/broadphase/octree.ts @@ -1038,24 +1038,22 @@ export default class Octree implements Broadphase { * — captures only the fields the walk inspects. Same approach as * QuadTree. `getChildren` is optional because leaf renderables don't * have it; the recursive walk narrows via {@link hasGetChildren}. - * @ignore - * @internal */ -interface ContainerOrChild extends OctreeItem { +export interface ContainerOrChild extends OctreeItem { addChild?: (...args: unknown[]) => unknown; getChildren?: () => ContainerOrChild[]; } /** - * @ignore - * @internal + * Structural shape used as the `insertContainer` / `removeContainer` + * parameter type. `Container` itself satisfies it. */ -type ContainerLike = { getChildren(): ContainerOrChild[] }; +export type ContainerLike = { getChildren(): ContainerOrChild[] }; /** - * @ignore - * @internal + * The same shape with `getChildren` optional, for a container whose lazy + * `children` accessor may not have produced one yet. */ -type ContainerLikeOptional = { getChildren?(): ContainerOrChild[] }; +export type ContainerLikeOptional = { getChildren?(): ContainerOrChild[] }; /** * Type predicate mirror of QuadTree's. Narrows a `ContainerOrChild` diff --git a/packages/melonjs/src/physics/broadphase/quadtree.ts b/packages/melonjs/src/physics/broadphase/quadtree.ts index 53b93111a5..de270f2cc0 100644 --- a/packages/melonjs/src/physics/broadphase/quadtree.ts +++ b/packages/melonjs/src/physics/broadphase/quadtree.ts @@ -620,10 +620,8 @@ export default class QuadTree implements Broadphase { * * `getChildren` is optional because leaf renderables don't have it; * the recursive `insertContainer` narrows via {@link hasGetChildren}. - * @ignore - * @internal */ -interface ContainerOrChild extends QuadTreeItem { +export interface ContainerOrChild extends QuadTreeItem { addChild?: (...args: unknown[]) => unknown; getChildren?: () => ContainerOrChild[]; } @@ -635,15 +633,13 @@ interface ContainerOrChild extends QuadTreeItem { * documentation at every call site. `Container` itself satisfies this * shape; the optional variant covers the case where the World's lazy * `children` accessor returns `undefined`. - * @ignore - * @internal */ -type ContainerLike = { getChildren(): ContainerOrChild[] }; +export type ContainerLike = { getChildren(): ContainerOrChild[] }; /** - * @ignore - * @internal + * The same shape with `getChildren` optional, for a container whose lazy + * `children` accessor may not have produced one yet. */ -type ContainerLikeOptional = { getChildren?(): ContainerOrChild[] }; +export type ContainerLikeOptional = { getChildren?(): ContainerOrChild[] }; /** * Type predicate: narrows a `ContainerOrChild` to one whose diff --git a/packages/melonjs/src/physics/builtin/body.js b/packages/melonjs/src/physics/builtin/body.js index f2afa23acd..3c6392edf0 100644 --- a/packages/melonjs/src/physics/builtin/body.js +++ b/packages/melonjs/src/physics/builtin/body.js @@ -92,8 +92,12 @@ export default class Body { /** * The body collision mask, that defines what should collide with what.
* (by default will collide with all entities) - * @ignore - * @internal + * + * Public, and not `@internal`: it is part of the portable + * {@link PhysicsBody} contract every adapter implements, this class's + * own examples assign to it, and stripping it made `Body` fail to + * satisfy `PhysicsBody` in the published declarations. + * @public * @type {number} * @default collision.types.ALL_OBJECT * @see collision.types diff --git a/packages/melonjs/src/pool.ts b/packages/melonjs/src/pool.ts index 29a25d7419..b5882c7606 100644 --- a/packages/melonjs/src/pool.ts +++ b/packages/melonjs/src/pool.ts @@ -12,7 +12,11 @@ import { vector3dPool } from "./math/vector3d"; import type ParticleEmitter from "./particles/emitter"; import type Particle from "./particles/particle"; import { boundsPool } from "./physics/bounds"; +// `textPool` / `colorLayerPool` register themselves in their own modules, +// the same way `particlePool` does, so these are types only +import type { colorLayerPool } from "./renderable/colorlayer.js"; import { bitmapTextDataPool } from "./renderable/text/bitmaptextdata"; +import type { textPool } from "./renderable/text/text.js"; import type { Pool } from "./system/pool"; import { getRegisteredPools, registerPool } from "./system/pool"; import { tweenPool } from "./tweens/tween"; @@ -50,6 +54,8 @@ interface PoolMap { roundedRectangle: typeof roundedRectanglePool; ellipse: typeof ellipsePool; tween: typeof tweenPool; + text: typeof textPool; + colorLayer: typeof colorLayerPool; particle: Pool; bitmapTextData: typeof bitmapTextDataPool; } diff --git a/packages/melonjs/src/renderable/colorlayer.js b/packages/melonjs/src/renderable/colorlayer.js index 7c504577c4..e016ecf6a6 100644 --- a/packages/melonjs/src/renderable/colorlayer.js +++ b/packages/melonjs/src/renderable/colorlayer.js @@ -1,5 +1,6 @@ import { colorPool } from "../math/color.ts"; -import Renderable from "./renderable.js"; +import { createPool, registerPool } from "../system/pool.ts"; +import Renderable, { resetRenderableState } from "./renderable.js"; /** * additional import for TypeScript @@ -34,6 +35,11 @@ export default class ColorLayer extends Renderable { } onResetEvent(name, color, z = 0) { + // the inherited state first: a recycled layer carried the previous + // one's alpha and blend mode, so a flash faded out came back faded + if (typeof this.currentTransform !== "undefined") { + resetRenderableState(this); + } // apply given parameters this.name = name; this.pos.z = z; @@ -65,3 +71,27 @@ export default class ColorLayer extends Renderable { super.destroy(); } } + +/** + * A pool of reusable {@link ColorLayer} instances. + * + * Reachable as `getPool("colorLayer")`. `release` it yourself when the layer + * is finished, or let a container do it: a layer this pool built carries the + * pool it came from, so removing it from a container releases it back. Pass + * `keepalive` to `removeChild()` to keep holding one. + * @example + * const flash = getPool("colorLayer").get("flash", "#ffffff", 10); + * // ... later + * getPool("colorLayer").release(flash); + */ +export const colorLayerPool = createPool((name, color, z) => { + const instance = new ColorLayer(name, color, z); + return { + instance, + reset(name, color, z) { + instance.onResetEvent(name, color, z); + }, + }; +}); + +registerPool("colorLayer", colorLayerPool); diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index f01c89dd3f..f0b6b5b6a9 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -6,7 +6,7 @@ import { } from "../physics/physicseditor.js"; import state from "../state/state.ts"; import { CANVAS_ONRESIZE, off, on } from "../system/event.ts"; -import pool from "../system/legacy_pool.js"; +import { releaseToOwningPool } from "../system/pool.ts"; import { defer } from "../utils/function"; import { createGUID } from "../utils/utils"; import Renderable from "./renderable.js"; @@ -974,9 +974,13 @@ export default class Container extends Renderable { } if (!keepalive) { - // attempt at recycling the object - if (pool.push(child, false) === false) { - // else just destroy it + // Back to whichever pool built it, if any. A container holds a + // child and has no idea what it is, so the object has to carry + // the answer: `createPool` stamps the owning pool on everything + // it builds. The legacy pool answered the same question with a + // `className` string and a global registry. + if (!releaseToOwningPool(child)) { + // nothing owns it, so it is ours to destroy if (typeof child.destroy === "function") { child.destroy(); } diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 7211a09c4b..bdbc8c3560 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -38,7 +38,7 @@ let _warnedLitUnder2dOnce = false; * @import CanvasRenderer from "./../video/canvas/canvas_renderer.js"; * @import WebGLRenderer from "./../video/webgl/webgl_renderer.js"; * @import Camera2d from "../camera/camera2d.ts"; - * @import Light3d from "../lighting/light3d.ts"; + * @import {Light3d} from "../lighting/light3d.ts"; * @import GLShader from "../video/webgl/glshader.js"; */ diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index b877e9c043..dbc45f6005 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -1528,3 +1528,50 @@ export default class Renderable extends Rect { // to be extended ! } } + +/** + * Restore the mutable `Renderable` state a recycled instance must not inherit. + * + * A pool hands the SAME object back, so everything the previous user wrote + * stays written. `onResetEvent` applies the new settings and nothing else, and + * the fields below are the ones a game routinely drives: a damage number + * tweened to `alpha` 0 came back invisible, a popup scaled up came back large, + * a flash faded out came back faded. Named here rather than in each pooled + * class so the list has one home. + * + * Owned resources follow `destroy()`: the mask is dropped rather than recycled, + * since it belongs to the game, and post effects are destroyed unless they are + * `shared`. A body is left alone: a pooled label is not expected to carry one, + * and tearing one down from a reset would be a surprise. + * @param {Renderable} renderable - the instance being handed out again + * @ignore + * @internal + */ +export function resetRenderableState(renderable) { + renderable.setOpacity(1.0); + renderable.tint.setColor(255, 255, 255, 1.0); + renderable.blendMode = "normal"; + renderable.name = ""; + renderable.floating = false; + renderable.isRenderable = true; + renderable.isKinematic = true; + renderable.visibleInAllCameras = false; + renderable.alwaysUpdate = false; + renderable.updateWhenPaused = false; + renderable.isPersistent = false; + renderable.autoTransform = true; + renderable.applyAnchorTransform = true; + renderable.postEffectNeedsCapture = false; + renderable.anchorPoint.set(0.5, 0.5); + renderable.currentTransform.identity(); + renderable._flip.x = false; + renderable._flip.y = false; + renderable.mask = undefined; + for (const effect of renderable.postEffects) { + if (typeof effect.destroy === "function" && !effect.shared) { + effect.destroy(); + } + } + renderable.postEffects.length = 0; + renderable.isDirty = true; +} diff --git a/packages/melonjs/src/renderable/sprite.js b/packages/melonjs/src/renderable/sprite.js index bff968a229..3f3dc50a43 100644 --- a/packages/melonjs/src/renderable/sprite.js +++ b/packages/melonjs/src/renderable/sprite.js @@ -1,9 +1,13 @@ import { game } from "../application/application.ts"; -import { getImage } from "./../loader/loader.js"; +// `cache.js`, not `loader.js`: the accessor lives with its data, and this +// file must not reach the whole loader. `loader.js` pulls in the glTF parser +// and through it `level.js` and `TMXTileMap.js`, which imports `ImageLayer`, +// which `extends Sprite` — and reading `Sprite` while THIS file is still +// loading is a `Cannot access 'Sprite' before initialization` at startup. +import { getImage } from "./../loader/cache.js"; import { Color } from "../math/color.ts"; import { vector2dPool } from "../math/vector2d.ts"; import { on, STATE_PAUSE } from "../system/event.ts"; -import { TextureAtlas } from "./../video/texture/atlas.js"; import Texture2d from "./../video/texture/texture2d.ts"; import { resolveAnchorPoint } from "./anchorPoint.ts"; import FrameAnimation from "./frameAnimation.js"; @@ -14,6 +18,7 @@ const FLICKER_INTERVAL_MS = 33; /** * additional import for TypeScript + * @import {TextureAtlas} from "./../video/texture/atlas.js"; * @import {Vector2d} from "../math/vector2d.ts"; * @import Renderer from "./../video/renderer.js"; * @import {CompressedImage} from "../loader/parsers/compressed_textures/compressed_image.js"; @@ -196,7 +201,7 @@ export default class Sprite extends Renderable { }; // set the proper image/texture to use - if (settings.image instanceof TextureAtlas) { + if (settings.image instanceof Texture2d && settings.image.isAtlas) { this.source = settings.image; this.image = this.source.getTexture(); this.textureAtlas = settings.image; @@ -334,7 +339,8 @@ export default class Sprite extends Renderable { // When the source is a TextureAtlas, prefer its paired normal-map // over an explicit `settings.normalMap` (the atlas drove the layout). if ( - settings.image instanceof TextureAtlas && + settings.image instanceof Texture2d && + settings.image.isAtlas && typeof settings.image.getNormalTexture === "function" ) { const fromAtlas = settings.image.getNormalTexture(); diff --git a/packages/melonjs/src/renderable/text/bitmaptext.js b/packages/melonjs/src/renderable/text/bitmaptext.js index 460b697b02..a10d1b0728 100644 --- a/packages/melonjs/src/renderable/text/bitmaptext.js +++ b/packages/melonjs/src/renderable/text/bitmaptext.js @@ -365,7 +365,7 @@ export default class BitmapText extends Renderable { * change the font display size * @param {number} scale - a ratio against the font's authored size, NOT a * pixel size: `1` is the page image at its native scale, `2` is double - * @returns {BitmapText} this object for chaining + * @returns {this} this object for chaining * @example * // a bitmap font is pixel art — whole-number ratios stay crisp, and * // fractional ones resample the page image diff --git a/packages/melonjs/src/renderable/text/text.js b/packages/melonjs/src/renderable/text/text.js index fdb82e69fb..b508a5a6e4 100644 --- a/packages/melonjs/src/renderable/text/text.js +++ b/packages/melonjs/src/renderable/text/text.js @@ -1,10 +1,11 @@ import { game } from "../../application/application.ts"; import { Color, colorPool } from "../../math/color.ts"; +import { createPool, registerPool } from "../../system/pool.ts"; import { Gradient } from "../../video/gradient.js"; import CanvasRenderTarget from "../../video/rendertarget/canvasrendertarget.js"; import { resolveAnchorPoint } from "../anchorPoint.ts"; -import Renderable from "../renderable.js"; +import Renderable, { resetRenderableState } from "../renderable.js"; import TextMetrics from "./textmetrics.js"; import setContextStyle from "./textstyle.js"; @@ -205,6 +206,14 @@ export default class Text extends Renderable { * @internal */ onResetEvent(x, y, settings) { + // Everything inherited from `Renderable` first, before the settings + // below write over whatever they name. A pooled label handed back + // still carries the previous one's alpha, tint, blend mode and + // transform, and the settings do not mention any of them. + if (typeof this.currentTransform !== "undefined") { + resetRenderableState(this); + } + if (typeof this.fillStyle === "undefined") { this.fillStyle = colorPool.get(0, 0, 0); } @@ -265,6 +274,11 @@ export default class Text extends Renderable { // string (#RGB, #ARGB, #RRGGBB, #AARRGGBB) this.fillStyle.parseCSS(settings.fillStyle); } + } else { + // back to black. Without this a RECYCLED label keeps the previous + // one's colour, because the pool hands the same instance back and + // only the settings that were given are written. + this.fillStyle.setColor(0, 0, 0, 1); } if (typeof settings.strokeStyle !== "undefined") { @@ -274,6 +288,9 @@ export default class Text extends Renderable { // string (#RGB, #ARGB, #RRGGBB, #AARRGGBB) this.strokeStyle.parseCSS(settings.strokeStyle); } + } else { + // as above: a recycled label must not inherit an outline colour + this.strokeStyle.setColor(0, 0, 0, 1); } if (typeof settings.gradientPerLine === "boolean") { @@ -301,6 +318,10 @@ export default class Text extends Renderable { // if floating was specified through settings if (typeof settings.floating !== "undefined") { this.floating = !!settings.floating; + } else { + // `Renderable`'s default, restated so a recycled label does not + // stay pinned to the viewport because an earlier one was + this.floating = false; } // font name and type @@ -317,9 +338,16 @@ export default class Text extends Renderable { // the canvas Texture used to render this text // offscreenCanvas is currently disabled for text rendering due to issue in WebGL mode // see https://github.com/melonjs/melonJS/issues/1180 - this.canvasTexture = new CanvasRenderTarget(2, 2, { - offscreenCanvas: false, - }); + // + // Kept across a recycle, which is most of what makes a pooled label + // worth pooling. A fresh one per reset also LEAKED the previous + // canvas and its GL texture, since only `destroy()` frees those, so a + // label reused often enough walked through a texture unit per reuse. + if (typeof this.canvasTexture === "undefined") { + this.canvasTexture = new CanvasRenderTarget(2, 2, { + offscreenCanvas: false, + }); + } /** * @ignore @@ -327,8 +355,11 @@ export default class Text extends Renderable { */ this._visibleCharacters = -1; - // instance to text metrics functions - this.metrics = new TextMetrics(this); + // instance to text metrics functions. Kept across a recycle too: it + // closes over this label and is re-measured by `setText` below. + if (typeof this.metrics === "undefined") { + this.metrics = new TextMetrics(this); + } // set the text this.setText(settings.text); @@ -806,3 +837,31 @@ export default class Text extends Renderable { super.destroy(); } } + +/** + * A pool of reusable {@link Text} instances. + * + * Text is worth recycling: each one owns a canvas-backed measurement cache and + * a colour, so a scene that spawns damage numbers or score popups churns + * meaningfully without one. Reachable as `getPool("text")`. + * + * `release` it yourself when the text is finished, as the particle and tween + * pools require. A label this pool built is the exception: it carries the pool + * it came from, so removing it from a container releases it back rather than + * destroying it. Pass `keepalive` to `removeChild()` to keep holding one. + * @example + * const label = getPool("text").get(x, y, { font: "Arial", size: 12, text: "+10" }); + * // ... later + * getPool("text").release(label); + */ +export const textPool = createPool((x, y, settings) => { + const instance = new Text(x, y, settings); + return { + instance, + reset(x, y, settings) { + instance.onResetEvent(x, y, settings); + }, + }; +}); + +registerPool("text", textPool); diff --git a/packages/melonjs/src/renderable/trigger.js b/packages/melonjs/src/renderable/trigger.js index b9cac518d8..f17ec1cfa9 100644 --- a/packages/melonjs/src/renderable/trigger.js +++ b/packages/melonjs/src/renderable/trigger.js @@ -15,6 +15,7 @@ import Renderable from "./renderable.js"; * @import Container from "./container.js"; * @import {Ellipse} from "../geometries/ellipse.ts"; * @import {Line} from "../geometries/line.ts"; + * @import {Vector3d} from "../math/vector3d.ts"; * @import {Polygon} from "../geometries/polygon.ts"; * @import {Rect} from "../geometries/rectangle.ts"; */ diff --git a/packages/melonjs/src/system/bootstrap.ts b/packages/melonjs/src/system/bootstrap.ts index 9631b8b7cf..47117c4fef 100644 --- a/packages/melonjs/src/system/bootstrap.ts +++ b/packages/melonjs/src/system/bootstrap.ts @@ -2,7 +2,6 @@ import { initKeyboardEvent } from "../input/keyboard.ts"; import { registerBuiltinTiledClass } from "../level/tiled/TMXObjectFactory.js"; import Light2d from "../lighting/light2d.ts"; import { setNocache } from "../loader/loader.js"; -import Particle from "../particles/particle.ts"; import Collectable from "../renderable/collectable.js"; import ColorLayer from "../renderable/colorlayer.js"; import Entity from "../renderable/entity/entity.js"; @@ -13,12 +12,10 @@ import Sprite from "../renderable/sprite.js"; import BitmapText from "../renderable/text/bitmaptext.js"; import Text from "../renderable/text/text.js"; import Trigger from "../renderable/trigger.js"; -import Tween from "../tweens/tween.ts"; import { getUriFragment } from "../utils/utils.ts"; import { version } from "../version.ts"; import { initVisibilityEvents } from "./device.js"; import { BOOT, DOM_READY, emit } from "./event.ts"; -import pool from "./legacy_pool.js"; /** * a flag indicating that melonJS is fully initialized @@ -41,25 +38,18 @@ export function boot() { // output melonJS version in the console console.log(`melonJS 2 (v${version}) | http://melonjs.org`); - // register all built-ins objects into the object legacy pool - // eslint-disable-next-line @typescript-eslint/no-deprecated - pool.register("Entity", Entity); - pool.register("Collectable", Collectable); - pool.register("Trigger", Trigger); - pool.register("Light2d", Light2d); - pool.register("Particle", Particle, true); - pool.register("Sprite", Sprite); - pool.register("NineSliceSprite", NineSliceSprite); - pool.register("Renderable", Renderable); - pool.register("Text", Text, true); - pool.register("BitmapText", BitmapText); - pool.register("ImageLayer", ImageLayer); - pool.register("Tween", Tween, true); - pool.register("ColorLayer", ColorLayer, true); - - // ensure built-in classes are registered as Tiled object factories - // (redundant with pool.register auto-registration, but ensures - // built-ins remain available if pool behavior changes in the future) + // The built-in classes are registered as Tiled object factories, and + // ONLY there. They used to be registered in the legacy object pool as + // well, which bought nothing and cost something: + // + // - nine of them had recycling off, so `pool.pull("Sprite")` was a plain + // construction behind a string key and `pool.push` refused them; + // - `Particle` and `Tween` have typed pools (`particlePool`, `tweenPool`) + // and neither can reach the container's recycle path anyway, one being + // skipped with `keepalive` and the other not a `Renderable` at all; + // - every one of them registered a Tiled factory a second time, by side + // effect, and that duplicate is what silently replaced a game's own + // class when it registered one under a built-in name. registerBuiltinTiledClass("Renderable", Renderable); registerBuiltinTiledClass("Text", Text); registerBuiltinTiledClass("BitmapText", BitmapText); diff --git a/packages/melonjs/src/system/legacy_pool.js b/packages/melonjs/src/system/legacy_pool.js index 9e166d9c69..08d26babb2 100644 --- a/packages/melonjs/src/system/legacy_pool.js +++ b/packages/melonjs/src/system/legacy_pool.js @@ -1,3 +1,4 @@ +import { warning } from "../lang/console.js"; import { getTotalPoolSize } from "../pool"; /** @@ -17,6 +18,40 @@ export function setPoolRegisterCallback(callback) { onRegisterCallback = callback; } +/** + * Which methods have already warned. + * + * Once per method, never per call: `pull` runs in the hot path of any game + * that pools its bullets, and `warning` prints a collapsed group WITH a stack + * trace. Firing that per bullet would be worse than the thing it warns about. + * @ignore + * @internal + */ +const warned = new Set(); + +/** + * Say this method is on the way out, once. + * + * Names both replacements, because `pool.register` was doing two unrelated + * jobs and a caller only ever wanted one of them: recycling instances, or + * letting a Tiled map name a class. + * @param {string} method - the method being called + * @ignore + * @internal + */ +function warnOnce(method) { + if (warned.has(method)) { + return; + } + warned.add(method); + warning( + "pool." + method + "()", + 'getPool("tween") / createPool() to pool instances, or ' + + "registerTiledObjectClass() to let a Tiled map name a class", + "18.0.0", + ); +} + /** * Object pooling - a technique that might speed up your game if used properly.
* If some of your classes will be instantiated and removed a lot at a time, it is a @@ -28,6 +63,17 @@ export function setPoolRegisterCallback(callback) { * which means, that on level loading the engine will try to instantiate every object * found in the map, based on the user defined name in each Object Properties
*
+ * + * **Superseded by the typed pools.** `createPool` arrived in 18.0.0 and does + * the same job without the string keys: `getPool("tween")`, `getPool("text")` + * and the rest are typed, so a wrong name is a compile error rather than a + * throw, and a pool can be created for any class with `createPool`. For + * registering a class so a Tiled map can name it, use + * {@link registerTiledObjectClass}, which is what this now delegates to. + * + * The engine itself no longer uses this pool for anything. + * @deprecated since 18.0.0, use {@link getPool} / `createPool`, and + * {@link registerTiledObjectClass} for Tiled classes * @see {@link pool} the default global instance of ObjectPool */ class ObjectPool { @@ -78,6 +124,7 @@ class ObjectPool { * me.pool.register("cherrysprite", Cherry, true); */ register(className, classObj, recycling = false) { + warnOnce("register"); if (typeof classObj !== "undefined") { const entry = { class: classObj, @@ -125,6 +172,7 @@ class ObjectPool { * app.world.removeChild(bullet); */ pull(name, ...args) { + warnOnce("pull"); const className = this.objectClass[name]; if (className) { const proto = className["class"]; @@ -166,12 +214,22 @@ class ObjectPool { * Object pooling for the object class must be enabled, * and object must have been instantiated using {@link pull}, * otherwise this function won't work - * @throws will throw an error if the object cannot be recycled + * + * Reports failure by RETURNING FALSE. It used to throw by default, and + * nothing at the call site made that visible: a class that was never + * registered, or registered without recycling, aborted whatever was + * running. Both of the internal uses removed in 20.7.0 failed that way, + * inside a `destroy()` that had already recycled other state, which left + * a half-torn-down object to die later somewhere unrelated. Pass + * `throwOnError` to opt back in where a missed registration is a bug you + * want to hear about immediately. * @param {object} obj - instance to be recycled - * @param {boolean} [throwOnError=true] - throw an exception if the object cannot be recycled + * @param {boolean} [throwOnError=false] - throw an exception instead of returning false + * @throws when the object cannot be recycled AND `throwOnError` is true * @returns {boolean} true if the object was successfully recycled in the object pool */ - push(obj, throwOnError = true) { + push(obj, throwOnError = false) { + warnOnce("push"); if (!this.poolable(obj)) { if (throwOnError === true) { throw new Error("me.pool: object " + obj + " cannot be recycled"); diff --git a/packages/melonjs/src/system/pool.ts b/packages/melonjs/src/system/pool.ts index d5fbc63aa8..89f13b55f3 100644 --- a/packages/melonjs/src/system/pool.ts +++ b/packages/melonjs/src/system/pool.ts @@ -18,8 +18,11 @@ type Release = (() => void) | undefined; export interface CreatePoolOptions { instance: T; - reset?: Reset; - release?: Release; + // spelled out rather than naming the two aliases above: those are + // internal, and a public interface must not reference a type a consumer + // cannot see + reset?: ((...args: A) => void) | undefined; + release?: (() => void) | undefined; } // Pool registry for centralized access via getPool/getTotalPoolSize @@ -44,6 +47,69 @@ export const registerPool = (key: string, pool: Pool) => { */ export const getRegisteredPools = () => pools; +/** + * Instances currently sitting in the pool that built them. + * + * `Pool#release` throws on an instance it already holds, which is the right + * answer for a caller releasing the same object twice by hand. It is the wrong + * answer for `releaseToOwningPool`, which is called for EVERY child a + * container drops: a game that released a pooled label itself, with the label + * still a container child, then had the next `clearChildren()` or level change + * throw out of the teardown. This lets that path recognise the object is + * already home and say so without going near `release`. + * @ignore + * @internal + */ +const parked = new WeakSet(); + +/** + * The back-pointer `createPool` stamps on everything it builds. + * + * It answers "which pool owns this object", which is the question a GENERIC + * caller has to ask: `Container#removeChildNow` holds a child and has no idea + * what it is. A per-class check cannot answer it, because two pools can exist + * for one class and releasing to the wrong one silently corrupts both. + * + * The legacy pool answered the same question with a `className` string; this + * is that, typed and without the registry. + * @internal + * @ignore + */ +export interface Poolable { + /** + * the pool that created this object, if any + * + * Typed the same way the pool registry above is: a pool's parameters vary + * per class, and this field holds whichever one built the object. + * @internal + * @ignore + */ + poolable?: Pool; +} + +/** + * Hand an object back to whichever pool created it. + * + * Returns false for anything a pool did not build, which is the signal a + * caller needs to fall back to destroying it instead. + * @param instance - the object to release + * @returns true if a pool took it back + * @internal + * @ignore + */ +export const releaseToOwningPool = (instance: object): boolean => { + const owner = (instance as Poolable).poolable; + if (owner === undefined) { + return false; + } + // already back in its pool, so the caller must not destroy it either + if (parked.has(instance)) { + return true; + } + owner.release(instance); + return true; +}; + export const createPool = ( options: (...args: A) => CreatePoolOptions, ): Pool => { @@ -52,7 +118,7 @@ export const createPool = ( const instanceReleaseMethods = new Map(); let inUse: number = 0; - return { + const pool: Pool = { /** * release an object back to the pool * @param instance The object to release. @@ -79,6 +145,9 @@ export const createPool = ( const release = instanceReleaseMethods.get(instance); release?.(); available.add(instance); + if (instance !== null && typeof instance === "object") { + parked.add(instance); + } inUse--; }, /** @@ -88,6 +157,9 @@ export const createPool = ( get: (...args) => { const object = takeFromSet(available); if (object) { + if (object !== null && typeof object === "object") { + parked.delete(object); + } const reset = instanceResetMethods.get(object); reset?.(...args); inUse++; @@ -96,6 +168,23 @@ export const createPool = ( const { instance, reset, release } = options(...args); instanceResetMethods.set(instance, reset); instanceReleaseMethods.set(instance, release); + // Stamped once, at construction: a recycled instance already + // carries it, and this is what lets a generic caller return + // the object without knowing its type. + // + // NON-ENUMERABLE, which is not a detail: as a plain property + // it joins `Object.keys`, `JSON.stringify`, console output and + // every deep-equality comparison. `tests/vector2d.spec.ts` + // caught it immediately, comparing two vectors that were now + // carrying a pool each. + if (instance !== null && typeof instance === "object") { + Object.defineProperty(instance, "poolable", { + value: pool, + enumerable: false, + writable: true, + configurable: true, + }); + } inUse++; return instance; } @@ -120,4 +209,6 @@ export const createPool = ( return inUse; }, }; + + return pool; }; diff --git a/packages/melonjs/src/video/effects/tintPulse.js b/packages/melonjs/src/video/effects/tintPulse.js index 2c3a636f04..f9a9df2994 100644 --- a/packages/melonjs/src/video/effects/tintPulse.js +++ b/packages/melonjs/src/video/effects/tintPulse.js @@ -71,14 +71,6 @@ export default class TintPulseEffect extends ShaderEffect { this.setUniform("uTime", 0.0); } - /** - * set the current time (call each frame for animation) - * @param {number} time - time in seconds - */ - setTime(time) { - this.setUniform("uTime", time); - } - /** * set the pulse color * @param {number[]} color - pulse color as [r, g, b] (0.0–1.0) diff --git a/packages/melonjs/src/video/effects/wave.js b/packages/melonjs/src/video/effects/wave.js index 59fec37840..825d59fe58 100644 --- a/packages/melonjs/src/video/effects/wave.js +++ b/packages/melonjs/src/video/effects/wave.js @@ -67,14 +67,6 @@ export default class WaveEffect extends ShaderEffect { this.setUniform("uTime", 0.0); } - /** - * set the current time (call each frame for animation) - * @param {number} time - time in seconds - */ - setTime(time) { - this.setUniform("uTime", time); - } - /** * set the wave amplitude * @param {number} amplitude - displacement strength in UV space diff --git a/packages/melonjs/src/video/gpu/batcher.js b/packages/melonjs/src/video/gpu/batcher.js index 8cef82abd3..7588a7f645 100644 --- a/packages/melonjs/src/video/gpu/batcher.js +++ b/packages/melonjs/src/video/gpu/batcher.js @@ -44,10 +44,17 @@ export class Batcher { * Initialize (or re-initialize after a context/device loss) this batcher. * Derived classes override this with their full `(renderer, settings)` * signature and own the whole body; the base implementation only stores - * the renderer reference. + * the renderer reference and ignores the settings. + * + * `settings` is declared here even though this body does not read it: a + * subclass cannot add a required parameter the base does not have without + * breaking substitutability, and the backends all take one. * @param {Renderer} renderer - the owning renderer + * @param {object} [settings] - backend-specific batcher settings, owned + * and documented by the derived class */ - init(renderer) { + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + init(renderer, settings) { /** * the renderer this batcher is bound to * @type {Renderer} diff --git a/packages/melonjs/src/video/rendertarget/canvasrendertarget.js b/packages/melonjs/src/video/rendertarget/canvasrendertarget.js index 5e9dc18aa3..dad2692177 100644 --- a/packages/melonjs/src/video/rendertarget/canvasrendertarget.js +++ b/packages/melonjs/src/video/rendertarget/canvasrendertarget.js @@ -186,6 +186,22 @@ class CanvasRenderTarget extends RenderTarget { this.canvas.height = Math.round(height); } + /** + * Read back pixel data from this render target. + * + * The canvas backend can read back synchronously, so this resolves + * immediately; it exists so code that must work on every backend has one + * method to call. See {@link RenderTarget#toImageData}. + * @param {number} [x=0] - x coordinate of the top-left corner + * @param {number} [y=0] - y coordinate of the top-left corner + * @param {number} [width=this.width] - width of the area to read + * @param {number} [height=this.height] - height of the area to read + * @returns {Promise} the pixel data + */ + toImageData(x, y, width, height) { + return Promise.resolve(this.getImageData(x, y, width, height)); + } + /** * Returns an ImageData object representing the underlying pixel data for a specified portion of this canvas texture. * (Note: when using getImageData(), it is highly recommended to use the `willReadFrequently` attribute when creatimg the corresponding canvas texture) diff --git a/packages/melonjs/src/video/rendertarget/rendertarget.ts b/packages/melonjs/src/video/rendertarget/rendertarget.ts index 3a0464e325..83d1bd57de 100644 --- a/packages/melonjs/src/video/rendertarget/rendertarget.ts +++ b/packages/melonjs/src/video/rendertarget/rendertarget.ts @@ -41,33 +41,54 @@ export default abstract class RenderTarget { * @param width - new width in pixels * @param height - new height in pixels */ - abstract resize(width: number, height: number): void; + resize(width: number, height: number): void { + throw new Error( + `${this.constructor.name} does not implement resize(${width}, ${height})`, + ); + } /** * Clear the render target contents. */ - abstract clear(): void; + clear(): void { + throw new Error(`${this.constructor.name} does not implement clear()`); + } /** * Release all resources held by this render target. * The target must not be used after calling destroy. */ - abstract destroy(): void; + destroy(): void { + throw new Error(`${this.constructor.name} does not implement destroy()`); + } /** * Read back pixel data from this render target. + * + * The portable form, and the one anything backend-agnostic should use. + * Synchronous readback is NOT portable: WebGPU can only map a buffer + * asynchronously, so a render target of that backend cannot answer at all + * until a frame has been awaited. The two backends that CAN read back + * synchronously offer `getImageData()` as an extra, but it is not part of + * this contract and does not exist on every target. * @param x - x coordinate of the top-left corner (default 0) * @param y - y coordinate of the top-left corner (default 0) * @param width - width of the area to read (default full width) * @param height - height of the area to read (default full height) - * @returns an ImageData object containing the pixel data + * @returns the pixel data */ - abstract getImageData( + toImageData( x?: number, y?: number, width?: number, height?: number, - ): ImageData; + ): Promise { + return Promise.reject( + new Error( + `${this.constructor.name} does not implement toImageData(${x}, ${y}, ${width}, ${height})`, + ), + ); + } /** * Creates a Blob object representing the image contained in this render target. @@ -75,8 +96,8 @@ export default abstract class RenderTarget { * @param quality - a number between 0 and 1 for lossy formats (e.g. image/jpeg) * @returns a Promise resolving to a Blob */ - toBlob(type = "image/png", quality?: number): Promise { - const imageData = this.getImageData(); + async toBlob(type = "image/png", quality?: number): Promise { + const imageData = await this.toImageData(); if (typeof OffscreenCanvas !== "undefined") { const canvas = new OffscreenCanvas(this.width, this.height); const ctx = canvas.getContext("2d"); @@ -117,8 +138,8 @@ export default abstract class RenderTarget { * Creates an ImageBitmap object from the current contents of this render target. * @returns a Promise resolving to an ImageBitmap */ - toImageBitmap(): Promise { - const imageData = this.getImageData(); + async toImageBitmap(): Promise { + const imageData = await this.toImageData(); return globalThis.createImageBitmap(imageData); } diff --git a/packages/melonjs/src/video/rendertarget/webglrendertarget.js b/packages/melonjs/src/video/rendertarget/webglrendertarget.js index 918b0205cb..254935e671 100644 --- a/packages/melonjs/src/video/rendertarget/webglrendertarget.js +++ b/packages/melonjs/src/video/rendertarget/webglrendertarget.js @@ -361,6 +361,22 @@ export default class WebGLRenderTarget extends RenderTarget { this.unbind(); } + /** + * Read back pixel data from this render target. + * + * The WebGL backend can read back synchronously, so this resolves + * immediately; it exists so code that must work on every backend has one + * method to call. See {@link RenderTarget#toImageData}. + * @param {number} [x=0] - x coordinate of the top-left corner + * @param {number} [y=0] - y coordinate of the top-left corner + * @param {number} [width=this.width] - width of the area to read + * @param {number} [height=this.height] - height of the area to read + * @returns {Promise} the pixel data + */ + toImageData(x, y, width, height) { + return Promise.resolve(this.getImageData(x, y, width, height)); + } + /** * Returns an ImageData object representing the pixel contents of this render target. * @param {number} [x=0] - x coordinate of the top-left corner diff --git a/packages/melonjs/src/video/rendertarget/webgpurendertarget.js b/packages/melonjs/src/video/rendertarget/webgpurendertarget.js index 95275b17f7..a664c571b5 100644 --- a/packages/melonjs/src/video/rendertarget/webgpurendertarget.js +++ b/packages/melonjs/src/video/rendertarget/webgpurendertarget.js @@ -212,25 +212,32 @@ export default class WebGPURenderTarget extends RenderTarget { /** * Synchronous readback is impossible under WebGPU — use - * {@link WebGPURenderTarget#readPixels}. + * {@link RenderTarget#toImageData}. + * + * Kept, and kept throwing, so the two backends that DO read back + * synchronously can still be used that way without this one silently + * returning something wrong. * @throws {Error} always - * @override */ getImageData() { throw new Error( - "WebGPURenderTarget.getImageData: WebGPU readback is asynchronous — use `await target.readPixels()` instead", + "WebGPURenderTarget.getImageData: WebGPU readback is asynchronous — use `await target.toImageData()` instead", ); } /** - * Asynchronously read back this target's pixels. + * Read back pixel data from this render target. + * + * The portable readback, and the ONLY form this backend can offer: WebGPU + * maps its buffer asynchronously, which is why the shared contract is a + * promise. See {@link RenderTarget#toImageData}. * @param {number} [x=0] - x of the top-left corner * @param {number} [y=0] - y of the top-left corner * @param {number} [width=this.width] - width of the area to read * @param {number} [height=this.height] - height of the area to read * @returns {Promise} the pixel data (RGBA order) */ - async readPixels(x = 0, y = 0, width = this.width, height = this.height) { + async toImageData(x = 0, y = 0, width = this.width, height = this.height) { const renderer = this.renderer; const device = renderer.device; // clamp the read window to the target (an out-of-bounds copy is a diff --git a/packages/melonjs/src/video/texture/atlas.js b/packages/melonjs/src/video/texture/atlas.js index 467267736c..43d84a4983 100644 --- a/packages/melonjs/src/video/texture/atlas.js +++ b/packages/melonjs/src/video/texture/atlas.js @@ -1,8 +1,12 @@ -import { getImage } from "./../../loader/loader.js"; +// from `cache.js` rather than `loader.js`, for the same reason `sprite.js` +// does: this module is inside the cycle that `class NineSliceSprite extends +// Sprite` has to load through, and the cache module keeps a single runtime +// import where the loader pulls in every parser +import { getImage } from "./../../loader/cache.js"; import { Vector2d } from "../../math/vector2d.ts"; +import NineSliceSprite from "./../../renderable/nineslicesprite.js"; import Sprite from "./../../renderable/sprite.js"; import { on, VIDEO_INIT } from "../../system/event.ts"; -import pool from "../../system/legacy_pool.js"; import { parseAseprite } from "./parser/aseprite.js"; import { parseSpriteSheet } from "./parser/spritesheet.js"; import { parseTexturePacker } from "./parser/texturepacker.js"; @@ -23,7 +27,6 @@ on(VIDEO_INIT, (renderer) => { /** * additional import for TypeScript - * @import NineSliceSprite from "./../../renderable/nineslicesprite.js"; * @import {CompressedImage} from "../../loader/parsers/compressed_textures/compressed_image.js"; */ @@ -85,6 +88,15 @@ export function identifyFormat(app) { * @category Game Objects */ export class TextureAtlas extends Texture2d { + /** + * This texture carries named regions, so `getRegion()` is meaningful. + * @type {boolean} + * @readonly + */ + get isAtlas() { + return true; + } + /** * @param {object|object[]} atlases - atlas information. See {@link loader.getJSON} * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|CompressedImage|string|OffscreenCanvas[]|HTMLImageElement[]|HTMLCanvasElement[]|string[]} [src=atlas.meta.image] - Image source @@ -543,8 +555,8 @@ export class TextureAtlas extends Texture2d { */ createSpriteFromName(name, settings, nineSlice = false) { // instantiate a new sprite object - return pool.pull( - nineSlice === true ? "me.NineSliceSprite" : "me.Sprite", + const Class = nineSlice === true ? NineSliceSprite : Sprite; + return new Class( 0, 0, Object.assign( diff --git a/packages/melonjs/src/video/texture/texture2d.ts b/packages/melonjs/src/video/texture/texture2d.ts index e53b6d81a4..7a49a83d63 100644 --- a/packages/melonjs/src/video/texture/texture2d.ts +++ b/packages/melonjs/src/video/texture/texture2d.ts @@ -70,6 +70,25 @@ export type Texture2dSource = * @category Game Objects */ export default abstract class Texture2d { + /** + * Whether this texture carries named regions, addressable with + * `getRegion()`. `false` on every texture but a {@link TextureAtlas}. + * @default false + */ + // Tested instead of `instanceof TextureAtlas` because `atlas.js` cannot be + // imported from everything that needs to ask: `sprite.js` importing it + // forms a cycle that `NineSliceSprite` and `ImageLayer` cannot load + // through, since `class X extends Sprite` reads `Sprite` as it evaluates. + // Concrete rather than `abstract` because `Texture2d` is publicly + // subclassable, and an abstract member would break external subclasses. + // A prototype getter rather than an instance field, so it stays out of + // `Object.keys` and off anything that enumerates a texture, and so the + // answer cannot be written over at runtime the way `instanceof` could not + // be spoofed. + get isAtlas(): boolean { + return false; + } + /** * Return the backing source for this texture — a drawable canvas/image for * CPU-backed assets (assignable to {@link Sprite#image} / diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js index 53d0a444b8..d4de015a39 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -15,6 +15,8 @@ import QuadBatcher from "./quad_batcher.js"; /** * additional import for TypeScript * @import {TextureAtlas} from "./../../texture/atlas.js"; + * @import GLShader from "../glshader.js"; + * @import ShaderEffect from "../../effects/shadereffect.js"; */ /** diff --git a/packages/melonjs/tests/anchorpoint-presets.spec.js b/packages/melonjs/tests/anchorpoint-presets.spec.js index d3e9bc59f0..166d4cb8e4 100644 --- a/packages/melonjs/tests/anchorpoint-presets.spec.js +++ b/packages/melonjs/tests/anchorpoint-presets.spec.js @@ -5,10 +5,10 @@ import { boot, Collectable, Entity, + getPool, ImageLayer, loader, NineSliceSprite, - pool, Sprite, Sprite3d, Text, @@ -442,21 +442,25 @@ describe("Tiled property chain", () => { }); describe("pool recycling", () => { - it("a pooled Text resolves presets on pull AND on recycle", () => { - const t1 = pool.pull("Text", 0, 0, { + it("a pooled Text resolves presets on get AND on recycle", () => { + // through the typed pool: `Text` is no longer registered in the legacy + // name-keyed one, which only ever gave it string-keyed construction + const pool2d = getPool("text"); + const t1 = pool2d.get(0, 0, { font: "Arial", size: 16, anchorPoint: "bottom", }); expect([t1.anchorPoint.x, t1.anchorPoint.y]).toEqual([0.5, 1]); - pool.push(t1); + pool2d.release(t1); // recycled instance must re-resolve the NEW settings, not keep the old - const t2 = pool.pull("Text", 0, 0, { + const t2 = pool2d.get(0, 0, { font: "Arial", size: 16, anchorPoint: "top-right", }); + expect(t2).toBe(t1); expect([t2.anchorPoint.x, t2.anchorPoint.y]).toEqual([1, 0]); - pool.push(t2); + pool2d.release(t2); }); }); diff --git a/packages/melonjs/tests/helpers/pixels.js b/packages/melonjs/tests/helpers/pixels.js index c4942e9adb..31aa566198 100644 --- a/packages/melonjs/tests/helpers/pixels.js +++ b/packages/melonjs/tests/helpers/pixels.js @@ -6,8 +6,9 @@ * * - `gl.readPixels` rows are BOTTOM-up, so y must be flipped * - `CanvasRenderingContext2D.getImageData` rows are TOP-down already - * - `WebGPURenderTarget.readPixels` is top-down AND un-swizzles BGRA→RGBA, - * but it is asynchronous — `getImageData` throws by contract on WebGPU + * - `RenderTarget#toImageData` is top-down AND un-swizzles BGRA→RGBA on + * WebGPU, and is a promise on every backend, because WebGPU can only read + * back asynchronously. Synchronous `getImageData` throws on that one. * * Everything here is async so the WebGPU path fits the same shape as the * other two, rather than forcing callers to branch. diff --git a/packages/melonjs/tests/legacy-pool-deprecation.spec.js b/packages/melonjs/tests/legacy-pool-deprecation.spec.js new file mode 100644 index 0000000000..08cc5cf125 --- /dev/null +++ b/packages/melonjs/tests/legacy-pool-deprecation.spec.js @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { pool } from "../src/index.js"; + +/** + * The legacy pool's deprecation notice. + * + * Its own spec file on purpose: the notice fires ONCE per method for the life + * of the module, and `legacy_pool.spec.js` registers classes at module scope, + * so by the time any test there runs the notice has already been spent. + * Vitest isolates modules per file, which is what gives this one a clean slate. + */ +describe("legacy pool deprecation notice", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** capture what the notice prints */ + const capture = () => { + const seen = []; + vi.spyOn(console, "groupCollapsed").mockImplementation((...args) => { + seen.push(args.join(" ")); + }); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "groupEnd").mockImplementation(() => {}); + return seen; + }; + + class Poolable { + onResetEvent() {} + } + + it("names BOTH replacements, because register did two unrelated jobs", () => { + // a caller only ever wanted one of them: recycling instances, or + // letting a Tiled map name a class. The message has to serve either + // reader, so it must not name only the pooling one. + const seen = capture(); + pool.register("deprecation-probe", Poolable, true); + + // the format string and its arguments arrive separately, so assert on + // the joined output rather than on a substituted sentence + const text = seen.join(" "); + expect(text).toMatch(/deprecated since version/); + expect(text).toMatch(/pool\.register\(\)/); + expect(text).toMatch(/18\.0\.0/); + // both replacements, which is the point: one reader wants pooling, + // the other wants a Tiled class name + expect(text).toMatch(/createPool\(\)/); + expect(text).toMatch(/registerTiledObjectClass\(\)/); + }); + + it("push warns too, and on its own", () => { + // each of the three methods has its own one-shot, so a game that only + // ever pushes still hears about it + const seen = capture(); + const instance = new Poolable(); + instance.className = "deprecation-probe"; + pool.push(instance); + expect(seen.length).toEqual(1); + expect(seen[0]).toMatch(/pool\.push\(\)/); + }); + + it("warns ONCE per method, however many times it is called", () => { + // `pull` runs in the hot path of any game that pools its bullets, and + // the notice prints a stack trace. Per call would cost more than the + // thing it is warning about. + const seen = capture(); + pool.pull("deprecation-probe"); + pool.pull("deprecation-probe"); + pool.pull("deprecation-probe"); + expect(seen.length).toEqual(1); + expect(seen[0]).toMatch(/pool\.pull\(\)/); + }); +}); diff --git a/packages/melonjs/tests/legacy_pool.spec.js b/packages/melonjs/tests/legacy_pool.spec.js index 2e7a427073..a1a52f12fb 100644 --- a/packages/melonjs/tests/legacy_pool.spec.js +++ b/packages/melonjs/tests/legacy_pool.spec.js @@ -46,11 +46,27 @@ describe("pool", () => { expect(obj.alive).toEqual(true); }); - it("object is not recycled when pushed and pulled back again", () => { - // pushing it into the object pool should throw an exception + it("REGRESSION: refusing to recycle REPORTS, it does not throw", () => { + // It used to throw by default, and nothing at the call site made + // that visible, so a class registered without recycling aborted + // whatever was running. Both internal uses removed in 20.7.0 + // failed that way, from inside a `destroy()` that had already + // recycled other state. + let returned; expect(() => { - pool.push(obj); - }).toThrow(); + returned = pool.push(obj); + }).not.toThrow(); + expect(returned).toBe(false); + // and it really did not take it: the next pull is a fresh one + expect(pool.pull("dummyClass")).not.toBe(obj); + }); + + it("still throws when the caller asks for it", () => { + // the opt-in, for a caller that wants a missed registration to be + // loud rather than silent + expect(() => { + pool.push(obj, true); + }).toThrow("cannot be recycled"); }); }); }); diff --git a/packages/melonjs/tests/loader.spec.js b/packages/melonjs/tests/loader.spec.js index b60412faf8..adaee92376 100644 --- a/packages/melonjs/tests/loader.spec.js +++ b/packages/melonjs/tests/loader.spec.js @@ -1,9 +1,30 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { audio, boot, event, loader } from "../src/index.js"; -import { fontList, videoList } from "../src/loader/cache.js"; +import { + binList, + fontList, + gltfList, + imgList, + jsonList, + mtlList, + objList, + shaderList, + tmxList, + videoList, +} from "../src/loader/cache.js"; import { preloadFontFace } from "../src/loader/parsers/fontface.js"; describe("loader", () => { + // `silence` is the only audio fixture in the repo, so several tests across + // this file load it. Releasing it here rather than in each test is what + // keeps them independent: a loaded clip does not fire its load callback + // again, so one test forgetting to clean up made a later one time out after + // five seconds, and it passed only because an `unloadAll()` happened to run + // in between. Unloading something that was never loaded is a no-op. + afterEach(() => { + loader.unload({ name: "silence", type: "audio" }); + }); + let audioURI; let imgURI; @@ -390,6 +411,48 @@ describe("loader", () => { expect(loader.getBinary("nonexistent")).toBeNull(); expect(loader.getVideo("nonexistent")).toBeNull(); expect(loader.getFont("nonexistent")).toBeNull(); + // null, not undefined: both are documented "or null if not found", + // and both have call sites that mask the difference + expect(loader.getOBJ("nonexistent")).toBeNull(); + expect(loader.getMTL("nonexistent")).toBeNull(); + expect(loader.getShader("nonexistent")).toBeNull(); + expect(loader.getGLTF("nonexistent")).toBeNull(); + }); + + it("unloading a `js` asset is a successful no-op", () => { + // a script ran at load time and is never cached, so there is nothing to + // delete and nothing to report missing. Pinned because the case looks + // like an oversight otherwise, and because falling to `default` would + // now THROW on an unknown type + expect(loader.unload({ name: "anything", type: "js" })).toBe(true); + }); + + it("unloading audio reports what the audio backend found", () => { + // the case delegates, so it has to return the delegate's answer rather + // than an unconditional true + expect(loader.unload({ name: "never-loaded-sound", type: "audio" })).toBe( + false, + ); + }); + + it("unloading an aseprite asset clears BOTH of its caches", () => { + // an aseprite asset writes the image and the json cache under one key. + // Deleting them with `a || b` short-circuits once the image goes and + // leaks the json half forever, which is why both calls are made before + // the result is combined. Seeding one cache at a time cannot catch it. + imgList["ase-both"] = {}; + jsonList["ase-both"] = {}; + expect(loader.unload({ name: "ase-both", type: "aseprite" })).toBe(true); + expect("ase-both" in imgList).toBe(false); + expect("ase-both" in jsonList).toBe(false); + }); + + it("getImage coerces a non-string name, as its callers rely on", () => { + // `getBasename` calls `.replace`, so an un-coerced number THROWS rather + // than missing. The other getters index an object and coerce anyway. + imgList["123"] = {}; + expect(loader.getImage(123)).toBe(imgList["123"]); + delete imgList["123"]; }); it("should return false when unloading non-existent assets", () => { @@ -733,6 +796,123 @@ describe("loader", () => { expect("unloadall-test" in fontList).toBe(false); expect("unloadall-test" in videoList).toBe(false); }); + + it("frees audio too, which keeps its own store", async () => { + // audio is not in `cacheByType`: `unloadAll` has to call the audio + // backend separately, and dropping that call leaves every clip + // loaded with nothing to notice + audio.init("mp3"); + await new Promise((resolve, reject) => { + loader.load( + { name: "silence", type: "audio", src: "data/sfx/" }, + resolve, + reject, + ); + }); + expect(audio.state("silence")).toBe("loaded"); + + loader.unloadAll(); + + expect(() => { + return audio.state("silence"); + }).toThrow(); + }); + + it("unloads a tsx asset through the tmx cache it shares", () => { + // `tsx` and `tmx` are one cache, and that aliasing is the reason + // the type map exists. `glb`/`gltf` is covered in gltf.spec.js; + // this is the other half. + tmxList["alias-tsx"] = {}; + expect(loader.unload({ name: "alias-tsx", type: "tsx" })).toBe(true); + expect("alias-tsx" in tmxList).toBe(false); + expect(loader.unload({ name: "alias-tsx", type: "tmx" })).toBe(false); + }); + + it("unloads an aseprite asset when only one of its two caches is set", () => { + // an aseprite asset writes BOTH the image and the json cache under + // one key, and either half alone still counts as present. `&&` + // here instead of `||` would report false and leak the other half. + imgList["ase-img-only"] = {}; + expect(loader.unload({ name: "ase-img-only", type: "aseprite" })).toBe( + true, + ); + expect("ase-img-only" in imgList).toBe(false); + + jsonList["ase-json-only"] = {}; + expect(loader.unload({ name: "ase-json-only", type: "aseprite" })).toBe( + true, + ); + expect("ase-json-only" in jsonList).toBe(false); + + expect(loader.unload({ name: "ase-neither", type: "aseprite" })).toBe( + false, + ); + }); + + it("throws for an unknown or missing asset type, as `load` does", () => { + // the pair has to agree: a typo that is loud going in must not be + // silent coming out + expect(() => { + return loader.unload({ name: "x", type: "bogus" }); + }).toThrow(/unknown or invalid resource type/); + expect(() => { + return loader.unload({ name: "x" }); + }).toThrow(/unknown or invalid resource type/); + }); + + it("removes a fontface from the document, not only from the cache", () => { + // the cache entry going is the easy half; the FontFace also has to + // leave `document.fonts`, and nothing asserted that before + const ff = new FontFace( + "unload-doc-test", + "url(data:font/woff2;base64,)", + ); + globalThis.document.fonts.add(ff); + fontList["unload-doc-test"] = ff; + + expect(loader.unload({ name: "unload-doc-test", type: "fontface" })).toBe( + true, + ); + expect("unload-doc-test" in fontList).toBe(false); + const stillThere = [...globalThis.document.fonts].some((f) => { + return f.family === "unload-doc-test"; + }); + expect(stillThere).toBe(false); + }); + + it("empties EVERY cache, not just the ones a test happens to seed", () => { + // `unloadAll` walks a list of asset types. A type missing from that + // list leaks its whole cache silently, and no other test notices: + // seeding one entry per cache and demanding all of them go is the + // only thing that catches it. + const caches = { + binary: binList, + image: imgList, + json: jsonList, + tmx: tmxList, + obj: objList, + mtl: mtlList, + gltf: gltfList, + shader: shaderList, + video: videoList, + }; + for (const cache of Object.values(caches)) { + cache["unloadall-sweep"] = {}; + } + // a `null` shader entry is the Canvas-renderer case: no GL program + shaderList["unloadall-sweep"] = null; + + loader.unloadAll(); + + const leaked = Object.entries(caches) + .filter(([, cache]) => { + return "unloadall-sweep" in cache; + }) + .map(([type]) => { + return type; + }); + expect(leaked).toEqual([]); + }); }); describe("audio parser registration", () => { @@ -775,7 +955,6 @@ describe("loader", () => { }); expect(count).toBeGreaterThan(0); expect(audio.state("silence")).toBe("loaded"); - loader.unload({ name: "silence", type: "audio" }); }); it("unloads an audio asset through the loader", async () => { diff --git a/packages/melonjs/tests/mesh.spec.js b/packages/melonjs/tests/mesh.spec.js index 3f512c873a..8a101ad3af 100644 --- a/packages/melonjs/tests/mesh.spec.js +++ b/packages/melonjs/tests/mesh.spec.js @@ -10,6 +10,7 @@ import { Renderable, Stage, state, + TextureAtlas, Vector2d, Vector3d, video, @@ -1826,3 +1827,85 @@ describe("Mesh × Camera3d world-space path", () => { }); }); }); + +describe("Mesh texture resolution", () => { + // `resolveTextureAtlas` returns a TextureAtlas it is handed AS IS, and + // builds one from the renderer cache otherwise. The existing readback + // spec cannot tell the two apart: its atlas came OUT of the cache, so + // falling through returns the very same object and the test passes with + // the branch broken. This one hands over an atlas the cache has never + // seen, so identity is the whole assertion. + let app; + + beforeAll(async () => { + boot(); + app = new Application(64, 64, { parent: "screen", renderer: video.AUTO }); + await app.init(); + }); + + afterAll(() => { + app?.destroy(); + }); + + // a local copy: the other fixture is scoped to its own describe + const pyramid = () => { + return { + vertices: new Float32Array([ + 0, 1, 0, -1, -1, -1, 1, -1, -1, 1, -1, 1, -1, -1, 1, + ]), + uvs: new Float32Array([0.5, 0, 0, 1, 1, 1, 1, 1, 0, 1]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1]), + width: 60, + height: 60, + cullBackFaces: true, + }; + }; + + const namedAtlas = () => { + return new TextureAtlas( + { + meta: { + app: "https://www.codeandweb.com/texturepacker", + size: { w: 64, h: 64 }, + image: "default", + }, + frames: [ + { + filename: "face.png", + frame: { x: 0, y: 0, w: 16, h: 16 }, + rotated: false, + trimmed: false, + spriteSourceSize: { x: 0, y: 0, w: 16, h: 16 }, + sourceSize: { w: 16, h: 16 }, + }, + ], + }, + Renderer.createCanvas(64, 64), + // `cache: false`, and that is the whole point of the fixture. + // A TextureAtlas registers ITSELF in the renderer cache keyed by + // its source (atlas.js:283), so a cached one is handed back by + // `cache.get(image)` too — and an identity assertion then passes + // even with the branch under test disabled. Keeping this one out + // of the cache is what makes the test able to fail. + { cache: false }, + ); + }; + + it("keeps the exact TextureAtlas it was given", () => { + const atlas = namedAtlas(); + const settings = pyramid(); + settings.texture = atlas; + const mesh = new Mesh(0, 0, settings); + // identity, not equality: re-resolving through the cache would hand + // back a DIFFERENT, whole-image atlas and silently lose the regions + expect(mesh.texture).toBe(atlas); + expect(mesh.texture.getRegion("face.png")).toBeDefined(); + }); + + it("builds an atlas for a plain canvas source", () => { + const settings = pyramid(); + settings.texture = Renderer.createCanvas(32, 32); + const mesh = new Mesh(0, 0, settings); + expect(mesh.texture).toBeInstanceOf(TextureAtlas); + }); +}); diff --git a/packages/melonjs/tests/pool-autoreturn.spec.js b/packages/melonjs/tests/pool-autoreturn.spec.js new file mode 100644 index 0000000000..24a43989a5 --- /dev/null +++ b/packages/melonjs/tests/pool-autoreturn.spec.js @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { boot, Container, createPool, Renderable } from "../src/index.js"; + +/** + * A container returns a child to whichever pool built it. + * + * This is the migration path the legacy pool's deprecation notice points at, + * so it has to work from the PUBLIC surface: `createPool` is exported for + * exactly this reason, because `getPool` only reaches the pools the engine + * ships and a game needs one for its own class. + */ +describe("pooled children are returned on removal", () => { + class Bullet extends Renderable { + constructor(x, y) { + super(x, y, 4, 4); + } + onResetEvent(x, y) { + this.pos.set(x, y, 0); + } + } + + const makePool = () => { + return createPool((x, y) => { + const instance = new Bullet(x, y); + return { + instance, + reset: (x, y) => { + return instance.onResetEvent(x, y); + }, + }; + }); + }; + + it("removeChildNow hands the child back, and the next get reuses it", () => { + boot(); + const bulletPool = makePool(); + const container = new Container(0, 0, 100, 100); + + const first = bulletPool.get(10, 20); + container.addChild(first); + expect(bulletPool.used()).toEqual(1); + + container.removeChildNow(first); + // returned, not destroyed: the container asked the object which pool + // owns it rather than consulting a name registry + expect(bulletPool.used()).toEqual(0); + expect(bulletPool.size()).toEqual(1); + + const second = bulletPool.get(30, 40); + expect(second).toBe(first); + expect(second.pos.x).toEqual(30); + }); + + it("keepalive leaves the child alone", () => { + boot(); + const bulletPool = makePool(); + const container = new Container(0, 0, 100, 100); + + const b = bulletPool.get(1, 2); + container.addChild(b); + container.removeChildNow(b, true); + // still counted as in use: keepalive means the caller keeps it + expect(bulletPool.used()).toEqual(1); + }); + + it("REGRESSION: releasing a child yourself, then clearing, does not throw", () => { + // the pattern the pool docs instruct: `release` it when it is + // finished. If it is still a container child, and it usually is, the + // next `clearChildren()` or level change reached the pool a second + // time and an exception escaped the teardown partway through. + boot(); + const bulletPool = makePool(); + const container = new Container(0, 0, 100, 100); + + const b = bulletPool.get(5, 6); + container.addChild(b); + bulletPool.release(b); + expect(() => { + container.clearChildren(); + }).not.toThrow(); + // and it is in the pool exactly once + expect(bulletPool.size()).toEqual(1); + expect(bulletPool.used()).toEqual(0); + }); + + it("and the container does not take it back twice", () => { + // the tolerance is the container's, not `release`'s: releasing the + // same object twice by hand is still an error, and that is what + // `tests/pool.test.ts` pins + boot(); + const bulletPool = makePool(); + const container = new Container(0, 0, 100, 100); + + const b = bulletPool.get(7, 8); + container.addChild(b); + container.clearChildren(); + expect(bulletPool.size()).toEqual(1); + // a second pass over a container that still listed it must not add a + // second copy either + expect(() => { + container.clearChildren(); + }).not.toThrow(); + expect(bulletPool.size()).toEqual(1); + expect(bulletPool.used()).toEqual(0); + }); + + it("an unpooled child is destroyed instead", () => { + boot(); + const container = new Container(0, 0, 100, 100); + const plain = new Renderable(0, 0, 4, 4); + container.addChild(plain); + container.removeChildNow(plain); + // `destroy()` releases `pos` back to the vector pool + expect(plain.pos).toBeUndefined(); + }); +}); diff --git a/packages/melonjs/tests/renderTarget.spec.js b/packages/melonjs/tests/renderTarget.spec.js index a045dae614..716f806fed 100644 --- a/packages/melonjs/tests/renderTarget.spec.js +++ b/packages/melonjs/tests/renderTarget.spec.js @@ -53,6 +53,25 @@ describe("RenderTarget", () => { expect(data.height).toEqual(50); }); + it("toImageData resolves the same pixels getImageData returns", async () => { + // the portable readback. On this backend it wraps the synchronous + // one, so the two must agree; the point of the promise is that + // WebGPU, which cannot read back synchronously, can honour the + // same contract. + const rt = new CanvasRenderTarget(16, 16); + rt.context.fillStyle = "#ff0000"; + rt.context.fillRect(0, 0, 16, 16); + + const data = await rt.toImageData(0, 0, 16, 16); + expect(data).toBeInstanceOf(ImageData); + expect(data.width).toEqual(16); + expect(data.height).toEqual(16); + expect(Array.from(data.data.slice(0, 4))).toEqual([255, 0, 0, 255]); + expect(Array.from(data.data)).toEqual( + Array.from(rt.getImageData(0, 0, 16, 16).data), + ); + }); + it("should destroy without error", () => { const rt = new CanvasRenderTarget(100, 100); expect(() => { diff --git a/packages/melonjs/tests/text-colorlayer-pool.spec.js b/packages/melonjs/tests/text-colorlayer-pool.spec.js new file mode 100644 index 0000000000..c0b8e4ec56 --- /dev/null +++ b/packages/melonjs/tests/text-colorlayer-pool.spec.js @@ -0,0 +1,327 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + boot, + Color, + ColorLayer, + getPool, + Text, + video, +} from "../src/index.js"; +import { colorLayerPool } from "../src/renderable/colorlayer.js"; +import { textPool } from "../src/renderable/text/text.js"; + +/** + * `Text` and `ColorLayer` on the typed pool. + * + * They were the last two classes whose only recycling route was the legacy + * name-keyed pool, and the adversarial half of this file is `Text`: its + * `onResetEvent` writes some fields unconditionally and others only when the + * caller supplies a setting. Every field in the second group is a leak across + * a recycle, because the pool hands the SAME instance back. + */ +describe("textPool", () => { + let app; + + beforeAll(async () => { + // `Text` measures through `game.renderer`, so a booted application is + // the minimum this needs; `ColorLayer` below does not + boot(); + app = new Application(64, 64, { parent: "screen", renderer: video.AUTO }); + await app.init(); + }); + + afterAll(() => { + app?.destroy(); + }); + + /** settings that exercise as many conditional branches as possible */ + const loud = { + font: "Arial", + size: 24, + text: "FIRST", + fillStyle: "#ff0000", + strokeStyle: "#00ff00", + lineWidth: 4, + textAlign: "right", + textBaseline: "bottom", + lineHeight: 2, + wordWrapWidth: 100, + floating: true, + }; + + /** the barest settings that still make a valid label */ + const quiet = { font: "Arial", size: 12, text: "SECOND" }; + + it("is the pool registered under the public `text` key", () => { + expect(getPool("text")).toBe(textPool); + }); + + it("hands out a real Text bound to what was asked for", () => { + const t = textPool.get(10, 20, quiet); + try { + expect(t).toBeInstanceOf(Text); + expect(t.pos.x).toEqual(10); + expect(t.pos.y).toEqual(20); + expect(t._text.join("\n")).toEqual("SECOND"); + } finally { + textPool.release(t); + } + }); + + it("REGRESSION: a recycled label keeps NOTHING from the previous one", () => { + // The adversarial case. `onResetEvent` applies `fillStyle`, + // `strokeStyle` and `floating` only when the setting is present, so a + // recycled label silently inherited the previous label's colour, + // outline and viewport-pinning. A fresh `new Text()` never shows it, + // because a fresh instance has the constructor defaults. + const first = textPool.get(0, 0, loud); + expect(first.fillStyle.toHex()).toEqual("#FF0000"); + textPool.release(first); + + const second = textPool.get(10, 20, quiet); + try { + // the same instance came back ... + expect(second).toBe(first); + // ... carrying none of the first label's state + expect(second._text.join("\n")).toEqual("SECOND"); + expect(second.fillStyle.toHex()).toEqual("#000000"); + expect(second.strokeStyle.toHex()).toEqual("#000000"); + expect(second.floating).toBe(false); + expect(second.lineWidth).toEqual(0); + expect(second.textAlign).toEqual("left"); + expect(second.textBaseline).toEqual("top"); + expect(second.lineHeight).toEqual(1.0); + expect(second.wordWrapWidth).toEqual(-1); + expect(second.pos.x).toEqual(10); + expect(second.pos.y).toEqual(20); + } finally { + textPool.release(second); + } + }); + + it("REGRESSION: a recycled label keeps nothing inherited from Renderable either", () => { + // The settings only name Text's own fields, so everything a game + // drives through the base class survived a recycle. A damage number + // tweened to alpha 0 came back invisible, one scaled up came back + // large, one tinted came back tinted. + const first = textPool.get(0, 0, quiet); + first.setOpacity(0); + first.tint.setColor(255, 0, 0, 1); + first.blendMode = "multiply"; + first.name = "first-label"; + first.isKinematic = false; + first.alwaysUpdate = true; + first.scale(2, 3); + first.flipX(true); + textPool.release(first); + + const second = textPool.get(0, 0, quiet); + try { + expect(second).toBe(first); + expect(second.alpha).toEqual(1); + expect(second.tint.toHex()).toEqual("#FFFFFF"); + expect(second.blendMode).toEqual("normal"); + expect(second.name).toEqual(""); + expect(second.isKinematic).toBe(true); + expect(second.alwaysUpdate).toBe(false); + expect(second.currentTransform.isIdentity()).toBe(true); + expect(second.isFlippedX).toBe(false); + // Text anchors at the top-left, which `onResetEvent` applies + // after the inherited reset + expect(second.anchorPoint.x).toEqual(0); + expect(second.anchorPoint.y).toEqual(0); + } finally { + textPool.release(second); + } + }); + + it("a recycled label reuses its canvas and metrics rather than leaking them", () => { + // the whole justification for pooling a label. A fresh + // `CanvasRenderTarget` per reset also stranded the previous canvas and + // its GL texture, which only `destroy()` frees + const first = textPool.get(0, 0, quiet); + const canvas = first.canvasTexture; + const metrics = first.metrics; + textPool.release(first); + + const second = textPool.get(0, 0, { ...quiet, text: "AGAIN" }); + try { + expect(second.canvasTexture).toBe(canvas); + expect(second.metrics).toBe(metrics); + } finally { + textPool.release(second); + } + }); + + it("a recycled label re-measures rather than reporting the old size", () => { + // the metrics are cached on the instance, so a short label recycled + // from a long one must not keep the long one's width + const long = textPool.get(0, 0, { + font: "Arial", + size: 24, + text: "a very much longer piece of text", + }); + const longWidth = long.getBounds().width; + textPool.release(long); + + const short = textPool.get(0, 0, { font: "Arial", size: 24, text: "i" }); + try { + expect(short.getBounds().width).toBeLessThan(longWidth); + } finally { + textPool.release(short); + } + }); + + it("a recycled label drops the previous one's gradient fill", () => { + // `fillGradient` is a separate field from `fillStyle`, so a label + // recycled from a gradient-filled one into a plain colour would paint + // the old ramp if the reset were conditional the way the colour was + const ramp = app.renderer.createLinearGradient(0, 0, 0, 24); + ramp.addColorStop(0, "#ffffff"); + ramp.addColorStop(1, "#000000"); + const first = textPool.get(0, 0, { ...quiet, fillStyle: ramp }); + expect(first.fillGradient).toBeDefined(); + textPool.release(first); + + // settings that say nothing about the fill: the gradient has to go + // anyway, or the label paints the previous ramp + const second = textPool.get(0, 0, quiet); + expect(second.fillGradient).toBeUndefined(); + expect(second.fillStyle.toHex()).toEqual("#000000"); + textPool.release(second); + + // and the same when the new label names a plain colour + const third = textPool.get(0, 0, { ...quiet, fillStyle: ramp }); + textPool.release(third); + const fourth = textPool.get(0, 0, { ...quiet, fillStyle: "#00ff00" }); + try { + expect(fourth.fillGradient).toBeUndefined(); + expect(fourth.fillStyle.toHex()).toEqual("#00FF00"); + } finally { + textPool.release(fourth); + } + }); + + it("accepts a Color instance without aliasing it", () => { + // `fillStyle` is a pooled `Color` the label owns; a caller's Color must + // be COPIED, or mutating theirs later silently repaints the label + const mine = new Color(255, 0, 0, 1); + const t = textPool.get(0, 0, { ...quiet, fillStyle: mine }); + try { + expect(t.fillStyle.toHex()).toEqual("#FF0000"); + expect(t.fillStyle).not.toBe(mine); + mine.setColor(0, 0, 255, 1); + expect(t.fillStyle.toHex()).toEqual("#FF0000"); + } finally { + textPool.release(t); + } + }); + + it("counts instances in use, and gives them back on release", () => { + const before = textPool.used(); + const a = textPool.get(0, 0, quiet); + const b = textPool.get(0, 0, quiet); + expect(textPool.used()).toEqual(before + 2); + textPool.release(a); + textPool.release(b); + expect(textPool.used()).toEqual(before); + }); + + it("ignores a Text it did not create", () => { + // `release` is reachable from game code, and a hand-built label was + // never the pool's to recycle + const foreign = new Text(0, 0, quiet); + const before = textPool.used(); + expect(() => { + textPool.release(foreign); + }).not.toThrow(); + expect(textPool.used()).toEqual(before); + + const fresh = textPool.get(0, 0, { ...quiet, text: "THIRD" }); + try { + expect(fresh).not.toBe(foreign); + expect(fresh._text.join("\n")).toEqual("THIRD"); + } finally { + textPool.release(fresh); + } + }); +}); + +describe("colorLayerPool", () => { + beforeAll(() => { + boot(); + }); + + it("is the pool registered under the public `colorLayer` key", () => { + expect(getPool("colorLayer")).toBe(colorLayerPool); + }); + + it("hands out a real ColorLayer with the name, colour and depth asked for", () => { + const layer = colorLayerPool.get("flash", "#ff0000", 7); + try { + expect(layer).toBeInstanceOf(ColorLayer); + expect(layer.name).toEqual("flash"); + expect(layer.color.toHex()).toEqual("#FF0000"); + expect(layer.pos.z).toEqual(7); + } finally { + colorLayerPool.release(layer); + } + }); + + it("REGRESSION: a recycled layer takes its new name, colour and depth", () => { + const first = colorLayerPool.get("first", "#ff0000", 1); + colorLayerPool.release(first); + + const second = colorLayerPool.get("second", "#0000ff", 2); + try { + expect(second).toBe(first); + expect(second.name).toEqual("second"); + expect(second.color.toHex()).toEqual("#0000FF"); + expect(second.pos.z).toEqual(2); + } finally { + colorLayerPool.release(second); + } + }); + + it("REGRESSION: a recycled layer keeps nothing inherited from Renderable", () => { + const first = colorLayerPool.get("first", "#ff0000", 1); + first.setOpacity(0.25); + first.blendMode = "multiply"; + colorLayerPool.release(first); + + const second = colorLayerPool.get("second", "#0000ff", 2); + try { + expect(second).toBe(first); + expect(second.alpha).toEqual(1); + expect(second.blendMode).toEqual("normal"); + // and the layer's own default, which it sets after the reset + expect(second.floating).toBe(true); + } finally { + colorLayerPool.release(second); + } + }); + + it("defaults the depth when it is not given", () => { + const layer = colorLayerPool.get("no-z", "#112233"); + try { + expect(layer.pos.z).toEqual(0); + } finally { + colorLayerPool.release(layer); + } + }); + + it("counts instances in use, and ignores a foreign layer", () => { + const before = colorLayerPool.used(); + const a = colorLayerPool.get("a", "#000000", 0); + expect(colorLayerPool.used()).toEqual(before + 1); + colorLayerPool.release(a); + expect(colorLayerPool.used()).toEqual(before); + + const foreign = new ColorLayer("foreign", "#ffffff", 0); + expect(() => { + colorLayerPool.release(foreign); + }).not.toThrow(); + expect(colorLayerPool.used()).toEqual(before); + }); +}); diff --git a/packages/melonjs/tests/texture.spec.js b/packages/melonjs/tests/texture.spec.js index b8aa30a9f0..3db5453a8b 100644 --- a/packages/melonjs/tests/texture.spec.js +++ b/packages/melonjs/tests/texture.spec.js @@ -3,6 +3,8 @@ import { Application, boot, CanvasTexture, + NineSliceSprite, + pool, Sprite, TextureAtlas, video, @@ -426,6 +428,122 @@ describe("Texture", () => { }); }); + describe("TextureAtlas.createSpriteFromName", () => { + // No coverage existed for this factory at all, and it is not a + // convenience nobody uses: `TMXTile` builds every Tiled tile object + // through it. It also resolves its class indirectly, so a change to + // HOW it resolves is invisible unless the class and the region are + // both pinned here. + let atlas; + + beforeAll(() => { + const mockImage = Renderer.createCanvas(256, 256); + atlas = new TextureAtlas( + { + meta: { + app: "https://www.codeandweb.com/texturepacker", + size: { w: 256, h: 256 }, + image: "default", + }, + frames: [ + { + filename: "tile.png", + frame: { x: 0, y: 0, w: 32, h: 48 }, + rotated: false, + trimmed: false, + spriteSourceSize: { x: 0, y: 0, w: 32, h: 48 }, + sourceSize: { w: 32, h: 48 }, + }, + { + filename: "panel.png", + frame: { x: 32, y: 0, w: 16, h: 16 }, + rotated: false, + trimmed: false, + spriteSourceSize: { x: 0, y: 0, w: 16, h: 16 }, + sourceSize: { w: 16, h: 16 }, + }, + ], + }, + mockImage, + ); + }); + + it("returns a Sprite sized to the REGION, not to the sheet", () => { + const sprite = atlas.createSpriteFromName("tile.png"); + expect(sprite).toBeInstanceOf(Sprite); + expect(sprite).not.toBeInstanceOf(NineSliceSprite); + // the sheet is 256x256; reading the region is the whole point + expect(sprite.width).toEqual(32); + expect(sprite.height).toEqual(48); + // and it went through the atlas branch rather than being treated + // as a plain drawable source + expect(sprite.textureAtlas).toBe(atlas); + expect(sprite.source).toBe(atlas); + // and `image` is the atlas's BACKING canvas, not the atlas object + expect(sprite.image).toBe(atlas.getTexture()); + }); + + it("returns a NineSliceSprite when asked, stretched to the given size", () => { + const panel = atlas.createSpriteFromName( + "panel.png", + { width: 64, height: 48 }, + true, + ); + expect(panel).toBeInstanceOf(NineSliceSprite); + // width/height on a 9-slice are the size to stretch TO, which is + // what separates it from the plain sprite above + expect(panel.width).toEqual(64); + expect(panel.height).toEqual(48); + expect(panel.textureAtlas).toBe(atlas); + }); + + it("passes extra settings through to the sprite", () => { + const sprite = atlas.createSpriteFromName("tile.png", { + anchorPoint: { x: 0, y: 1 }, + }); + expect(sprite.anchorPoint.x).toEqual(0); + expect(sprite.anchorPoint.y).toEqual(1); + }); + + it("throws for a region the atlas does not have", () => { + // matched, not bare: a bare `.toThrow()` passes for ANY error, + // including the atlas branch not being taken at all + expect(() => { + return atlas.createSpriteFromName("nope.png"); + }).toThrow(/region for nope\.png not found/); + }); + + it("builds the real classes, not whatever the pool has registered", () => { + // This is the `### Changed` entry in the changelog. The factory used + // to resolve through `pool.pull("me.Sprite")`, so re-registering a + // BUILT-IN name substituted the class. It constructs directly now. + // Without this test, putting the pool lookup back is invisible. + class MySprite extends Sprite {} + class MyNineSlice extends NineSliceSprite {} + try { + pool.register("Sprite", MySprite); + pool.register("NineSliceSprite", MyNineSlice); + + const plain = atlas.createSpriteFromName("tile.png"); + expect(plain.constructor).toBe(Sprite); + expect(plain).not.toBeInstanceOf(MySprite); + + const nine = atlas.createSpriteFromName( + "panel.png", + { width: 32, height: 32 }, + true, + ); + expect(nine.constructor).toBe(NineSliceSprite); + expect(nine).not.toBeInstanceOf(MyNineSlice); + } finally { + // registration is global AND registers a Tiled object factory, + // so it has to be put back whatever happens above + pool.register("Sprite", Sprite); + pool.register("NineSliceSprite", NineSliceSprite); + } + }); + }); + describe("TextureAtlas.getAnimationSettings", () => { let atlas; diff --git a/packages/melonjs/tests/texture2d.spec.js b/packages/melonjs/tests/texture2d.spec.js index 6b606fb6a6..51531e9834 100644 --- a/packages/melonjs/tests/texture2d.spec.js +++ b/packages/melonjs/tests/texture2d.spec.js @@ -70,4 +70,80 @@ describe("Texture2d", () => { expect(sprite.width).toBe(48); expect(sprite.height).toBe(24); }); + + describe("isAtlas discriminates atlases without importing TextureAtlas", () => { + // `sprite.js` asks `image instanceof Texture2d && image.isAtlas` + // rather than `instanceof TextureAtlas`, because importing `atlas.js` + // there forms a module cycle its subclasses cannot load through. These + // pin the three ways that could silently stop being equivalent. + it("is false on a plain Texture2d and true on a TextureAtlas", () => { + expect(new StubTexture(8, 8).isAtlas).toBe(false); + const atlas = new TextureAtlas( + { + meta: { app: "texturepacker", size: { w: 32, h: 32 }, image: "d" }, + frames: [ + { + filename: "r.png", + frame: { x: 0, y: 0, w: 16, h: 16 }, + rotated: false, + trimmed: false, + spriteSourceSize: { x: 0, y: 0, w: 16, h: 16 }, + sourceSize: { w: 16, h: 16 }, + }, + ], + }, + Renderer.createCanvas(32, 32), + { cache: false }, + ); + expect(atlas.isAtlas).toBe(true); + }); + + it("does not show up as an own key of the texture", () => { + // a prototype getter, not an instance field: as a field it became + // the first own enumerable key of every texture, which reaches + // `Object.keys`, `JSON.stringify`, console output and anything + // that iterates a texture's properties + const plain = new StubTexture(8, 8); + expect(plain.isAtlas).toBe(false); + expect(Object.keys(plain)).not.toContain("isAtlas"); + expect(Object.hasOwn(plain, "isAtlas")).toBe(false); + // and it cannot be written over, so the atlas branch in + // `sprite.js` cannot be entered by a texture with no regions + expect(() => { + plain.isAtlas = true; + }).toThrow(); + }); + + it("a USER SUBCLASS of TextureAtlas still takes the atlas branch", () => { + // the case `instanceof TextureAtlas` used to cover for free, and + // the one a field-based check could lose: it would break if + // `isAtlas` ever moved after an early return in the constructor, + // or became an own property set outside the class body + class MyAtlas extends TextureAtlas {} + const sub = new MyAtlas( + { + meta: { app: "texturepacker", size: { w: 32, h: 32 }, image: "d" }, + frames: [ + { + filename: "r.png", + frame: { x: 0, y: 0, w: 16, h: 16 }, + rotated: false, + trimmed: false, + spriteSourceSize: { x: 0, y: 0, w: 16, h: 16 }, + sourceSize: { w: 16, h: 16 }, + }, + ], + }, + Renderer.createCanvas(32, 32), + { cache: false }, + ); + expect(sub.isAtlas).toBe(true); + + const sprite = new Sprite(0, 0, { image: sub, region: "r.png" }); + // the atlas branch is what sets these; the generic Texture2d + // fallback leaves `textureAtlas` unset and drops the region + expect(sprite.textureAtlas).toBe(sub); + expect(sprite.width).toEqual(16); + }); + }); }); diff --git a/packages/melonjs/tests/tiled-builtin-override.spec.js b/packages/melonjs/tests/tiled-builtin-override.spec.js new file mode 100644 index 0000000000..545b8e8eb7 --- /dev/null +++ b/packages/melonjs/tests/tiled-builtin-override.spec.js @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + boot, + Renderable, + registerTiledObjectClass, + Trigger, +} from "../src/index.js"; +import { createTMXObject } from "../src/level/tiled/TMXObjectFactory.js"; + +/** + * A game's own class must beat the built-in of the same name. + * + * This needs a spec file of its own: the factory registry initialises LAZILY, + * on the first `createTMXObject`, and the thing under test is what happens + * when a game registers BEFORE that. Any spec that has already built a Tiled + * object has passed the moment this is about, and would instead exercise the + * documented post-init conflict throw. + */ +describe("a game's class beats the built-in of the same name", () => { + const tmxObject = (cls) => { + return createTMXObject( + { name: "o", class: cls, x: 0, y: 0, width: 8, height: 8, z: 0 }, + { tilewidth: 32, tileheight: 32 }, + ); + }; + + it("REGRESSION: the registration is not silently discarded at first load", () => { + // Built-ins are queued at boot and flushed on the first map load, + // while this call registers immediately. The built-in therefore + // landed second and replaced the game's class, with no error at the + // call and none at load: the map just produced the wrong type. + boot(); + class MyTrigger extends Trigger {} + registerTiledObjectClass("Trigger", MyTrigger); + + // the flush happens inside this call + const obj = tmxObject("Trigger"); + expect(obj).toBeInstanceOf(MyTrigger); + }); + + it("a built-in still answers a name the game has not claimed", () => { + // the other half of "defaults": they must still be there + const obj = tmxObject("Renderable"); + expect(obj.constructor.name).toEqual("Renderable"); + }); + + it("REGRESSION: the `me.` alias of a built-in still resolves", () => { + // the 1.x spelling, which the engine has always answered to. It used + // to arrive by side effect: `pool.register` registered a Tiled + // factory for the prefixed name as well, and the built-ins went + // through that call. Dropping those registrations turned every `me.X` + // object into a plain `Renderable`, with nothing said. + expect(tmxObject("me.Renderable").constructor.name).toEqual("Renderable"); + // and the alias is not collateral damage when the game claims the + // unprefixed name, as it did in the first test here + expect(tmxObject("me.Trigger").constructor.name).toEqual("Trigger"); + }); + + it("a game can take a built-in name over after the first map load too", () => { + // a `Stage` registering its classes in `onResetEvent`, with an + // earlier level already loaded, arrives here. A built-in is a + // default, so this has to win rather than throw. + class MyRenderable extends Renderable {} + registerTiledObjectClass("Renderable", MyRenderable); + expect(tmxObject("Renderable")).toBeInstanceOf(MyRenderable); + }); + + it("two classes of the game's own for one name is still an error", () => { + class A extends Renderable {} + class B extends Renderable {} + registerTiledObjectClass("Hazard", A); + expect(() => { + registerTiledObjectClass("Hazard", B); + }).toThrow("a different class is already registered"); + }); +}); diff --git a/packages/melonjs/tests/tmxtilemap.spec.js b/packages/melonjs/tests/tmxtilemap.spec.js index 81b04dbae8..607437ee0d 100644 --- a/packages/melonjs/tests/tmxtilemap.spec.js +++ b/packages/melonjs/tests/tmxtilemap.spec.js @@ -4,6 +4,7 @@ import { boot, Container, collision, + ImageLayer, pool, Renderable, registerTiledObjectClass, @@ -1459,8 +1460,9 @@ describe("TMXTileMap", () => { // save original behavior via a wrapper const originalShapeFactory = (settings) => { - const obj = pool.pull( - "Renderable", + // constructed directly: the built-in classes are no longer + // registered in the legacy name-keyed pool + const obj = new Renderable( settings.x, settings.y, settings.width, @@ -1733,4 +1735,255 @@ describe("TMXTileMap", () => { }).toThrow("invalid factory function for invalid"); }); }); + + // --------------------------------------------------------------- + // Image layers + // --------------------------------------------------------------- + describe("image layers", () => { + // Both image-layer constructions had no coverage at all. They matter + // for more than the class: `readImageLayer` hands the constructor a + // positional (ox, oy) pair ahead of its settings object, which is the + // kind of argument order an edit silently gets wrong. + beforeAll(() => { + fakeImage("bg", 128, 128); + }); + + it("builds an ImageLayer for an imagelayer, with its offset and parallax", () => { + const map = new TMXTileMap("imagelayer-test", { + width: 4, + height: 4, + tilewidth: 32, + tileheight: 32, + orientation: "orthogonal", + version: "1.0", + tilesets: [], + layers: [ + { + type: "imagelayer", + name: "clouds", + image: "bg", + offsetx: 12, + offsety: 34, + parallaxx: 0.5, + parallaxy: 0.25, + opacity: 1, + visible: true, + }, + ], + }); + const layer = map.getLayers().find((l) => { + return l.name === "clouds"; + }); + + expect(layer).toBeInstanceOf(ImageLayer); + // the positional pair: offset x then y, not swapped + expect(layer.pos.x).toEqual(12); + expect(layer.pos.y).toEqual(34); + expect(layer.ratio.x).toBeCloseTo(0.5, 5); + expect(layer.ratio.y).toBeCloseTo(0.25, 5); + }); + + it("honours repeatx / repeaty on an image layer", () => { + const map = new TMXTileMap("imagelayer-repeat", { + width: 4, + height: 4, + tilewidth: 32, + tileheight: 32, + orientation: "orthogonal", + version: "1.0", + tilesets: [], + layers: [ + { + type: "imagelayer", + name: "banner", + image: "bg", + repeatx: true, + repeaty: false, + opacity: 1, + visible: true, + }, + ], + }); + const layer = map.getLayers().find((l) => { + return l.name === "banner"; + }); + expect(layer.repeat).toEqual("repeat-x"); + }); + + const mapWith = (layers, props) => { + return { + width: 4, + height: 4, + tilewidth: 32, + tileheight: 32, + orientation: "orthogonal", + version: "1.0", + tilesets: [], + layers, + ...(props ?? {}), + }; + }; + + it("carries tint, blend mode and opacity onto the layer", () => { + const map = new TMXTileMap( + "il-style", + mapWith([ + { + type: "imagelayer", + name: "lit", + image: "bg", + opacity: 0.5, + visible: true, + mode: "multiply", + tintcolor: "#ff0000", + }, + { + type: "imagelayer", + name: "hidden", + image: "bg", + opacity: 0.5, + visible: false, + }, + // no `visible` key at all: the default is visible + { type: "imagelayer", name: "default", image: "bg", opacity: 1 }, + ]), + ); + const byName = (n) => { + return map.getLayers().find((l) => { + return l.name === n; + }); + }; + + expect(byName("lit").getOpacity()).toBeCloseTo(0.5, 5); + expect(byName("lit").blendMode).toEqual("multiply"); + expect(byName("lit").tint.toHex()).toEqual("#FF0000"); + // `visible: false` is expressed as zero opacity, not a flag + expect(byName("hidden").getOpacity()).toEqual(0); + expect(byName("default").getOpacity()).toEqual(1); + }); + + it("derives the repeat mode from every repeatx / repeaty combination", () => { + const cases = [ + ["both", { repeatx: true, repeaty: true }, "repeat"], + ["x-only", { repeatx: true }, "repeat-x"], + ["y-only", { repeaty: true }, "repeat-y"], + // the XML path gives strings rather than booleans + ["strings", { repeatx: "1", repeaty: "1" }, "repeat"], + [ + // the legacy custom property takes precedence over the + // Tiled 1.8+ native flags + "legacy-wins", + { repeatx: true, properties: { repeat: "no-repeat" } }, + "no-repeat", + ], + ]; + for (const [name, flags, expected] of cases) { + const map = new TMXTileMap( + "il-repeat-" + name, + mapWith([ + { + type: "imagelayer", + name, + image: "bg", + opacity: 1, + visible: true, + ...flags, + }, + ]), + ); + const layer = map.getLayers().find((l) => { + return l.name === name; + }); + expect(layer.repeat, name).toEqual(expected); + } + }); + + it("folds the map-level parallax origin into the offset, scaled by the ratio", () => { + const map = new TMXTileMap( + "il-origin", + mapWith( + [ + { + type: "imagelayer", + name: "scaled", + image: "bg", + offsetx: 10, + offsety: 20, + parallaxx: 0.5, + parallaxy: 0.25, + opacity: 1, + visible: true, + }, + // x / y as the offset, which is the older Tiled spelling + { + type: "imagelayer", + name: "legacy-offset", + image: "bg", + x: 7, + y: 9, + opacity: 1, + visible: true, + }, + ], + { parallaxoriginx: 100, parallaxoriginy: 200 }, + ), + ); + const byName = (n) => { + return map.getLayers().find((l) => { + return l.name === n; + }); + }; + // offset + origin * ratio, not offset + origin + expect(byName("scaled").pos.x).toBeCloseTo(10 + 100 * 0.5, 5); + expect(byName("scaled").pos.y).toBeCloseTo(20 + 200 * 0.25, 5); + expect(byName("legacy-offset").pos.x).toBeCloseTo(7 + 100, 5); + expect(byName("legacy-offset").pos.y).toBeCloseTo(9 + 200, 5); + }); + + it("builds an ImageLayer for a map-level background image", () => { + const map = new TMXTileMap("bg-test", { + width: 4, + height: 4, + tilewidth: 32, + tileheight: 32, + orientation: "orthogonal", + version: "1.0", + tilesets: [], + layers: [], + properties: { background_image: "bg" }, + }); + const layer = map.getLayers().find((l) => { + return l.name === "background_image"; + }); + + expect(layer).toBeInstanceOf(ImageLayer); + expect(layer.pos.x).toEqual(0); + expect(layer.pos.y).toEqual(0); + }); + + it("the background image takes z 0 and pushes the other layers up", () => { + const map = new TMXTileMap( + "bg-z", + mapWith( + [ + { + type: "imagelayer", + name: "second", + image: "bg", + opacity: 1, + visible: true, + }, + ], + { properties: { background_image: "bg" } }, + ), + ); + const byName = (n) => { + return map.getLayers().find((l) => { + return l.name === n; + }); + }; + expect(byName("background_image").pos.z).toEqual(0); + expect(byName("second").pos.z).toEqual(1); + }); + }); }); diff --git a/packages/melonjs/tests/trigger_level_change.spec.js b/packages/melonjs/tests/trigger_level_change.spec.js index f66474fc47..673551dd83 100644 --- a/packages/melonjs/tests/trigger_level_change.spec.js +++ b/packages/melonjs/tests/trigger_level_change.spec.js @@ -257,13 +257,28 @@ describe("Trigger level change (#1646)", () => { setTimeout(resolve, 0); }); }; - // one tick past the 10ms duration finishes it outright - tween._onTick(1000); - for (let i = 0; i < 20 && loaded.length === 0; i++) { + // Advance it by a DELTA well past the 10ms duration, which finishes it + // outright. Through `update(dt)` rather than `_onTick(timestamp)`: + // `_onTick` takes an absolute `performance.now()` stamp, derives + // `dt = timestamp - _lastTick` against the stamp `start()` recorded, + // and ignores any `dt` outside `(0, 1000)`. The hardcoded `1000` this + // used to pass is therefore a valid tick only while the PAGE is + // younger than one second; past that the delta is negative and the + // call does nothing at all, leaving the tween to be finished by a real + // animation frame instead. That is what flaked: a fixed count of + // immediate timers below racing one rAF callback, which a loaded + // full-suite run loses often enough. It failed with `loaded` empty, + // because the hide tween had never completed and so the load had + // never been asked for. + tween.update(500); + // Wait on a DEADLINE for the END state, rather than counting task + // boundaries to an intermediate one: how many macrotasks sit between + // the load's timer and the reveal chained off the promise it returns + // is the scheduler's business, not this test's. + const deadline = globalThis.performance.now() + 5000; + while (seen.length < 2 && globalThis.performance.now() < deadline) { await nextTask(); } - // and let the reveal chained after the load settle - await nextTask(); GLTFScene.prototype.addTo = previousAddTo; app.viewport = original; diff --git a/packages/melonjs/tests/tweenpool.spec.js b/packages/melonjs/tests/tweenpool.spec.js new file mode 100644 index 0000000000..28327ad779 --- /dev/null +++ b/packages/melonjs/tests/tweenpool.spec.js @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { getPool, Tween } from "../src/index.js"; +import { tweenPool } from "../src/tweens/tween.ts"; + +/** + * `tweenPool` is the typed `createPool` replacement for the legacy + * `pool.pull("Tween", …)` path, and it is publicly reachable as + * `getPool("tween")`. + * + * It had no direct coverage: breaking its `reset` failed only two tests, both + * camera effects that merely happen to use it, so the one thing the pool + * exists to guarantee was pinned by nothing it owns. `particlePool`, the other + * renderable pool, is covered by 27. + */ +describe("tweenPool", () => { + it("is the pool registered under the public `tween` key", () => { + // `getPool` is exported from the barrel, so this key is API + expect(getPool("tween")).toBe(tweenPool); + }); + + it("hands out a real Tween bound to the object it was asked for", () => { + const target = { x: 0 }; + const tween = tweenPool.get(target); + try { + expect(tween).toBeInstanceOf(Tween); + expect(tween._object).toBe(target); + } finally { + tweenPool.release(tween); + } + }); + + it("REGRESSION: a recycled tween is bound to its NEW target", () => { + // The whole job of `reset`. Without it a recycled tween keeps the + // previous owner's object and animates something the caller never + // mentioned, silently and from a frame later. Only observable through + // a get / release / get cycle, which is why no existing test saw it. + const first = { x: 0 }; + const tween = tweenPool.get(first); + tweenPool.release(tween); + + const second = { x: 100 }; + const recycled = tweenPool.get(second); + try { + // the same instance came back ... + expect(recycled).toBe(tween); + // ... pointing at the new object, not the old one + expect(recycled._object).toBe(second); + expect(recycled._object).not.toBe(first); + } finally { + tweenPool.release(recycled); + } + }); + + it("counts instances in use, and gives them back on release", () => { + // `used()` never decrementing is a leak that looks like nothing until + // the pool stops recycling entirely; `particlePool` has this test and + // this one did not + const before = tweenPool.used(); + const a = tweenPool.get({ x: 0 }); + const b = tweenPool.get({ x: 0 }); + expect(tweenPool.used()).toBe(before + 2); + + tweenPool.release(a); + tweenPool.release(b); + expect(tweenPool.used()).toBe(before); + }); + + it("ignores a foreign object handed to release", () => { + // `release` is reachable from game code, and a tween built with `new` + // was never the pool's to recycle. Taking it would hand a later + // `get()` an instance with no reset registered, which would then + // silently ignore the target it was asked for. + const foreign = new Tween({ x: 0 }); + const before = tweenPool.used(); + expect(() => { + tweenPool.release(foreign); + }).not.toThrow(); + expect(tweenPool.used()).toBe(before); + + const fresh = tweenPool.get({ x: 42 }); + try { + expect(fresh).not.toBe(foreign); + expect(fresh._object.x).toBe(42); + } finally { + tweenPool.release(fresh); + } + }); +}); diff --git a/packages/melonjs/tests/webgpu_render_target.spec.js b/packages/melonjs/tests/webgpu_render_target.spec.js index d1ef618c6f..d8618c0ec3 100644 --- a/packages/melonjs/tests/webgpu_render_target.spec.js +++ b/packages/melonjs/tests/webgpu_render_target.spec.js @@ -124,11 +124,47 @@ describe("WebGPURenderTarget (mock device)", () => { expect(renderer.currentRenderTarget).toBeNull(); }); - it("getImageData throws with async guidance (WebGPU readback is async)", () => { + it("getImageData throws and names the portable readback instead", () => { + // WebGPU maps its buffer asynchronously, so synchronous readback is + // impossible here. The message has to name the method that DOES work + // on every backend, not a WebGPU-only one. const rt = new WebGPURenderTarget(renderer, 64, 64); expect(() => { return rt.getImageData(); - }).toThrow(/readPixels/); + }).toThrow(/toImageData/); + }); + + it("toImageData is the async readback, and is what the base contract names", async () => { + const rt = new WebGPURenderTarget(renderer, 64, 64); + expect(typeof rt.toImageData).toBe("function"); + // `readPixels` was this backend's own spelling of the same thing and + // was never part of the RenderTarget API; it is gone + expect(rt.readPixels).toBeUndefined(); + }); + + it("REGRESSION: toBlob, toDataURL and toImageBitmap read through toImageData", async () => { + // All three inherit `RenderTarget`'s implementations, which used to + // call the SYNCHRONOUS `getImageData` — the one method this backend + // can only answer by throwing. So all three were broken on WebGPU + // while working everywhere else, which is the contract mismatch this + // fixed. `CanvasRenderTarget` cannot cover it: it overrides toBlob and + // toDataURL with canvas-native versions that never read back. + const rt = new WebGPURenderTarget(renderer, 8, 8); + + // a device that can really map a buffer is out of scope for this mock, + // so the readback itself is stubbed; the ROUTE is what is under test + let reads = 0; + rt.toImageData = () => { + reads++; + return Promise.resolve(new ImageData(8, 8)); + }; + + await expect(rt.toBlob()).resolves.toBeInstanceOf(Blob); + await expect(rt.toDataURL()).resolves.toMatch(/^data:image\/png/); + await expect(rt.toImageBitmap()).resolves.toBeDefined(); + + // toBlob once, toDataURL through toBlob once, toImageBitmap once + expect(reads).toEqual(3); }); it("the render-target pool composes with the WebGPU factory (camera 0/1, sprite 2/3)", () => { From 14a793d35d94f3c217c2826804c3df344b883082 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 9 Oct 2026 16:43:47 +0800 Subject: [PATCH 2/2] Lint: template literal in the declaration check, for the root lint gate `packages/melonjs`'s own `lint` script is `eslint src tests`, so `scripts/` is only reached by the ROOT `pnpm lint`, which is what CI runs. The new file tripped `prefer-template` there and nowhere locally. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/scripts/check-declarations.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/melonjs/scripts/check-declarations.ts b/packages/melonjs/scripts/check-declarations.ts index 26193e1f01..10c12f0df4 100644 --- a/packages/melonjs/scripts/check-declarations.ts +++ b/packages/melonjs/scripts/check-declarations.ts @@ -131,8 +131,7 @@ try { } catch (error) { const output = (error as { stdout?: string; stderr?: string }).stdout ?? ""; problems.push( - "the published declarations do not type-check against a consumer:\n" + - output.trim(), + `the published declarations do not type-check against a consumer:\n${output.trim()}`, ); }