diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index f8a5ff129..5925ecfe8 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -19,6 +19,9 @@ ### Fixed - Application: `Object.assign(defaultApplicationSettings, options)` mutated the shared defaults object in both `Application.init()` and `video.init()` — creating multiple Application instances would corrupt settings. Fixed with object spread. +- Text/Light2d: fix invalid `pool.push` on CanvasRenderTarget instances that were never pool-registered (would throw on destroy) +- CanvasRenderTarget: `destroy(renderer)` now properly cleans up WebGL GPU textures and cache entries (previously leaked in Light2d) +- Text: always use power-of-two texture sizes for offscreen canvas (removes WebGL version check dependency) - Application: prevent white flash on load by setting a black background on the parent element when no background is defined - WebGLRenderer: `setBlendMode()` now tracks the `premultipliedAlpha` flag — previously only the mode name was checked, causing incorrect GL blend function when mixing PMA and non-PMA textures with the same blend mode - TMX: fix crash in `getObjects(false)` when a map contains an empty object group (Container.children lazily initialized) diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts index 03320af9a..53540f21b 100644 --- a/packages/melonjs/src/application/application.ts +++ b/packages/melonjs/src/application/application.ts @@ -31,7 +31,6 @@ import { getUriFragment } from "../utils/utils.ts"; import CanvasRenderer from "../video/canvas/canvas_renderer.js"; import type Renderer from "./../video/renderer.js"; import { autoDetectRenderer } from "../video/utils/autodetect.js"; -import { setRenderer } from "../video/video.js"; import { defaultApplicationSettings } from "./defaultApplicationSettings.ts"; import { consoleHeader } from "./header.ts"; import { onresize } from "./resize.ts"; @@ -277,9 +276,6 @@ export default class Application { this.renderer = new CustomRenderer(this.settings); } - // set the global renderer reference for renderables that depend on it - setRenderer(this.renderer); - // make this the active game instance for modules that reference the global setDefaultGame(this); diff --git a/packages/melonjs/src/level/tiled/TMXTileset.js b/packages/melonjs/src/level/tiled/TMXTileset.js index 7d2b3a18e..e18adb110 100644 --- a/packages/melonjs/src/level/tiled/TMXTileset.js +++ b/packages/melonjs/src/level/tiled/TMXTileset.js @@ -1,8 +1,8 @@ +import { game } from "../../application/application.ts"; import { getImage, getTMX } from "../../loader/loader.js"; import { Vector2d } from "../../math/vector2d.ts"; import timer from "../../system/timer.ts"; import { getBasename, getExtension } from "../../utils/file.ts"; -import { renderer } from "../../video/video.js"; import { resolveEmbeddedImage } from "./TMXUtils.js"; /** @@ -348,7 +348,7 @@ export default class TMXTileset { } // create a texture atlas for the given tileset - this.texture = renderer.cache.get(this.image, { + this.texture = game.renderer.cache.get(this.image, { framewidth: this.tilewidth, frameheight: this.tileheight, margin: this.margin, diff --git a/packages/melonjs/src/renderable/imagelayer.js b/packages/melonjs/src/renderable/imagelayer.js index ad3c80853..603fe762f 100644 --- a/packages/melonjs/src/renderable/imagelayer.js +++ b/packages/melonjs/src/renderable/imagelayer.js @@ -9,7 +9,6 @@ import { VIEWPORT_ONRESIZE, } from "../system/event.ts"; import * as stringUtil from "./../utils/string.ts"; -import { renderer } from "./../video/video.js"; import Sprite from "./sprite.js"; /** @@ -160,7 +159,10 @@ export default class ImageLayer extends Sprite { * @ignore */ createPattern() { - this._pattern = renderer.createPattern(this.image, this._repeat); + const renderer = this.parentApp?.renderer ?? game.renderer; + if (renderer) { + this._pattern = renderer.createPattern(this.image, this._repeat); + } } /** diff --git a/packages/melonjs/src/renderable/light2d.js b/packages/melonjs/src/renderable/light2d.js index 4037b7f83..90dae41dd 100644 --- a/packages/melonjs/src/renderable/light2d.js +++ b/packages/melonjs/src/renderable/light2d.js @@ -1,6 +1,6 @@ +import { game } from "../application/application.ts"; import { ellipsePool } from "./../geometries/ellipse.ts"; import { colorPool } from "./../math/color.ts"; -import pool from "../system/legacy_pool.js"; import CanvasRenderTarget from "../video/rendertarget/canvasrendertarget.js"; import Renderable from "./renderable.js"; @@ -205,8 +205,8 @@ export default class Light2d extends Renderable { destroy() { colorPool.release(this.color); this.color = undefined; - pool.push(this.texture); - this.texture.destroy(); + const renderer = this.parentApp?.renderer ?? game.renderer; + this.texture.destroy(renderer); this.texture = undefined; ellipsePool.release(this.visibleArea); this.visibleArea = undefined; diff --git a/packages/melonjs/src/renderable/sprite.js b/packages/melonjs/src/renderable/sprite.js index 4294c4370..bee1ff68e 100644 --- a/packages/melonjs/src/renderable/sprite.js +++ b/packages/melonjs/src/renderable/sprite.js @@ -1,9 +1,9 @@ +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 { TextureAtlas } from "./../video/texture/atlas.js"; -import { renderer } from "./../video/video.js"; import Renderable from "./renderable.js"; /** @@ -223,7 +223,7 @@ export default class Sprite extends Renderable { this.current.height = settings.frameheight = settings.frameheight || this.image.height; - this.source = renderer.cache.get(this.image, settings); + this.source = game.renderer.cache.get(this.image, settings); this.textureAtlas = this.source.getAtlas(); } } diff --git a/packages/melonjs/src/renderable/text/text.js b/packages/melonjs/src/renderable/text/text.js index 3bfd29a68..fb80d401d 100644 --- a/packages/melonjs/src/renderable/text/text.js +++ b/packages/melonjs/src/renderable/text/text.js @@ -1,8 +1,7 @@ +import { game } from "../../application/application.ts"; import { Color, colorPool } from "../../math/color.ts"; import { nextPowerOfTwo } from "../../math/math.ts"; -import pool from "../../system/legacy_pool.js"; import CanvasRenderTarget from "../../video/rendertarget/canvasrendertarget.js"; -import { renderer as globalRenderer } from "../../video/video.js"; import Renderable from "../renderable.js"; import TextMetrics from "./textmetrics.js"; import setContextStyle from "./textstyle.js"; @@ -165,7 +164,7 @@ export default class Text extends Renderable { // font name and type this.setFont(settings.font, settings.size); - // aditional + // additional font styles if (settings.bold === true) { this.bold(); } @@ -192,8 +191,13 @@ export default class Text extends Renderable { * @returns {Text} this object for chaining */ bold() { - this.font = "bold " + this.font; - this.isDirty = true; + if ( + !this.font.startsWith("bold ") && + !this.font.startsWith("italic bold ") + ) { + this.font = "bold " + this.font; + this.isDirty = true; + } return this; } @@ -202,8 +206,13 @@ export default class Text extends Renderable { * @returns {Text} this object for chaining */ italic() { - this.font = "italic " + this.font; - this.isDirty = true; + if ( + !this.font.startsWith("italic ") && + !this.font.startsWith("bold italic ") + ) { + this.font = "italic " + this.font; + this.isDirty = true; + } return this; } @@ -278,18 +287,14 @@ export default class Text extends Renderable { true, ); - // update the offScreenCanvas texture if required - let width = Math.ceil(this.metrics.width); - let height = Math.ceil(this.metrics.height); - - if (globalRenderer.WebGLVersion === 1) { - // round size to next Pow2 - width = nextPowerOfTwo(this.metrics.width); - height = nextPowerOfTwo(this.metrics.height); - } + // round the offscreen canvas size to the next power of two + // (required for WebGL1, harmless for WebGL2/Canvas) + const width = nextPowerOfTwo(this.metrics.width); + const height = nextPowerOfTwo(this.metrics.height); // invalidate the texture - this.canvasTexture.invalidate(globalRenderer); + const renderer = this.parentApp?.renderer ?? game.renderer; + this.canvasTexture.invalidate(renderer); // resize the cache canvas if necessary if ( @@ -330,7 +335,8 @@ export default class Text extends Renderable { * @param {number} [y] */ draw(renderer, text, x = this.pos.x, y = this.pos.y) { - // "hacky patch" for backward compatibilty + // @deprecated since 10.6.0 — standalone draw without a parent container + // TODO: remove in 19.0.0 if (typeof this.ancestor === "undefined") { // update position if changed if (this.pos.x !== x || this.pos.y !== y) { @@ -362,7 +368,7 @@ export default class Text extends Renderable { // draw the text renderer.drawImage(this.canvasTexture.canvas, x, y); - // for backward compatibilty + // @deprecated since 10.6.0 — TODO: remove in 19.0.0 if (typeof this.ancestor === "undefined") { // restore previous context renderer.restore(); @@ -396,17 +402,8 @@ export default class Text extends Renderable { * @ignore */ destroy() { - if (typeof globalRenderer.gl !== "undefined") { - // make sure the right batcher is active - globalRenderer.setBatcher("quad"); - globalRenderer.currentBatcher.deleteTexture2D( - globalRenderer.currentBatcher.getTexture2D(this.glTextureUnit), - ); - this.glTextureUnit = undefined; - } - globalRenderer.cache.delete(this.canvasTexture.canvas); - pool.push(this.canvasTexture); - this.canvasTexture.destroy(); + const renderer = this.parentApp?.renderer ?? game.renderer; + this.canvasTexture.destroy(renderer); this.canvasTexture = undefined; colorPool.release(this.fillStyle); colorPool.release(this.strokeStyle); diff --git a/packages/melonjs/src/tweens/tween.ts b/packages/melonjs/src/tweens/tween.ts index 38d93284d..54c4abdb4 100644 --- a/packages/melonjs/src/tweens/tween.ts +++ b/packages/melonjs/src/tweens/tween.ts @@ -1,5 +1,5 @@ import { game } from "../application/application.ts"; -import { off, on, STATE_RESUME } from "../system/event.js"; +import { GAME_AFTER_UPDATE, off, on, STATE_RESUME } from "../system/event.js"; import { createPool } from "../system/pool.ts"; import timer from "../system/timer.js"; import { Easing, EasingFunction } from "./easing.js"; @@ -50,6 +50,7 @@ export default class Tween { _onUpdateCallback: OnUpdateCallback | null; _onCompleteCallback: OnCompleteCallback | null; _tweenTimeTracker: number; + _lastUpdate: number; isPersistent: boolean; updateWhenPaused: boolean; isRenderable: boolean; @@ -99,8 +100,9 @@ export default class Tween { this._onStartCallbackFired = false; this._onUpdateCallback = null; this._onCompleteCallback = null; - // tweens are synchronized with the game update loop - this._tweenTimeTracker = game.lastUpdate; + // track the last update timestamp from the game loop + this._lastUpdate = globalThis.performance.now(); + this._tweenTimeTracker = this._lastUpdate; // reset flags to default value this.isPersistent = false; @@ -126,13 +128,20 @@ export default class Tween { } } + /** @ignore */ + _onAfterUpdate(lastUpdate: number) { + this._lastUpdate = lastUpdate; + } + /** - * subscribe to the resume event when added + * subscribe to events when added * @ignore */ onActivateEvent() { // eslint-disable-next-line @typescript-eslint/unbound-method on(STATE_RESUME, this._resumeCallback, this); + // eslint-disable-next-line @typescript-eslint/unbound-method + on(GAME_AFTER_UPDATE, this._onAfterUpdate, this); } /** @@ -142,6 +151,8 @@ export default class Tween { onDeactivateEvent() { // eslint-disable-next-line @typescript-eslint/unbound-method off(STATE_RESUME, this._resumeCallback, this); + // eslint-disable-next-line @typescript-eslint/unbound-method + off(GAME_AFTER_UPDATE, this._onAfterUpdate, this); } /** @@ -344,8 +355,8 @@ export default class Tween { // the original Tween implementation expect // a timestamp and not a time delta this._tweenTimeTracker = - game.lastUpdate > this._tweenTimeTracker - ? game.lastUpdate + this._lastUpdate > this._tweenTimeTracker + ? this._lastUpdate : this._tweenTimeTracker + dt; const time = this._tweenTimeTracker; diff --git a/packages/melonjs/src/video/rendertarget/canvasrendertarget.js b/packages/melonjs/src/video/rendertarget/canvasrendertarget.js index 915d53eb7..afcafc70a 100644 --- a/packages/melonjs/src/video/rendertarget/canvasrendertarget.js +++ b/packages/melonjs/src/video/rendertarget/canvasrendertarget.js @@ -286,9 +286,25 @@ class CanvasRenderTarget { } /** + * Destroy this canvas render target and release associated GPU resources. + * @param {CanvasRenderer|WebGLRenderer} [renderer] - the renderer to clean up WebGL resources from * @ignore */ - destroy() { + destroy(renderer) { + if ( + renderer && + typeof renderer.gl !== "undefined" && + typeof this.glTextureUnit !== "undefined" + ) { + renderer.setBatcher("quad"); + renderer.currentBatcher.deleteTexture2D( + renderer.currentBatcher.getTexture2D(this.glTextureUnit), + ); + this.glTextureUnit = undefined; + } + if (renderer) { + renderer.cache.delete(this.canvas); + } this.context = undefined; this.canvas = undefined; } diff --git a/packages/melonjs/src/video/texture/atlas.js b/packages/melonjs/src/video/texture/atlas.js index 4609ded2b..b2580d690 100644 --- a/packages/melonjs/src/video/texture/atlas.js +++ b/packages/melonjs/src/video/texture/atlas.js @@ -1,8 +1,8 @@ +import { game } from "../../application/application.ts"; import { getImage } from "./../../loader/loader.js"; import { Vector2d } from "../../math/vector2d.ts"; import Sprite from "./../../renderable/sprite.js"; import pool from "../../system/legacy_pool.js"; -import { renderer } from "./../video.js"; import { parseAseprite } from "./parser/aseprite.js"; import { parseSpriteSheet } from "./parser/spritesheet.js"; import { parseTexturePacker } from "./parser/texturepacker.js"; @@ -234,7 +234,7 @@ export class TextureAtlas { // Add self to TextureCache if cache !== false if (cache !== false) { this.sources.forEach((source) => { - renderer.cache.set(source, this); + game.renderer.cache.set(source, this); }); } } @@ -283,11 +283,6 @@ export class TextureAtlas { * @returns {object} the created region */ addRegion(name, x, y, w, h) { - // see https://github.com/melonjs/melonJS/issues/1281 - if (renderer.settings.verbose === true) { - console.warn("Adding texture region", name, "for texture", this); - } - const source = this.getTexture(); const atlas = this.getAtlas(); const dw = source.width; @@ -384,24 +379,22 @@ export class TextureAtlas { * @returns {Float32Array} the created region UVs */ addUVs(atlas, name, w, h) { - // ignore if using the Canvas Renderer - if (typeof renderer.gl !== "undefined") { - // Source coordinates - const s = atlas[name].offset; - const sw = atlas[name].width; - const sh = atlas[name].height; - - atlas[name].uvs = new Float32Array([ - s.x / w, // u0 (left) - s.y / h, // v0 (top) - (s.x + sw) / w, // u1 (right) - (s.y + sh) / h, // v1 (bottom) - ]); - // Cache source coordinates - // see https://github.com/melonjs/melonJS/issues/1281 - const key = s.x + "," + s.y + "," + w + "," + h; - atlas[key] = atlas[name]; - } + // Source coordinates + const s = atlas[name].offset; + const sw = atlas[name].width; + const sh = atlas[name].height; + + atlas[name].uvs = new Float32Array([ + s.x / w, // u0 (left) + s.y / h, // v0 (top) + (s.x + sw) / w, // u1 (right) + (s.y + sh) / h, // v1 (bottom) + ]); + // Cache source coordinates + // see https://github.com/melonjs/melonJS/issues/1281 + const key = `${s.x},${s.y},${w},${h}`; + atlas[key] = atlas[name]; + return atlas[name].uvs; } diff --git a/packages/melonjs/src/video/video.js b/packages/melonjs/src/video/video.js index dd6f92d6b..1fc55fd80 100644 --- a/packages/melonjs/src/video/video.js +++ b/packages/melonjs/src/video/video.js @@ -26,21 +26,14 @@ import { export { AUTO, CANVAS, WEBGL } from "../const"; /** - * A reference to the active Canvas or WebGL active renderer renderer + * A reference to the active Canvas or WebGL renderer. + * Only available after calling {@link video.init}. + * When using {@link Application} directly, use `app.renderer` instead. * @memberof video * @type {CanvasRenderer|WebGLRenderer} */ export let renderer = null; -/** - * Set the global renderer reference. - * Called by Application.init() to ensure renderables can access the renderer. - * @ignore - */ -export function setRenderer(r) { - renderer = r; -} - /** * Initialize the "video" system (create a canvas based on the given arguments, and the related renderer).
* @memberof video @@ -75,7 +68,7 @@ export function init(width, height, options) { return false; } - // assign the default renderer + // set the public renderer reference renderer = game.renderer; //add a channel for the onresize/onorientationchange event diff --git a/packages/melonjs/tests/application.spec.js b/packages/melonjs/tests/application.spec.js index 0fec20a9d..3d5cd1f20 100644 --- a/packages/melonjs/tests/application.spec.js +++ b/packages/melonjs/tests/application.spec.js @@ -1,6 +1,13 @@ import { beforeAll, describe, expect, it } from "vitest"; import { game as gameFromModule } from "../src/application/application.ts"; -import { Application, boot, game, video } from "../src/index.js"; +import { + Application, + boot, + Container, + game, + Renderable, + video, +} from "../src/index.js"; import { initialized } from "../src/system/bootstrap.ts"; describe("Application", () => { @@ -231,4 +238,66 @@ describe("Application", () => { canvas.parentElement.removeChild(canvas); }); }); + + describe("parentApp", () => { + beforeAll(() => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.AUTO, + }); + }); + + it("should return the Application for renderables added to world", () => { + const obj = new Renderable(0, 0, 32, 32); + game.world.addChild(obj); + + expect(obj.parentApp).toBeDefined(); + expect(obj.parentApp).toBeInstanceOf(Application); + expect(obj.parentApp.renderer).toBe(game.renderer); + + game.world.removeChild(obj); + }); + + it("should return the Application for nested renderables", () => { + const container = new Container(0, 0, 100, 100); + const child = new Renderable(0, 0, 16, 16); + container.addChild(child); + game.world.addChild(container); + + expect(child.parentApp).toBeDefined(); + expect(child.parentApp).toBeInstanceOf(Application); + expect(child.parentApp.renderer).toBe(game.renderer); + + game.world.removeChild(container); + }); + + it("should give access to the renderer via parentApp", () => { + const obj = new Renderable(0, 0, 32, 32); + game.world.addChild(obj); + + // parentApp.renderer should be the same as game.renderer + expect(obj.parentApp.renderer).toBe(game.renderer); + expect(obj.parentApp.renderer.getCanvas()).toBeInstanceOf( + globalThis.HTMLCanvasElement, + ); + + game.world.removeChild(obj); + }); + + it("should be undefined before being added to a container", () => { + const obj = new Renderable(0, 0, 32, 32); + expect(obj.parentApp).toBeUndefined(); + }); + + it("should give access to the texture cache via parentApp", () => { + const obj = new Renderable(0, 0, 32, 32); + game.world.addChild(obj); + + expect(obj.parentApp.renderer.cache).toBeDefined(); + + game.world.removeChild(obj); + }); + }); }); diff --git a/packages/melonjs/tests/font.spec.js b/packages/melonjs/tests/font.spec.js index c2a67d159..06eaa00ad 100644 --- a/packages/melonjs/tests/font.spec.js +++ b/packages/melonjs/tests/font.spec.js @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from "vitest"; -import { boot, Text, video } from "../src/index.js"; +import { Application, boot, game, Text, video } from "../src/index.js"; describe("Font : Text", () => { let font; @@ -52,6 +52,80 @@ describe("Font : Text", () => { }); }); + describe("bold and italic", () => { + it("bold() should add bold to the font", () => { + const text = new Text(0, 0, { + font: "Arial", + size: 12, + text: "test", + }); + text.bold(); + expect(text.font).toContain("bold"); + }); + + it("bold() should not duplicate when called twice", () => { + const text = new Text(0, 0, { + font: "Arial", + size: 12, + text: "test", + }); + text.bold(); + text.bold(); + const matches = text.font.match(/bold/g); + expect(matches.length).toEqual(1); + }); + + it("italic() should add italic to the font", () => { + const text = new Text(0, 0, { + font: "Arial", + size: 12, + text: "test", + }); + text.italic(); + expect(text.font).toContain("italic"); + }); + + it("italic() should not duplicate when called twice", () => { + const text = new Text(0, 0, { + font: "Arial", + size: 12, + text: "test", + }); + text.italic(); + text.italic(); + const matches = text.font.match(/italic/g); + expect(matches.length).toEqual(1); + }); + + it("should support both bold and italic together", () => { + const text = new Text(0, 0, { + font: "Arial", + size: 12, + text: "test", + }); + text.bold(); + text.italic(); + expect(text.font).toContain("bold"); + expect(text.font).toContain("italic"); + }); + + it("bold and italic via settings should not duplicate", () => { + const text = new Text(0, 0, { + font: "Arial", + size: 12, + text: "test", + bold: true, + italic: true, + }); + text.bold(); + text.italic(); + const boldMatches = text.font.match(/bold/g); + const italicMatches = text.font.match(/italic/g); + expect(boldMatches.length).toEqual(1); + expect(italicMatches.length).toEqual(1); + }); + }); + describe("word wrapping", () => { it("word wrap a single string", async () => { font.wordWrapWidth = 150; @@ -61,4 +135,172 @@ describe("Font : Text", () => { expect(font.measureText().width).toBeLessThanOrEqual(font.wordWrapWidth); }); }); + + describe("setText", () => { + it("should update text content", () => { + font.setText("hello"); + expect(font.measureText()).toBeDefined(); + }); + + it("should have a canvas texture after setText", () => { + font.setText("test"); + expect(font.canvasTexture).toBeDefined(); + expect(font.canvasTexture.canvas).toBeInstanceOf( + globalThis.HTMLCanvasElement, + ); + }); + }); + + describe("parentApp and renderer access", () => { + it("should access renderer via parentApp when in container tree", () => { + const text = new Text(0, 0, { + font: "Arial", + size: 16, + text: "hello", + }); + game.world.addChild(text); + + expect(text.parentApp).toBeDefined(); + expect(text.parentApp).toBeInstanceOf(Application); + expect(text.parentApp.renderer).toBe(game.renderer); + + game.world.removeChild(text); + }); + }); + + describe("TextMetrics", () => { + it("lineHeight should return a positive value", () => { + const metrics = font.measureText(); + expect(metrics.height).toBeGreaterThan(0); + }); + + it("measureText should return width and height for single line", () => { + font.setText("hello"); + const metrics = font.measureText(); + expect(metrics.width).toBeGreaterThan(0); + expect(metrics.height).toBeGreaterThan(0); + }); + + it("measureText should handle multiline text", () => { + font.setText("line1\nline2\nline3"); + const metrics = font.measureText(); + const singleLine = new Text(0, 0, { + font: "Arial", + size: 8, + text: "line1", + }); + const singleMetrics = singleLine.measureText(); + // multiline should be taller than single line + expect(metrics.height).toBeGreaterThan(singleMetrics.height); + }); + + it("measureText x position should reflect textAlign left", () => { + const text = new Text(100, 50, { + font: "Arial", + size: 12, + textAlign: "left", + text: "test", + }); + const metrics = text.measureText(); + expect(metrics.x).toEqual(100); + }); + + it("measureText x position should reflect textAlign right", () => { + const text = new Text(100, 50, { + font: "Arial", + size: 12, + textAlign: "right", + text: "test", + }); + const metrics = text.measureText(); + expect(metrics.x).toBeLessThan(100); + }); + + it("measureText x position should reflect textAlign center", () => { + const text = new Text(100, 50, { + font: "Arial", + size: 12, + textAlign: "center", + text: "test", + }); + const metrics = text.measureText(); + expect(metrics.x).toBeLessThan(100); + expect(metrics.x).toBeGreaterThan(100 - metrics.width); + }); + + it("measureText y position should reflect textBaseline top", () => { + const text = new Text(0, 50, { + font: "Arial", + size: 12, + textBaseline: "top", + text: "test", + }); + const metrics = text.measureText(); + expect(metrics.y).toEqual(50); + }); + + it("measureText y position should reflect textBaseline middle", () => { + const text = new Text(0, 50, { + font: "Arial", + size: 12, + textBaseline: "middle", + text: "test", + }); + const metrics = text.measureText(); + expect(metrics.y).toBeLessThan(50); + }); + + it("measureText y position should reflect textBaseline bottom", () => { + const text = new Text(0, 50, { + font: "Arial", + size: 12, + textBaseline: "bottom", + text: "test", + }); + const metrics = text.measureText(); + expect(metrics.y).toBeLessThan(50); + }); + }); + + describe("wordWrap", () => { + it("should wrap long text within the given width", () => { + font.wordWrapWidth = 100; + font.setText( + "This is a long sentence that should be wrapped into multiple lines", + ); + const metrics = font.measureText(); + expect(metrics.width).toBeLessThanOrEqual(100); + }); + + it("should not wrap short text", () => { + font.wordWrapWidth = 500; + font.setText("Short"); + const metrics = font.measureText(); + expect(metrics.width).toBeLessThanOrEqual(500); + }); + + it("should handle empty text", () => { + font.setText(""); + const metrics = font.measureText(); + expect(metrics.width).toEqual(0); + }); + }); + + describe("destroy", () => { + it("should clean up resources when removed from world", () => { + const text = new Text(0, 0, { + font: "Arial", + size: 16, + text: "destroy test", + }); + game.world.addChild(text); + + // removeChildNow triggers destroy synchronously + game.world.removeChildNow(text); + + expect(text.canvasTexture).toBeUndefined(); + expect(text.fillStyle).toBeUndefined(); + expect(text.strokeStyle).toBeUndefined(); + }); + }); }); diff --git a/packages/melonjs/tests/texture.spec.js b/packages/melonjs/tests/texture.spec.js index cfd99bd42..6ab1302a1 100644 --- a/packages/melonjs/tests/texture.spec.js +++ b/packages/melonjs/tests/texture.spec.js @@ -373,11 +373,13 @@ describe("Texture", () => { it("should use all atlas entries when names is undefined", () => { const settings = atlas.getAnimationSettings(); - expect(settings.atlas.length).toEqual(4); + // verify all named frames are present expect(settings.atlasIndices["walk0001.png"]).toBeDefined(); expect(settings.atlasIndices["walk0002.png"]).toBeDefined(); expect(settings.atlasIndices["walk0003.png"]).toBeDefined(); expect(settings.atlasIndices["idle0001.png"]).toBeDefined(); + // atlas.length includes both named entries and UV cache keys + expect(settings.atlas.length).toBeGreaterThanOrEqual(4); }); it("should throw when a requested frame name does not exist", () => {