From 07a8c2ab30118d92dbfdbbaec00e4b15dc74470c Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 17:41:51 +0200 Subject: [PATCH] fix(rendering): texture-load heal no longer clobbers explicitly narrowed frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Sprite constructed from a still-loading texture schedules a heal that resets the frame to the full texture on hydration. When a Spritesheet (or any caller) explicitly narrowed the frame in the meantime, that reset threw the chosen frame away — every sliced sprite rendered the whole atlas. The schedule-time reset leaves a 0x0 frame, so a non-empty frame now disables the heal. --- src/rendering/sprite/Sprite.ts | 8 ++++++-- test/rendering/sprite/sprite.test.ts | 30 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/rendering/sprite/Sprite.ts b/src/rendering/sprite/Sprite.ts index 8b956fc9..6c3975de 100644 --- a/src/rendering/sprite/Sprite.ts +++ b/src/rendering/sprite/Sprite.ts @@ -291,8 +291,12 @@ export class Sprite extends Drawable { return; // failed load shows Texture.missing; nothing to heal } - // Guard against a texture swap or destroy between scheduling and resolution. - if (this._texture === texture && !this.destroyed) { + // Guard against a texture swap or destroy between scheduling and resolution — + // and against an explicit frame set in the meantime (a Spritesheet slicing + // frames out of a still-loading atlas). The schedule-time reset above left a + // 0×0 frame, so a non-empty frame here means someone chose one deliberately; + // only heal the untouched case. + if (this._texture === texture && !this.destroyed && this._textureFrame.width === 0 && this._textureFrame.height === 0) { this.resetTextureFrame(); } } diff --git a/test/rendering/sprite/sprite.test.ts b/test/rendering/sprite/sprite.test.ts index 3df0a5e0..77f77148 100644 --- a/test/rendering/sprite/sprite.test.ts +++ b/test/rendering/sprite/sprite.test.ts @@ -89,6 +89,36 @@ describe('Sprite', () => { expect(Number.isNaN(sprite.scale.x)).toBe(false); expect(sprite.width).toBe(40); }); + + test('an explicit frame set while the texture is loading survives hydration', async () => { + const { texture, finishLoad } = makeDeferredTexture(); + const sprite = new Sprite(texture); + + sprite.setTextureFrame(new Rectangle(64, 0, 32, 32)); + + finishLoad(128, 128); + await texture.loaded; + await Promise.resolve(); // flush the .then microtask + + expect(sprite.textureFrame.x).toBe(64); + expect(sprite.textureFrame.y).toBe(0); + expect(sprite.textureFrame.width).toBe(32); + expect(sprite.textureFrame.height).toBe(32); + }); + + test('heals to the full texture frame when no explicit frame was set before hydration', async () => { + const { texture, finishLoad } = makeDeferredTexture(); + const sprite = new Sprite(texture); + + finishLoad(128, 128); + await texture.loaded; + await Promise.resolve(); // flush the .then microtask + + expect(sprite.textureFrame.x).toBe(0); + expect(sprite.textureFrame.y).toBe(0); + expect(sprite.textureFrame.width).toBe(128); + expect(sprite.textureFrame.height).toBe(128); + }); }); // Binding a destroyed texture is otherwise silent — warn once (dev) at