From 3f064d202826fe64866af96ee4bd1c9529bbf81a Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 28 Jul 2026 09:11:20 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=2019.9.1=20batch=20=E2=80=94=20video?= =?UTF-8?q?=20fullscreen=20frame-sync=20(rVFC),=20renderables/camera/parti?= =?UTF-8?q?cles=20bug-hunt,=20#1231=20resize=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - video sprites: pending requestVideoFrameCallback keeps detached elements at full frame rate in fullscreen; version-gated GPU uploads; statePause fix - bug-hunt batch (13 findings, all verified): onVisibilityChange churn, camera listener leaks + bounds-origin clamp, particle pool release race, play-once replay dead-end, zero-delay hang, destroy(arguments) forwarding, DropTarget validation, getNextChild, Text array aliasing, NineSlice seams - #1231: canvas defaults to display:block + content-box measurement kills the auto-scale resize feedback loop - hardening: setTexture(video) TypeError, BMFont padding order, dead texture-cache refinement removed (#1489) Closes #1231 Closes #1489 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JvxaXQRgQn5AoR8DNkv6Wg --- packages/melonjs/CHANGELOG.md | 24 ++ packages/melonjs/package.json | 8 +- .../melonjs/src/application/application.ts | 12 +- packages/melonjs/src/application/resize.ts | 37 +- packages/melonjs/src/camera/camera2d.ts | 22 +- packages/melonjs/src/particles/particle.ts | 12 +- packages/melonjs/src/particles/settings.ts | 4 +- packages/melonjs/src/renderable/container.js | 24 +- packages/melonjs/src/renderable/dragndrop.js | 2 +- .../melonjs/src/renderable/entity/entity.js | 6 +- .../melonjs/src/renderable/frameAnimation.js | 15 +- .../melonjs/src/renderable/nineslicesprite.js | 14 +- packages/melonjs/src/renderable/sprite.js | 53 ++- .../melonjs/src/renderable/text/bitmaptext.js | 5 +- .../src/renderable/text/bitmaptextdata.ts | 10 +- packages/melonjs/src/renderable/text/text.js | 5 +- packages/melonjs/src/state/stage.ts | 14 + packages/melonjs/src/video/texture/cache.js | 21 +- .../melonjs/src/video/webgl/shadereffect.js | 11 + .../melonjs/src/video/webgl/webgl_renderer.js | 21 +- .../melonjs/tests/anchorpoint-presets.spec.js | 134 +++---- packages/melonjs/tests/bitmaptextdata.spec.js | 9 +- .../melonjs/tests/bughunt-renderables.spec.js | 329 ++++++++++++++++++ .../melonjs/tests/resize-feedback.spec.js | 67 ++++ .../tests/shadereffect-settexture.spec.js | 16 + packages/melonjs/tests/texture.spec.js | 26 +- packages/melonjs/tests/vertexBuffer.spec.js | 72 ++-- packages/melonjs/tests/video-sprite.spec.js | 228 ++++++++++++ .../melonjs/tests/video-upload-gating.spec.js | 129 +++++++ 29 files changed, 1154 insertions(+), 176 deletions(-) create mode 100644 packages/melonjs/tests/bughunt-renderables.spec.js create mode 100644 packages/melonjs/tests/resize-feedback.spec.js create mode 100644 packages/melonjs/tests/video-sprite.spec.js create mode 100644 packages/melonjs/tests/video-upload-gating.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 909aedc592..8b9c06d1dd 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [19.9.1] (melonJS 2) - _2026-07-28_ + +### Fixed +- video sprite frames stalled (~4fps) in fullscreen — a pending `requestVideoFrameCallback` now keeps the browser delivering frames at full rate +- video sprites never paused on `state.pause()` (wrong event name) +- `onVisibilityChange` fired a spurious leave/enter pair every frame for visible renderables +- destroyed cameras kept reacting to game reset / canvas resize; stages now destroy the cameras they construct (one leaked per state switch with `cameraClass`) +- camera bounds clamp ignored a non-zero world origin, making the far edges unreachable +- dead particles were pool-released while their removal was still deferred — same-frame respawns could silently vanish +- a completed `loop: false` animation could never be replayed via `play()` / `setCurrentAnimation()` +- animation frames with a `0` delay froze the game in an infinite loop +- `Container` / `Entity` `destroy()` passed an `Arguments` object to `onDestroyEvent` instead of the actual arguments +- `DropTarget.setCheckMethod()` accepted invalid method names (validated the wrong variable) +- `Container.getNextChild()` returned the first child for a non-member instead of `undefined` +- `Text` / `BitmapText` `destroy()` wiped a caller-provided string array (now copied on assignment) +- `NineSliceSprite` showed ~2px seams for region sizes not divisible by 4 (fractional corners were truncated) +- the canvas slowly grew on window resize inside auto-sized wrappers ([#1231](https://github.com/melonjs/melonJS/issues/1231)) — the canvas now defaults to `display: block` and auto-scale measures the container's content box +- `ShaderEffect.setTexture()` rejects `HTMLVideoElement` with a `TypeError` instead of silently freezing on the first frame +- BMFont `padding` was parsed with `padLeft` / `padRight` swapped (AngelCode order is up/right/down/left) +- removed the texture cache's dead frame-dimension refinement ([#1489](https://github.com/melonjs/melonJS/issues/1489)); the first-registered-atlas behavior is now explicit + +### Performance +- video textures upload to the GPU only when a new frame was actually presented (previously every render tick, per sprite) + ## [19.9.0] (melonJS 2) - _2026-07-14_ **Highlights:** shader effects made easy. Effects that used to demand WebGL expertise, like a pond rippling with the scene reflected in it, heat haze, or frosted glass, now take a few lines of shader code: the engine hands your effect the screen behind it and the right coordinates, animated noise textures come built-in, and shaders preload like any other asset. See the new **Water Overworld** example for all of it in action. Also in this release: named anchor presets (`"bottom"`, `"top-left"`, and friends) on every renderable, shapes with holes in `Path2D`/SVG fills, and a 40+ bug-fix sweep across the loader, audio, texture atlas, and WebGL rendering. diff --git a/packages/melonjs/package.json b/packages/melonjs/package.json index c4c8df13c8..7d45f96e02 100644 --- a/packages/melonjs/package.json +++ b/packages/melonjs/package.json @@ -1,6 +1,6 @@ { "name": "melonjs", - "version": "19.9.0", + "version": "19.9.1", "description": "melonJS Game Engine", "homepage": "http://www.melonjs.org/", "type": "module", @@ -66,12 +66,12 @@ "esbuild": "^0.27.3", "serve": "^14.2.6", "tsconfig": "workspace:^", - "tsx": "^4.23.0", + "tsx": "^4.23.1", "type-fest": "^5.8.0", "typedoc": "^0.28.20", "typescript": "^6.0.2", - "vite": "8.0.16", - "vite-plugin-glsl": "^1.6.0" + "vite": "8.1.5", + "vite-plugin-glsl": "^1.6.1" }, "scripts": { "dev": "concurrently --raw \"pnpm build:watch\" \"pnpm tsc:watch\" \"pnpm doc:watch\"", diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts index 1661998df1..d97f8a16ac 100644 --- a/packages/melonjs/src/application/application.ts +++ b/packages/melonjs/src/application/application.ts @@ -468,7 +468,17 @@ export default class Application { }); // add our canvas (default to document.body if settings.parent is undefined) - this.parentElement.appendChild(this.renderer.getCanvas()); + const canvas = this.renderer.getCanvas(); + // the browser default `display: inline` puts the canvas on the text + // baseline, so an auto-sized ancestor measures canvas + baseline gap — + // and the auto-scale resize path then re-applies that few-px excess on + // EVERY resize event (the slow canvas-growth loop of #1231). `block` + // removes the gap. Only applied when the canvas carries no explicit + // inline display, so user styling stays authoritative. + if (canvas.style.display === "") { + canvas.style.display = "block"; + } + this.parentElement.appendChild(canvas); // Mobile browser hacks if (device.platform.isMobile) { diff --git a/packages/melonjs/src/application/resize.ts b/packages/melonjs/src/application/resize.ts index deaf0130c0..97796d3103 100644 --- a/packages/melonjs/src/application/resize.ts +++ b/packages/melonjs/src/application/resize.ts @@ -55,16 +55,45 @@ export function onresize(game: Application): void { canvasMaxHeight = parseInt(style.maxHeight, 10) || Infinity; } + let measuredElement; if (typeof game.settings.scaleTarget !== "undefined") { // get the bounds of the given scale target - nodeBounds = device.getElementBounds(game.settings.scaleTarget); + measuredElement = game.settings.scaleTarget; + nodeBounds = device.getElementBounds(measuredElement); } else { // get the maximum canvas size within the parent div containing the canvas container - nodeBounds = device.getParentBounds(game.getParentElement()); + measuredElement = device.getParentElement(game.getParentElement()); + nodeBounds = device.getElementBounds(measuredElement); } - const _max_width = Math.min(canvasMaxWidth, nodeBounds.width); - const _max_height = Math.min(canvasMaxHeight, nodeBounds.height); + let availWidth = nodeBounds.width; + let availHeight = nodeBounds.height; + // The space available to a child is the measured element's CONTENT + // box; `getBoundingClientRect` returns the border box. On an + // auto-sized container the difference (padding + border) gets baked + // into the canvas size and re-measured on the next pass — growing + // the canvas by that amount on every resize event (#1231). The + // window-fallback path (string target / document.body) has no + // padding concept and is left untouched. + if ( + typeof measuredElement === "object" && + typeof globalThis.getComputedStyle === "function" + ) { + const style = globalThis.getComputedStyle(measuredElement); + availWidth -= + (parseFloat(style.paddingLeft) || 0) + + (parseFloat(style.paddingRight) || 0) + + (parseFloat(style.borderLeftWidth) || 0) + + (parseFloat(style.borderRightWidth) || 0); + availHeight -= + (parseFloat(style.paddingTop) || 0) + + (parseFloat(style.paddingBottom) || 0) + + (parseFloat(style.borderTopWidth) || 0) + + (parseFloat(style.borderBottomWidth) || 0); + } + + const _max_width = Math.min(canvasMaxWidth, Math.max(0, availWidth)); + const _max_height = Math.min(canvasMaxHeight, Math.max(0, availHeight)); // calculate final canvas width & height const screenRatio = _max_width / _max_height; diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts index fb12ff6147..e08e802fe1 100644 --- a/packages/melonjs/src/camera/camera2d.ts +++ b/packages/melonjs/src/camera/camera2d.ts @@ -14,6 +14,7 @@ import { CANVAS_ONRESIZE, emit, GAME_RESET, + off, on, VIEWPORT_ONCHANGE, VIEWPORT_ONRESIZE, @@ -322,9 +323,12 @@ export default class Camera2d extends Renderable { _followH(target: Vector2d | Vector3d): number { let targetX = this.pos.x; if (target.x - this.pos.x > this.deadzone.right) { + // clamp against the bounds' right EDGE — `bounds.width` is + // `right - left`, so using it as a max only worked for a + // zero-origin world and cut `left` px off the far side targetX = Math.min( target.x - this.deadzone.right, - this.bounds.width - this.width, + this.bounds.right - this.width, ); } else if (target.x - this.pos.x < this.deadzone.pos.x) { targetX = Math.max(target.x - this.deadzone.pos.x, this.bounds.left); @@ -338,7 +342,7 @@ export default class Camera2d extends Renderable { if (target.y - this.pos.y > this.deadzone.bottom) { targetY = Math.min( target.y - this.deadzone.bottom, - this.bounds.height - this.height, + this.bounds.bottom - this.height, ); } else if (target.y - this.pos.y < this.deadzone.pos.y) { targetY = Math.max(target.y - this.deadzone.pos.y, this.bounds.top); @@ -591,8 +595,10 @@ export default class Camera2d extends Renderable { const _x = this.pos.x; const _y = this.pos.y; - this.pos.x = clamp(x, this.bounds.left, this.bounds.width - this.width); - this.pos.y = clamp(y, this.bounds.top, this.bounds.height - this.height); + // clamp against the bounds' far edges — `bounds.width/height` are + // extents (`right - left`), correct as a max only for a zero origin + this.pos.x = clamp(x, this.bounds.left, this.bounds.right - this.width); + this.pos.y = clamp(y, this.bounds.top, this.bounds.bottom - this.height); //publish the VIEWPORT_ONCHANGE event if necessary if (_x !== this.pos.x || _y !== this.pos.y) { @@ -1106,6 +1112,14 @@ export default class Camera2d extends Renderable { * @ignore */ override destroy(): void { + // unsubscribe the constructor-registered global listeners — without + // this a destroyed camera stays reachable forever and keeps reacting + // to GAME_RESET (TypeError on freed state) and CANVAS_ONRESIZE + // (duplicate VIEWPORT_ONRESIZE emits from zombie cameras) + /* eslint-disable @typescript-eslint/unbound-method -- listener identity must match the `on(...)` registration */ + off(GAME_RESET, this.reset, this); + off(CANVAS_ONRESIZE, this.resize, this); + /* eslint-enable @typescript-eslint/unbound-method */ // clean up the internal colorMatrix effect (may not be in postEffects if identity) if (this._colorMatrixEffect) { if (typeof this._colorMatrixEffect.destroy === "function") { diff --git a/packages/melonjs/src/particles/particle.ts b/packages/melonjs/src/particles/particle.ts index 4803fdd059..ebe39565b4 100644 --- a/packages/melonjs/src/particles/particle.ts +++ b/packages/melonjs/src/particles/particle.ts @@ -156,8 +156,16 @@ export default class Particle extends Renderable { if (this.alive && this.life <= 0) { const parent = this.ancestor as Container; - // use true for keepalive since we recycle the instance directly here after - parent.removeChild(this, true); + // IMMEDIATE removal (not the deferred `removeChild`) because the + // instance is released to the pool on the next line: a deferred + // removal leaves a stale `removeChildNow` pending against this + // instance, so a same-frame respawn recycling it would be + // silently killed by that timer, and a same-frame emitter + // teardown would destroy an instance already sitting in the + // pool (poisoning every later `particlePool.get()`). The + // immediate splice is safe here — `Container.update` walks its + // children in reverse. keepalive=true since we recycle directly. + parent.removeChildNow(this, true); particlePool.release(this); this.alive = false; return false; diff --git a/packages/melonjs/src/particles/settings.ts b/packages/melonjs/src/particles/settings.ts index 7e1eb8c9e3..aa6fbc3c02 100644 --- a/packages/melonjs/src/particles/settings.ts +++ b/packages/melonjs/src/particles/settings.ts @@ -159,7 +159,9 @@ export interface ParticleEmitterSettings { blendMode: string; /** - * Update particles only in the viewport; remove when out of viewport. + * Only repaint particles while they are inside the viewport (off-screen + * particles keep simulating and living out their lifetime, but do not + * mark the scene dirty). * @default true */ onlyInViewport: boolean; diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index 128ff21450..f2a347a9ee 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -578,9 +578,11 @@ export default class Container extends Renderable { * @returns {Renderable} child */ getNextChild(child) { - const index = this.getChildren().indexOf(child) + 1; - if (index >= 0 && index < this.getChildren().length) { - return this.getChildAt(index); + const index = this.getChildren().indexOf(child); + // `indexOf` returns -1 for a non-member — without this check the +1 + // would alias "not found" to index 0 and return the first child + if (index !== -1 && index + 1 < this.getChildren().length) { + return this.getChildAt(index + 1); } return undefined; } @@ -1098,8 +1100,10 @@ export default class Container extends Renderable { destroy() { // empty the container this.reset(); - // call the parent destroy method - super.destroy(arguments); + // call the parent destroy method, spreading the actual arguments — + // passing the `arguments` object itself would hand subclasses' + // `onDestroyEvent(app)` an Arguments wrapper instead of the value + super.destroy(...arguments); colorPool.release(this.backgroundColor); } @@ -1129,14 +1133,18 @@ export default class Container extends Renderable { globalFloatingCounter++; } - // check if object is in any active cameras - obj.inViewport = false; + // check if object is in any active cameras — accumulate into a + // local and assign ONCE: assigning `false` then `true` through + // the setter would fire `onVisibilityChange` (a change-detecting + // setter) twice per frame for every continuously-visible object + let inViewport = false; // iterate through all cameras cameras.forEach((camera) => { if (camera.isVisible(obj, isFloating)) { - obj.inViewport = true; + inViewport = true; } }); + obj.inViewport = inViewport; // update our object this.isDirty |= (obj.inViewport || obj.alwaysUpdate) && obj.update(dt); diff --git a/packages/melonjs/src/renderable/dragndrop.js b/packages/melonjs/src/renderable/dragndrop.js index 390e98ebc1..62a55af0ea 100644 --- a/packages/melonjs/src/renderable/dragndrop.js +++ b/packages/melonjs/src/renderable/dragndrop.js @@ -57,7 +57,7 @@ export class DropTarget extends Renderable { setCheckMethod(checkMethod) { // We can improve this check, // because now you can use every method in theory - if (typeof this.getBounds()[this.checkMethod] === "function") { + if (typeof this.getBounds()[checkMethod] === "function") { this.checkMethod = checkMethod; } } diff --git a/packages/melonjs/src/renderable/entity/entity.js b/packages/melonjs/src/renderable/entity/entity.js index 530c7f5aca..8b37b4d57a 100644 --- a/packages/melonjs/src/renderable/entity/entity.js +++ b/packages/melonjs/src/renderable/entity/entity.js @@ -388,8 +388,10 @@ export default class Entity extends Renderable { this.children.splice(0, 1); } - // call the parent destroy method - super.destroy(arguments); + // call the parent destroy method, spreading the actual arguments — + // passing the `arguments` object itself would hand subclasses' + // `onDestroyEvent(app)` an Arguments wrapper instead of the value + super.destroy(...arguments); } /** diff --git a/packages/melonjs/src/renderable/frameAnimation.js b/packages/melonjs/src/renderable/frameAnimation.js index e103da53fd..8fc36a9e6f 100644 --- a/packages/melonjs/src/renderable/frameAnimation.js +++ b/packages/melonjs/src/renderable/frameAnimation.js @@ -184,7 +184,12 @@ export default class FrameAnimation { */ setCurrentAnimation(name, resetAnim, preserve_dt = false) { if (typeof this.anim[name] !== "undefined") { - if (!this.isCurrentAnimation(name)) { + // re-selecting the already-current animation is a no-op (so the + // common call-every-frame idiom doesn't restart it) — EXCEPT when + // a play-once (`loop: false`) run has completed: without the + // `_animDone` escape, a finished animation could never be + // replayed (`_animDone` stayed set and new options were dropped) + if (!this.isCurrentAnimation(name) || this._animDone) { this.current.name = name; this.current.length = this.anim[this.current.name].length; const opts = parseAnimationOptions(resetAnim); @@ -383,6 +388,14 @@ export default class FrameAnimation { } // Get next frame duration duration = this.getAnimationFrameObjectByIndex(this.current.idx).delay; + + // a zero (or negative) frame delay would never drain `dt` and + // spin this loop forever — treat it as "fastest possible": + // advance at most one frame per update tick + if (duration <= 0) { + this.dt = 0; + break; + } } } return changed; diff --git a/packages/melonjs/src/renderable/nineslicesprite.js b/packages/melonjs/src/renderable/nineslicesprite.js index 1a65742c8b..f4f7ef80df 100644 --- a/packages/melonjs/src/renderable/nineslicesprite.js +++ b/packages/melonjs/src/renderable/nineslicesprite.js @@ -154,11 +154,15 @@ export default class NineSliceSprite extends Sprite { const corner_width = this.insetx || w / 4; const corner_height = this.insety || h / 4; - const image_center_width = w - (corner_width << 1); - const image_center_height = h - (corner_height << 1); - - const target_center_width = this.nss_width - (corner_width << 1); - const target_center_height = this.nss_height - (corner_height << 1); + // `* 2`, not `<< 1`: corners default to w/4 which is fractional for + // widths not divisible by 4, and the bit-shift truncates to int32 — + // desyncing the center-slice size from the un-truncated corner + // offsets below (up to ~2px of seam/bleed on the stretched panel) + const image_center_width = w - corner_width * 2; + const image_center_height = h - corner_height * 2; + + const target_center_width = this.nss_width - corner_width * 2; + const target_center_height = this.nss_height - corner_height * 2; // The 9-slice grid is separable per axis: each column/row is an unscaled // corner, a stretched center, then an unscaled corner. Fill the shared diff --git a/packages/melonjs/src/renderable/sprite.js b/packages/melonjs/src/renderable/sprite.js index 2754cb9a46..8d8e82c966 100644 --- a/packages/melonjs/src/renderable/sprite.js +++ b/packages/melonjs/src/renderable/sprite.js @@ -2,7 +2,7 @@ import { game } from "../application/application.ts"; import { getImage } from "./../loader/loader.js"; import { Color } from "../math/color.ts"; import { vector2dPool } from "../math/vector2d.ts"; -import { on } from "../system/event.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"; @@ -194,10 +194,34 @@ export default class Sprite extends Renderable { * pause the video when losing focus * @ignore */ - this.removeStatePauseListener = on("statePause", () => { + this.removeStatePauseListener = on(STATE_PAUSE, () => { this.image.pause(); }); + // Keep a video-frame callback pending while this sprite is + // alive: Chromium parks detached (never-in-DOM) videos on a + // ~250ms background timer when the page compositor idles + // (e.g. fullscreen direct scanout) — a pending rVFC forces + // full-rate frame delivery. The callback also stamps a + // monotonic frame counter on the element (the same + // duck-typed `version` convention as NoiseTexture2d) so + // update()/drawImage only repaint/re-upload when a frame + // actually arrived. Browsers without rVFC never get the + // stamp and stay on the legacy repaint-while-playing path. + this._lastVideoFrameVersion = -1; + if (typeof this.image.requestVideoFrameCallback === "function") { + this.image.version ??= 0; + const tick = () => { + // self-healing bump (never a bare `++`): a sharing + // sprite's destroy() deletes the stamp, and + // `undefined++` would poison the counter with NaN + // (NaN !== NaN → permanently dirty + re-uploading) + this.image.version = (this.image.version ?? -1) + 1; + this._videoFrameHandle = this.image.requestVideoFrameCallback(tick); + }; + this._videoFrameHandle = this.image.requestVideoFrameCallback(tick); + } + // call the onended when the video has ended this.image.onended = () => { if (typeof this.onended === "function") { @@ -562,7 +586,7 @@ export default class Sprite extends Renderable { * this.setCurrentAnimation("walk"); * * // set "walk" animation if it is not the current animation - * if (this.isCurrentAnimation("walk")) { + * if (!this.isCurrentAnimation("walk")) { * this.setCurrentAnimation("walk"); * } * @@ -740,7 +764,20 @@ export default class Sprite extends Renderable { } else if (this.image.paused) { this.image.play(); } - this.isDirty = !this.image.paused; + // set-only (never assign false — that would stomp a dirty flag + // set earlier in the tick, e.g. by a tween on a paused sprite) + if (this.image.version === undefined) { + // no rVFC tracking (old browsers): repaint while playing + if (!this.image.paused) { + this.isDirty = true; + } + } else if (this.image.version !== this._lastVideoFrameVersion) { + // a new frame was presented since we last looked; deliberately + // not gated on `paused` so a seek while paused (stop() rewind) + // still repaints with the presented frame + this._lastVideoFrameVersion = this.image.version; + this.isDirty = true; + } } else { // advance the shared frame-animation engine; a frame change marks this // sprite dirty via `_applyFrame` (the engine owns no dirty flag) @@ -864,6 +901,14 @@ export default class Sprite extends Renderable { this.offset = undefined; if (this.isVideo) { this.removeStatePauseListener(); + if (typeof this._videoFrameHandle !== "undefined") { + this.image.cancelVideoFrameCallback(this._videoFrameHandle); + // drop the frame-counter stamp so a later non-Sprite consumer + // (manual renderer.drawImage) gets the legacy per-frame upload + // path back; a still-alive sprite sharing this element simply + // re-stamps on its next presented frame + delete this.image.version; + } this.image.onended = undefined; this.image.pause(); this.image.currentTime = 0; diff --git a/packages/melonjs/src/renderable/text/bitmaptext.js b/packages/melonjs/src/renderable/text/bitmaptext.js index 1d2d358941..3d1b0985fa 100644 --- a/packages/melonjs/src/renderable/text/bitmaptext.js +++ b/packages/melonjs/src/renderable/text/bitmaptext.js @@ -191,7 +191,10 @@ export default class BitmapText extends Renderable { if (!Array.isArray(value)) { this._text = ("" + value).split("\n"); } else { - this._text = value; + // copy — storing the caller's array by reference would let + // destroy() (`_text.length = 0`) wipe it, and external + // mutation would change the rendered text without isDirty + this._text = value.slice(); } this.isDirty = true; } diff --git a/packages/melonjs/src/renderable/text/bitmaptextdata.ts b/packages/melonjs/src/renderable/text/bitmaptextdata.ts index 0e9adfbdc2..2dcb99c207 100644 --- a/packages/melonjs/src/renderable/text/bitmaptextdata.ts +++ b/packages/melonjs/src/renderable/text/bitmaptextdata.ts @@ -188,10 +188,11 @@ export default class BitmapTextData { } const paddingValues = padding[0].split("=")[1].split(","); + // AngelCode order is up, RIGHT, down, LEFT (T/R/B/L, like CSS) this.padTop = parseFloat(paddingValues[0]); - this.padLeft = parseFloat(paddingValues[1]); + this.padRight = parseFloat(paddingValues[1]); this.padBottom = parseFloat(paddingValues[2]); - this.padRight = parseFloat(paddingValues[3]); + this.padLeft = parseFloat(paddingValues[3]); this.lineHeight = parseFloat(getValueFromPair(lines[1], /lineHeight=\d+/g)); const baseLine = parseFloat(getValueFromPair(lines[1], /base=\d+/g)); @@ -258,10 +259,11 @@ export default class BitmapTextData { // padding is optional in XML exports (e.g. frostyfreeze) → default to 0 const pad = (info.get("padding") ?? "0,0,0,0").split(","); + // AngelCode order is up, RIGHT, down, LEFT (T/R/B/L, like CSS) this.padTop = parseFloat(pad[0]); - this.padLeft = parseFloat(pad[1]); + this.padRight = parseFloat(pad[1]); this.padBottom = parseFloat(pad[2]); - this.padRight = parseFloat(pad[3]); + this.padLeft = parseFloat(pad[3]); this.lineHeight = num(common, "lineHeight"); const baseLine = num(common, "base"); diff --git a/packages/melonjs/src/renderable/text/text.js b/packages/melonjs/src/renderable/text/text.js index 2a29384dec..0bfba7da1c 100644 --- a/packages/melonjs/src/renderable/text/text.js +++ b/packages/melonjs/src/renderable/text/text.js @@ -339,7 +339,10 @@ export default class Text extends Renderable { if (!Array.isArray(value)) { this._text = ("" + value).split("\n"); } else { - this._text = value; + // copy — storing the caller's array by reference would let + // destroy() (`_text.length = 0`) wipe it, and external + // mutation would change the rendered text without isDirty + this._text = value.slice(); } } diff --git a/packages/melonjs/src/state/stage.ts b/packages/melonjs/src/state/stage.ts index 7ea89203d3..ec8d944c6c 100644 --- a/packages/melonjs/src/state/stage.ts +++ b/packages/melonjs/src/state/stage.ts @@ -383,6 +383,20 @@ export default class Stage { * @ignore */ destroy(app: Application): void { + // destroy the cameras this stage constructed (the fresh per-stage + // `cameraClass` instances) — without this, every state switch leaks + // a zombie camera still subscribed to GAME_RESET/CANVAS_ONRESIZE. + // NOT stage-owned (so not destroyed here): user instances from + // `settings.cameras` (re-added from settings on the next reset) and + // the shared module-level Camera2d singleton (reused across stages) + this.cameras.forEach((camera) => { + if ( + camera !== default_camera && + !this.settings.cameras.includes(camera) + ) { + camera.destroy(); + } + }); // clear all cameras this.cameras.clear(); // clear all lights — Light2d.onDeactivateEvent will deregister each diff --git a/packages/melonjs/src/video/texture/cache.js b/packages/melonjs/src/video/texture/cache.js index 3a5ffae3b4..1aaf3a789a 100644 --- a/packages/melonjs/src/video/texture/cache.js +++ b/packages/melonjs/src/video/texture/cache.js @@ -254,21 +254,16 @@ class TextureCache { * return the textureAltas for the given image */ get(image, atlas) { + // Always the FIRST atlas registered for this image. A framewidth/ + // frameheight-based refinement loop lived here for years but was + // provably dead (#1489): it compared top-level `.width`/`.height` + // on the parsed atlas dict — properties the parsers never set + // (frames are keyed by name) — so it never selected anything else. + // Whether multi-atlas selection should exist at all is a design + // question deferred to the #1410 cache refactor; the single-result + // contract is codified in texture.spec.js. let entry = this.cache.get(image)[0]; - if (typeof entry !== "undefined" && typeof atlas !== "undefined") { - this.cache.forEach((value, key) => { - const _atlas = value.getAtlas(); - if ( - key === image && - _atlas.width === atlas.framewidth && - _atlas.height === atlas.frameheight - ) { - entry = value; - } - }); - } - if (typeof entry === "undefined") { if (!atlas) { atlas = createAtlas( diff --git a/packages/melonjs/src/video/webgl/shadereffect.js b/packages/melonjs/src/video/webgl/shadereffect.js index d1f48d4174..4ee89c6a01 100644 --- a/packages/melonjs/src/video/webgl/shadereffect.js +++ b/packages/melonjs/src/video/webgl/shadereffect.js @@ -500,6 +500,17 @@ export default class ShaderEffect { * water.setTime(me.timer.getTime() / 1000); */ setTexture(name, image, repeat = "no-repeat") { + // Explicitly reject HTMLVideoElement — it duck-types past the static + // upload path, which uploads ONCE (`entry.tex === null` guard in + // _prepareTextures) and would silently freeze the sampler on the + // video's first frame. Same contract as Sprite.normalMap. Checked + // before the Canvas/destroyed no-op guard so the error is raised + // consistently under every renderer. + if (typeof image?.videoWidth === "number") { + throw new TypeError( + "ShaderEffect.setTexture does not support HTMLVideoElement (extra textures upload once and would freeze on the first frame)", + ); + } // Canvas stub (no shader) and destroyed effects: keep the inert no-op if (typeof this._shader === "undefined" || this.destroyed === true) { return this; diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 0272565e6b..4084e888a3 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -1496,10 +1496,25 @@ export default class WebGLRenderer extends Renderer { shader._prepareTextures?.(this.currentBatcher); } - // force reuploading if the given image is a HTMLVideoElement or a - // force re-upload for video elements - const reupload = typeof image.videoWidth !== "undefined"; const texture = this.cache.get(image); + // Video sources need their GL texture refreshed as the video plays. + // When a Sprite tracks the element via requestVideoFrameCallback it + // stamps a monotonic `version` counter on it (same duck-typed + // convention as NoiseTexture2d) — re-upload only when a new frame was + // actually presented, recorded per cached atlas so a shared element + // drawn by several sprites uploads once per frame. Without the stamp + // (no rVFC support / manual drawImage callers), or on the lit path + // (each batcher owns a separate GL texture — one shared record would + // go stale across them), keep the legacy per-frame force re-upload. + let reupload = false; + if (typeof image.videoWidth !== "undefined") { + if (image.version === undefined || useLit) { + reupload = true; + } else if (texture._videoFrameVersion !== image.version) { + texture._videoFrameVersion = image.version; + reupload = true; + } + } const uvs = texture.getUVs(sx, sy, sw, sh); if (useLit) { this.currentBatcher.addQuad( diff --git a/packages/melonjs/tests/anchorpoint-presets.spec.js b/packages/melonjs/tests/anchorpoint-presets.spec.js index c05a969a5c..87d5bd75b3 100644 --- a/packages/melonjs/tests/anchorpoint-presets.spec.js +++ b/packages/melonjs/tests/anchorpoint-presets.spec.js @@ -160,80 +160,82 @@ const CONSUMERS = [ }, ]; -describe.each(CONSUMERS)("settings.anchorPoint on $name", ({ - make, - defaults, - throws, -}) => { - it('resolves the "bottom" preset to (0.5, 1)', () => { - const r = make("bottom"); - expect(r.anchorPoint.x).toBe(0.5); - expect(r.anchorPoint.y).toBe(1); - }); +describe.each(CONSUMERS)( + "settings.anchorPoint on $name", + ({ make, defaults, throws }) => { + it('resolves the "bottom" preset to (0.5, 1)', () => { + const r = make("bottom"); + expect(r.anchorPoint.x).toBe(0.5); + expect(r.anchorPoint.y).toBe(1); + }); - it('resolves the "top-left" preset to (0, 0)', () => { - const r = make("top-left"); - expect(r.anchorPoint.x).toBe(0); - expect(r.anchorPoint.y).toBe(0); - }); + it('resolves the "top-left" preset to (0, 0)', () => { + const r = make("top-left"); + expect(r.anchorPoint.x).toBe(0); + expect(r.anchorPoint.y).toBe(0); + }); - it("a preset and its equivalent {x, y} object land identically", () => { - const a = make("bottom"); - const b = make({ x: 0.5, y: 1 }); - expect(a.anchorPoint.x).toBe(b.anchorPoint.x); - expect(a.anchorPoint.y).toBe(b.anchorPoint.y); - }); + it("a preset and its equivalent {x, y} object land identically", () => { + const a = make("bottom"); + const b = make({ x: 0.5, y: 1 }); + expect(a.anchorPoint.x).toBe(b.anchorPoint.x); + expect(a.anchorPoint.y).toBe(b.anchorPoint.y); + }); - it("still accepts a Vector2d instance (back-compat)", () => { - const r = make(new Vector2d(0.25, 0.75)); - expect(r.anchorPoint.x).toBe(0.25); - expect(r.anchorPoint.y).toBe(0.75); - }); + it("still accepts a Vector2d instance (back-compat)", () => { + const r = make(new Vector2d(0.25, 0.75)); + expect(r.anchorPoint.x).toBe(0.25); + expect(r.anchorPoint.y).toBe(0.75); + }); - it("out-of-range values pass through unclamped (back-compat)", () => { - const r = make({ x: -0.5, y: 2 }); - expect(r.anchorPoint.x).toBe(-0.5); - expect(r.anchorPoint.y).toBe(2); - }); + it("out-of-range values pass through unclamped (back-compat)", () => { + const r = make({ x: -0.5, y: 2 }); + expect(r.anchorPoint.x).toBe(-0.5); + expect(r.anchorPoint.y).toBe(2); + }); - it("the default is preserved when no anchorPoint is given", () => { - const r = make(undefined); - expect(r.anchorPoint.x).toBe(defaults.x); - expect(r.anchorPoint.y).toBe(defaults.y); - }); + it("the default is preserved when no anchorPoint is given", () => { + const r = make(undefined); + expect(r.anchorPoint.x).toBe(defaults.x); + expect(r.anchorPoint.y).toBe(defaults.y); + }); - const garbage = [ - ["an unknown preset", "botom"], - ["a wrong-cased key object", { X: 1, Y: 1 }], - ["a string-valued object", { x: "1", y: "1" }], - ["a NaN component", { x: Number.NaN, y: 1 }], - ]; + const garbage = [ + ["an unknown preset", "botom"], + ["a wrong-cased key object", { X: 1, Y: 1 }], + ["a string-valued object", { x: "1", y: "1" }], + ["a NaN component", { x: Number.NaN, y: 1 }], + ]; - if (throws) { - it.each(garbage)("throws on %s (new API surface)", (_label, value) => { - expect(() => { - make(value); - }).toThrow(); - }); - } else { - it.each( - garbage, - )("warns and keeps the legacy (0, 0) outcome on %s — never throws", (_label, value) => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { - let r; + if (throws) { + it.each(garbage)("throws on %s (new API surface)", (_label, value) => { expect(() => { - r = make(value); - }).not.toThrow(); - expect(r.anchorPoint.x).toBe(0); - expect(r.anchorPoint.y).toBe(0); - expect(warnSpy).toHaveBeenCalled(); - } finally { - warnSpy.mockRestore(); - } - }); - } -}); + make(value); + }).toThrow(); + }); + } else { + it.each(garbage)( + "warns and keeps the legacy (0, 0) outcome on %s — never throws", + (_label, value) => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); + try { + let r; + expect(() => { + r = make(value); + }).not.toThrow(); + expect(r.anchorPoint.x).toBe(0); + expect(r.anchorPoint.y).toBe(0); + expect(warnSpy).toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }, + ); + } + }, +); describe("ImageLayer bare-number shorthand", () => { it("anchorPoint: 0.3 anchors both axes and never reaches the strict resolver", () => { diff --git a/packages/melonjs/tests/bitmaptextdata.spec.js b/packages/melonjs/tests/bitmaptextdata.spec.js index 25188d95d2..4300a94784 100644 --- a/packages/melonjs/tests/bitmaptextdata.spec.js +++ b/packages/melonjs/tests/bitmaptextdata.spec.js @@ -182,15 +182,18 @@ for (const format of ["text", "xml"]) { expect(g.id).toBe(8364); }); - it("maps padding to top / left / bottom / right in order", () => { + it("maps padding in AngelCode order: up, right, down, left", () => { + // the previous version of this test codified a parser bug — it + // asserted padLeft received AngelCode's RIGHT value (index 1) and + // padRight the LEFT value (index 3); BMFont padding is T/R/B/L const d = font({ padding: "1,2,3,4", chars: [{ id: 65, width: 6, height: 10, xadvance: 6 }], }); expect(d.padTop).toBe(1); - expect(d.padLeft).toBe(2); + expect(d.padRight).toBe(2); expect(d.padBottom).toBe(3); - expect(d.padRight).toBe(4); + expect(d.padLeft).toBe(4); }); it("stores positive AND negative kerning amounts", () => { diff --git a/packages/melonjs/tests/bughunt-renderables.spec.js b/packages/melonjs/tests/bughunt-renderables.spec.js new file mode 100644 index 0000000000..dd9f7cf87d --- /dev/null +++ b/packages/melonjs/tests/bughunt-renderables.spec.js @@ -0,0 +1,329 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { + BitmapText, + boot, + Camera2d, + Container, + DropTarget, + Entity, + event, + game, + loader, + NineSliceSprite, + ParticleEmitter, + Renderable, + Sprite, + Stage, + Text, + Vector2d, + video, +} from "../src/index.js"; + +/** + * Regression specs for the 2026-07 renderables/camera/particles bug-hunt + * batch. One describe block per confirmed finding. + */ + +function makeSpriteSheet() { + const canvas = document.createElement("canvas"); + canvas.width = 64; + canvas.height = 64; + return new Sprite(0, 0, { + image: canvas, + framewidth: 32, + frameheight: 32, + }); +} + +describe("Bug-hunt: renderables / camera / particles", () => { + beforeAll(async () => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + // bitmap font fixture for the BitmapText aliasing test + await new Promise((resolve) => { + loader.preload( + [ + { + name: "xolo12", + type: "image", + src: "/data/fnt/xolo12.png", + }, + { + name: "xolo12", + type: "binary", + src: "/data/fnt/xolo12.fnt", + }, + ], + resolve, + ); + }); + }); + + describe("onVisibilityChange fires only on actual transitions", () => { + it("a continuously-visible object gets no callbacks after entering", () => { + const obj = new Renderable(10, 10, 20, 20); + const calls = []; + obj.onVisibilityChange = (visible) => { + calls.push(visible); + }; + game.world.addChild(obj); + + game.world.update(16); + expect(calls).toEqual([true]); // enter once + + game.world.update(16); + game.world.update(16); + expect(calls).toEqual([true]); // no churn while visible + + obj.pos.x = -5000; // move far off-screen + game.world.update(16); + expect(calls).toEqual([true, false]); // leave once + + game.world.removeChildNow(obj); + }); + }); + + describe("Container/Entity destroy forwards real arguments to onDestroyEvent", () => { + it("a Container subclass receives the actual value, not an Arguments object", () => { + let received = "unset"; + class TestContainer extends Container { + onDestroyEvent(arg) { + received = arg; + } + } + const c = new TestContainer(0, 0, 10, 10); + c.destroy("the-app"); + expect(received).toBe("the-app"); + }); + + it("an Entity subclass receives the actual value, not an Arguments object", () => { + let received = "unset"; + class TestEntity extends Entity { + onDestroyEvent(arg) { + received = arg; + } + } + const e = new TestEntity(0, 0, { width: 10, height: 10 }); + e.destroy("the-app"); + expect(received).toBe("the-app"); + }); + }); + + describe("Container.getNextChild", () => { + it("returns undefined for a non-member instead of the first child", () => { + const c = new Container(0, 0, 100, 100); + const a = new Renderable(0, 0, 1, 1); + const b = new Renderable(0, 0, 1, 1); + c.addChild(a); + c.addChild(b); + const orphan = new Renderable(0, 0, 1, 1); + + expect(c.getNextChild(orphan)).toBe(undefined); + expect(c.getNextChild(c.getChildren()[0])).toBe(c.getChildren()[1]); + expect(c.getNextChild(c.getChildren()[1])).toBe(undefined); + }); + }); + + describe("DropTarget.setCheckMethod validates the new method", () => { + it("rejects an invalid method name and accepts a valid one", () => { + const target = new DropTarget(0, 0, 10, 10); + const before = target.checkMethod; + + target.setCheckMethod("nonsense"); + expect(target.checkMethod).toBe(before); + + target.setCheckMethod(target.CHECKMETHOD_CONTAINS); + expect(target.checkMethod).toBe("contains"); + target.destroy(); + }); + }); + + describe("Camera2d bounds clamp honors a non-zero origin", () => { + it("moveTo can reach the far edge of an offset world", () => { + const cam = new Camera2d(0, 0, 320, 240); + cam.setBounds(100, 50, 500, 400); // world spans x:[100,600] y:[50,450] + + cam.moveTo(10000, 10000); + expect(cam.pos.x).toBe(600 - 320); + expect(cam.pos.y).toBe(450 - 240); + + cam.moveTo(-10000, -10000); + expect(cam.pos.x).toBe(100); + expect(cam.pos.y).toBe(50); + cam.destroy(); + }); + + it("the follow path clamps against the far edges too", () => { + const cam = new Camera2d(0, 0, 320, 240); + cam.setBounds(100, 50, 500, 400); + // target far past the deadzone → must clamp to right/bottom edge + expect(cam._followH(new Vector2d(10000, 0))).toBe(600 - 320); + expect(cam._followV(new Vector2d(0, 10000))).toBe(450 - 240); + cam.destroy(); + }); + }); + + describe("Camera2d.destroy unsubscribes global listeners", () => { + it("a destroyed camera no longer reacts to GAME_RESET", () => { + const cam = new Camera2d(0, 0, 100, 100); + cam.destroy(); + // before the fix the zombie listener ran reset() on freed state + // (this.pos is undefined after destroy) and threw a TypeError + expect(() => { + event.emit(event.GAME_RESET); + }).not.toThrow(); + expect(() => { + event.emit(event.CANVAS_ONRESIZE, 800, 600); + }).not.toThrow(); + }); + + it("Stage.destroy destroys the cameras it constructed, sparing user cameras", () => { + const userCam = new Camera2d(0, 0, 100, 100); + // a secondary user camera (a camera named "default" would occupy + // the default slot and the stage would construct nothing) + userCam.name = "minimap"; + const stage = new Stage({ + cameras: [userCam], + cameraClass: Camera2d, + }); + stage.reset(game); + const stageCam = stage.cameras.get("default"); + expect(stageCam).not.toBe(userCam); + + stage.destroy(game); + // the stage-constructed default is destroyed (freed state)... + expect(stageCam.pos).toBe(undefined); + // ...the user-provided camera is untouched (re-added on next reset) + expect(userCam.pos).not.toBe(undefined); + userCam.destroy(); + }); + }); + + describe("completed play-once animations can be replayed", () => { + it("setCurrentAnimation with the same name restarts a finished loop:false run", () => { + const sprite = makeSpriteSheet(); + sprite.addAnimation("once", [0, 1], 10); + sprite.setCurrentAnimation("once", { loop: false }); + + sprite.update(25); // 25ms across 2×10ms frames → completes, holds last + expect(sprite.getCurrentAnimationFrame()).toBe(1); + sprite.update(50); // stays held + expect(sprite.getCurrentAnimationFrame()).toBe(1); + + // replay — before the fix this was a permanent no-op + sprite.setCurrentAnimation("once", { loop: false }); + expect(sprite.getCurrentAnimationFrame()).toBe(0); + sprite.update(12); + expect(sprite.getCurrentAnimationFrame()).toBe(1); + sprite.destroy(); + }); + + it("re-selecting a RUNNING animation stays a no-op (call-every-frame idiom)", () => { + const sprite = makeSpriteSheet(); + sprite.addAnimation("walk", [0, 1, 2], 10); + sprite.setCurrentAnimation("walk"); + + sprite.update(12); + expect(sprite.getCurrentAnimationFrame()).toBe(1); + + sprite.setCurrentAnimation("walk"); // must not restart mid-run + expect(sprite.getCurrentAnimationFrame()).toBe(1); + sprite.destroy(); + }); + }); + + describe("zero-delay animation frames do not hang the update loop", () => { + it("advances one frame per tick instead of spinning forever", () => { + const sprite = makeSpriteSheet(); + sprite.addAnimation("zero", [ + { name: 0, delay: 0 }, + { name: 1, delay: 0 }, + ]); + sprite.setCurrentAnimation("zero"); + + sprite.update(16); // before the fix: infinite while loop (tab freeze) + expect(sprite.getCurrentAnimationFrame()).toBe(1); + sprite.update(16); + expect(sprite.getCurrentAnimationFrame()).toBe(0); + sprite.destroy(); + }); + }); + + describe("dead particles are removed synchronously", () => { + it("a particle reaching end-of-life leaves its emitter within the same update", () => { + const emitter = new ParticleEmitter(100, 100, { totalParticles: 1 }); + game.world.addChild(emitter); + emitter.burstParticles(1); + expect(emitter.getChildren().length).toBe(1); + + const particle = emitter.getChildren()[0]; + particle.life = 0; // force end-of-life + particle.update(16); + + // before the fix removal was deferred to a macrotask while the + // instance was ALREADY released to the pool — a same-frame + // respawn could be silently killed by the stale deferred removal + expect(emitter.getChildren().length).toBe(0); + expect(particle.ancestor).toBe(undefined); + + game.world.removeChildNow(emitter); + }); + }); + + describe("Text does not alias the caller's string array", () => { + it("destroy() no longer wipes an array passed to setText", () => { + const lines = ["line one", "line two"]; + const text = new Text(0, 0, { + font: "Arial", + size: 10, + text: "placeholder", + }); + text.setText(lines); + text.destroy(); + expect(lines).toEqual(["line one", "line two"]); + }); + + it("BitmapText.destroy() no longer wipes an array passed to setText", () => { + const lines = ["line one", "line two"]; + const text = new BitmapText(0, 0, { + font: "xolo12", + text: "placeholder", + }); + text.setText(lines); + text.destroy(); + expect(lines).toEqual(["line one", "line two"]); + }); + }); + + describe("NineSliceSprite fractional corners", () => { + it("slices tile the source and target exactly for a non-/4 width", () => { + // w = 30 → corner = 7.5; the old `<< 1` truncated 2×7.5 = 15 to + // 14, so the three slice widths summed to 31/91 (1px bleed/seam) + const canvas = document.createElement("canvas"); + canvas.width = 30; + canvas.height = 30; + const nss = new NineSliceSprite(0, 0, { + image: canvas, + width: 90, + height: 90, + }); + const calls = []; + nss.draw({ + drawImage: (...args) => { + calls.push(args); + }, + }); + expect(calls.length).toBe(9); + // args: (image, sx, sy, sw, sh, dx, dy, dw, dh) — row 0 = calls 0..2 + const sourceRowWidth = calls[0][3] + calls[1][3] + calls[2][3]; + const targetRowWidth = calls[0][7] + calls[1][7] + calls[2][7]; + expect(sourceRowWidth).toBe(30); + expect(targetRowWidth).toBe(90); + nss.destroy(); + }); + }); +}); diff --git a/packages/melonjs/tests/resize-feedback.spec.js b/packages/melonjs/tests/resize-feedback.spec.js new file mode 100644 index 0000000000..e9a9243ad7 --- /dev/null +++ b/packages/melonjs/tests/resize-feedback.spec.js @@ -0,0 +1,67 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { onresize } from "../src/application/resize.ts"; +import { Application, boot, video } from "../src/index.js"; + +/** + * Regression spec for #1231 — the canvas slowly grows on every window + * resize event when the canvas container is nested inside a wrapper with + * no fixed CSS dimensions. + * + * The auto-scale path measures the grandparent's border-box rect. When + * that ancestor is auto-sized, its rect is derived from the canvas itself + * plus layout chrome (its padding/border, and the baseline gap of the + * default `display: inline` canvas) — so every resize pass re-adds that + * chrome to the canvas size: a monotone growth loop. + */ +describe("Auto-scale resize feedback (#1231)", () => { + let outer; + + beforeAll(() => { + boot(); + }); + + afterEach(() => { + if (outer?.parentNode) { + outer.parentNode.removeChild(outer); + } + outer = undefined; + }); + + function makeApp() { + // wrapper with NO fixed dimensions (auto-sizes around its children) + // plus padding — the exact layout shape from the issue report + outer = document.createElement("div"); + outer.style.padding = "8px"; + const inner = document.createElement("div"); + outer.appendChild(inner); + document.body.appendChild(outer); + + return new Application(100, 30, { + parent: inner, + renderer: video.CANVAS, + scale: "auto", + consoleHeader: false, + }); + } + + it("canvas CSS size reaches a fixed point instead of growing per resize", () => { + const app = makeApp(); + const canvas = app.renderer.getCanvas(); + + onresize(app); // settle once + const w1 = parseFloat(canvas.style.width); + const h1 = parseFloat(canvas.style.height); + + for (let i = 0; i < 5; i++) { + onresize(app); + } + + expect(parseFloat(canvas.style.width)).toBeCloseTo(w1, 1); + expect(parseFloat(canvas.style.height)).toBeCloseTo(h1, 1); + }); + + it("the engine-inserted canvas defaults to display:block (no baseline gap)", () => { + const app = makeApp(); + expect(app.renderer.getCanvas().style.display).toBe("block"); + }); +}); diff --git a/packages/melonjs/tests/shadereffect-settexture.spec.js b/packages/melonjs/tests/shadereffect-settexture.spec.js index f9c84c1856..e4e0295337 100644 --- a/packages/melonjs/tests/shadereffect-settexture.spec.js +++ b/packages/melonjs/tests/shadereffect-settexture.spec.js @@ -311,6 +311,22 @@ describe("ShaderEffect value-setting while disabled (audit fix)", () => { }); }; + it("rejects an HTMLVideoElement source with a clear TypeError", () => { + // extra textures upload ONCE (`entry.tex === null` guard in + // _prepareTextures) — a video would silently freeze on its first + // frame, so it must be rejected up front (same contract as + // Sprite.normalMap). The guard sits before the Canvas/destroyed + // no-op, so no WebGL requirement here: it throws under any renderer. + const effect = new ShaderEffect( + video.renderer, + "uniform sampler2D uVid;\nvec4 apply(vec4 color, vec2 uv) { return color; }", + ); + expect(() => { + effect.setTexture("uVid", document.createElement("video")); + }).toThrow(TypeError); + effect.destroy(); + }); + it("setUniform while USER-disabled applies once re-enabled", (ctx) => { if (!isWebGL) { ctx.skip(); diff --git a/packages/melonjs/tests/texture.spec.js b/packages/melonjs/tests/texture.spec.js index 1810ac26f0..f48f3b292f 100644 --- a/packages/melonjs/tests/texture.spec.js +++ b/packages/melonjs/tests/texture.spec.js @@ -81,18 +81,24 @@ describe("Texture", () => { expect(retrieved).toBe(entry1); }); - it("should retrieve specific atlas by frame dimensions", () => { + it("returns the first registered atlas regardless of frame dimensions (#1489)", () => { const canvas = new CanvasTexture(64, 64); // auto-create default atlas (full image: 64x64) const defaultEntry = cache.get(canvas.canvas); expect(defaultEntry).toBeDefined(); - // request with a specific atlas frame size that matches - const entry = cache.get(canvas.canvas, { - framewidth: 64, - frameheight: 64, - }); - expect(entry).toBeDefined(); + // Codifies the single-result contract: the frame-dimension + // "refinement" documented on get() was dead code for years (the + // compared properties were never set — see #1489), so a frame + // size — matching or not — must return the SAME first atlas. + // Changing this to real multi-atlas selection is a #1410 design + // decision, and this test failing is the intended tripwire. + expect( + cache.get(canvas.canvas, { framewidth: 64, frameheight: 64 }), + ).toBe(defaultEntry); + expect( + cache.get(canvas.canvas, { framewidth: 32, frameheight: 16 }), + ).toBe(defaultEntry); }); it("should keep different images as separate cache entries", () => { @@ -356,6 +362,12 @@ describe("Texture", () => { ctx.skip("WebGL-only — Canvas createPattern doesn't allocate GL units"); return; } + // start from a drained unit pool: the 50 sibling tests above can + // leave it near exhaustion, and an exhaustion reset firing INSIDE + // createPattern below would clear `usedUnits` mid-test and break + // the +1 arithmetic (engine behavior is by-design; the test must + // simply not straddle a reset) + video.renderer.cache.resetUnitAssignments(); const canvas = new CanvasTexture(32, 32); // create initial pattern diff --git a/packages/melonjs/tests/vertexBuffer.spec.js b/packages/melonjs/tests/vertexBuffer.spec.js index 1973ee111b..e182e9c352 100644 --- a/packages/melonjs/tests/vertexBuffer.spec.js +++ b/packages/melonjs/tests/vertexBuffer.spec.js @@ -303,24 +303,19 @@ describe("VertexArrayBuffer", () => { // 9 floats: x, y, z, u, v, r, g, b, a. const MESH_VERTEX_SIZE = 9; - it.each( - NAN_PATTERN_COLORS, - )("$name unpacks to RGBA floats in [0, 1] (no NaN possible)", ({ - tint, - b, - g, - r, - a, - }) => { - const buf = new VertexArrayBuffer(MESH_VERTEX_SIZE, 4); - buf.pushMesh(0, 0, 0, 0, 0, tint); + it.each(NAN_PATTERN_COLORS)( + "$name unpacks to RGBA floats in [0, 1] (no NaN possible)", + ({ tint, b, g, r, a }) => { + const buf = new VertexArrayBuffer(MESH_VERTEX_SIZE, 4); + buf.pushMesh(0, 0, 0, 0, 0, tint); - // Color floats sit at offsets 5..8 of the first vertex. - expect(buf.bufferF32[5]).toBeCloseTo(r / 255, 5); - expect(buf.bufferF32[6]).toBeCloseTo(g / 255, 5); - expect(buf.bufferF32[7]).toBeCloseTo(b / 255, 5); - expect(buf.bufferF32[8]).toBeCloseTo(a / 255, 5); - }); + // Color floats sit at offsets 5..8 of the first vertex. + expect(buf.bufferF32[5]).toBeCloseTo(r / 255, 5); + expect(buf.bufferF32[6]).toBeCloseTo(g / 255, 5); + expect(buf.bufferF32[7]).toBeCloseTo(b / 255, 5); + expect(buf.bufferF32[8]).toBeCloseTo(a / 255, 5); + }, + ); it("never produces a NaN float for any of the historical NaN-pattern packed colors", () => { // The whole point of the FLOAT × 4 path: by writing @@ -398,30 +393,25 @@ describe("VertexArrayBuffer", () => { // wrote, AND the byte view sees the same little-endian // layout the GPU's `UNSIGNED_BYTE × 4 normalized` aColor // attribute expects. - it.each( - NAN_PATTERN_COLORS, - )("$name round-trips through the Uint32 view AND lays out bytes the GPU expects", ({ - tint, - b, - g, - r, - a, - }) => { - // vertexSize=6: x, y, z, u, v, color (sprite format). - const buf = new VertexArrayBuffer(6, 4); - buf.push(0, 0, 0, 0, 0, tint); - - // The exact packed value sits at the Uint32 slot - expect(buf.bufferU32[5]).toBe(tint); - - // Little-endian byte layout: B, G, R, A — matches - // what the GPU's UNSIGNED_BYTE × 4 attribute reads. - const byteOffset = 5 * 4; - expect(buf.bufferU8[byteOffset]).toBe(b); - expect(buf.bufferU8[byteOffset + 1]).toBe(g); - expect(buf.bufferU8[byteOffset + 2]).toBe(r); - expect(buf.bufferU8[byteOffset + 3]).toBe(a); - }); + it.each(NAN_PATTERN_COLORS)( + "$name round-trips through the Uint32 view AND lays out bytes the GPU expects", + ({ tint, b, g, r, a }) => { + // vertexSize=6: x, y, z, u, v, color (sprite format). + const buf = new VertexArrayBuffer(6, 4); + buf.push(0, 0, 0, 0, 0, tint); + + // The exact packed value sits at the Uint32 slot + expect(buf.bufferU32[5]).toBe(tint); + + // Little-endian byte layout: B, G, R, A — matches + // what the GPU's UNSIGNED_BYTE × 4 attribute reads. + const byteOffset = 5 * 4; + expect(buf.bufferU8[byteOffset]).toBe(b); + expect(buf.bufferU8[byteOffset + 1]).toBe(g); + expect(buf.bufferU8[byteOffset + 2]).toBe(r); + expect(buf.bufferU8[byteOffset + 3]).toBe(a); + }, + ); it("textureId slot in vertexSize=7 writes through the float view", () => { // Color sits at slot 5 (Uint32); textureId at slot 6 diff --git a/packages/melonjs/tests/video-sprite.spec.js b/packages/melonjs/tests/video-sprite.spec.js new file mode 100644 index 0000000000..3a7a018e40 --- /dev/null +++ b/packages/melonjs/tests/video-sprite.spec.js @@ -0,0 +1,228 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { boot, event, Sprite, video } from "../src/index.js"; + +/** + * Video sprite frame sync via requestVideoFrameCallback (rVFC). + * + * A detached (never-in-DOM) video element only gets fresh frames from the + * browser while the page compositor is active — Chromium parks it on a + * ~250ms background timer otherwise (e.g. fullscreen direct scanout). The + * Sprite keeps an rVFC pending on the element to force full-rate delivery, + * and uses the callback to stamp a monotonic `version` counter so update() + * only repaints when a frame was actually presented. + * + * The rVFC surface and the media playback state are stubbed as INSTANCE + * properties on a real `