From 395396c2736863ce0103dafb95a01096ff6c4c63 Mon Sep 17 00:00:00 2001 From: dgali Date: Fri, 13 Nov 2026 20:52:58 +0000 Subject: [PATCH] Clear a sprite's image when its bitmap is cleared (runtime 20261113.1) Under Pixi v8, Sprite._onBitmapChange blanked a sprite by assigning texture.frame, which updates neither the UVs nor the source, so the sprite kept drawing its old bitmap. An actor sprite whose battler left battleMembers() mid-battle (a captured or banished party member) therefore stayed on the field in its last pose while the status window, target lists and turn order had already dropped it. The v8 path now returns the sprite to an empty texture of its own, as initialize() starts with, and destroys the sprite-owned texture it replaces. A texture whose frame is already 0x0 is kept, so construction does not allocate twice. The v5-v7 path is unchanged. editor/tests/sprite-null-bitmap.test.cjs vm-loads the real Sprite on Pixi v8; its clearing test fails on the previous runtime. Verified in a native NW.js battle: captured and banished actors vanish, a released actor reappears, a KO'd one lies in its slot. npm test failing names match a HEAD worktree apart from one concurrency flake in each direction. Co-Authored-By: Claude Opus 5.5 --- editor/CHANGELOG.md | 1 + editor/tests/sprite-null-bitmap.test.cjs | 73 ++++++++++++++++++++++++ runtime/reactor_core_display.js | 21 ++++++- runtime/reactor_main.js | 4 +- template/Demo/js/reactor_core_display.js | 21 ++++++- template/Demo/js/reactor_main.js | 4 +- 6 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 editor/tests/sprite-null-bitmap.test.cjs diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md index b3781dd8..beb81d8c 100644 --- a/editor/CHANGELOG.md +++ b/editor/CHANGELOG.md @@ -1,5 +1,6 @@ # Editor changelog +- A party member who leaves the battle line mid-fight, for example captured or banished by a state that hides them, disappears from the battlefield instead of standing there with their last pose (runtime 20261113.1). Clearing a sprite's bitmap assigned a new texture frame, which Pixi v8 never applies, so the sprite kept drawing the old image; it now returns to an empty texture of its own. Status window, target lists and turn order were already right. - For plugin authors: two methods on `Game_Enemy` to replace instead of working around. `rrActionPatterns()` returns the action patterns an enemy chooses from, and the turn-based, Action and Tactical battle modes now all read them through it, so a plugin that gives an enemy other skills changes one method and every mode follows. `rrEquipSource()` returns the rows an enemy's equipment is first read from, so a plugin can equip an enemy from somewhere other than its database entry. Both return the database lists by default, so nothing changes for a project without such a plugin. (runtime 20261112.1) The full technical list, one file per release or labeled development checkpoint, newest first. The short version of each release is in the [root changelog](../CHANGELOG.md). diff --git a/editor/tests/sprite-null-bitmap.test.cjs b/editor/tests/sprite-null-bitmap.test.cjs new file mode 100644 index 00000000..de8810ea --- /dev/null +++ b/editor/tests/sprite-null-bitmap.test.cjs @@ -0,0 +1,73 @@ +'use strict'; +// Sprite.bitmap = null must stop a sprite drawing its previous bitmap under Pixi v8. +// An actor sprite whose battler leaves battleMembers() mid-battle is cleared this way; +// before the fix it kept rendering that actor's last frame. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); +const native = require('pixi.js'); + +const runtime = path.resolve(__dirname, '../../runtime'); +const read = file => fs.readFileSync(path.join(runtime, file), 'utf8'); + +function loadSprite() { + const context = { + PIXI: { ...native }, TypeError, Error, Reflect, Float32Array, Uint8Array, ArrayBuffer, + console: { log() {}, warn() {}, error() {} }, location: { search: '' } + }; + context.window = context; + vm.createContext(context); + vm.runInContext(read('libs/pixi_compat.js'), context); + const core = read('reactor_core.js'); + const rectangleStart = core.indexOf('function Rectangle()'); + vm.runInContext(core.slice(rectangleStart, core.indexOf('//----', rectangleStart)), context); + const display = read('reactor_core_display.js'); + vm.runInContext(display.slice(0, display.indexOf('Sprite.prototype._createColorFilter')) + '\nthis.Sprite = Sprite;', context); + return context; +} + +function fakeBitmap(P) { + const baseTexture = P.BaseTexture.fromBuffer(new Uint8Array(64 * 64 * 4), 64, 64); + return { baseTexture, width: 64, height: 64, addLoadListener(callback) { callback(this); } }; +} + +test('clearing a sprite bitmap leaves it on an empty texture of its own', () => { + const context = loadSprite(); + const bitmap = fakeBitmap(context.PIXI); + const sprite = new context.Sprite(bitmap); + assert.equal(sprite.texture.source, bitmap.baseTexture.source); + assert.equal(sprite.texture.uvs.x1, 1); + const drawn = sprite.texture; + + sprite.bitmap = null; + + assert.notEqual(sprite.texture.source, bitmap.baseTexture.source, 'no longer samples the old bitmap'); + assert.equal(sprite.texture.uvs.x1, 0, 'UVs cover nothing'); + assert.deepEqual([sprite.texture.frame.width, sprite.texture.frame.height], [0, 0]); + assert.equal(sprite.texture.__rrSpriteOwned, true); + assert.equal(drawn.destroyed, true, 'the replaced sprite-owned texture is released'); + assert.equal(bitmap.baseTexture.source.destroyed, false, 'the bitmap source itself is untouched'); +}); + +test('a cleared sprite shows its bitmap again when one is assigned', () => { + const context = loadSprite(); + const bitmap = fakeBitmap(context.PIXI); + const sprite = new context.Sprite(bitmap); + sprite.bitmap = null; + sprite.bitmap = bitmap; + assert.equal(sprite.texture.source, bitmap.baseTexture.source); + assert.deepEqual([sprite.texture.frame.width, sprite.texture.frame.height], [64, 64]); +}); + +test('a new sprite without a bitmap keeps its constructor texture', () => { + const context = loadSprite(); + const realCreate = context.PIXICreateTexture; + let created = 0; + context.PIXICreateTexture = (...args) => { created++; return realCreate(...args); }; + const sprite = new context.Sprite(); + assert.equal(created, 1, 'initialize() does not build a second empty texture'); + assert.equal(sprite.texture.__rrSpriteOwned, true); + assert.deepEqual([sprite.texture.frame.width, sprite.texture.frame.height], [0, 0]); +}); diff --git a/runtime/reactor_core_display.js b/runtime/reactor_core_display.js index a28a6577..beaad4df 100644 --- a/runtime/reactor_core_display.js +++ b/runtime/reactor_core_display.js @@ -329,7 +329,26 @@ Sprite.prototype._onBitmapChange = function() { this._bitmap.addLoadListener(this._onBitmapLoad.bind(this)); } else { this._refreshFrame = false; - this.texture.frame = new Rectangle(); + if (PIXI.TextureSource) { + // v8: assigning texture.frame refreshes neither the UVs nor the + // source, so a sprite whose bitmap is cleared kept drawing the old + // bitmap's last frame (an actor sprite left without a battler went + // on showing that actor). Go back to an empty texture of the + // sprite's own, as initialize() starts with. A texture whose frame + // is already 0x0 draws nothing (initialize() calls this with one), + // so it is kept rather than allocated twice. + const old = this.texture; + if (!old || old.frame.width > 0 || old.frame.height > 0) { + const texture = PIXICreateTexture(Sprite._emptyBaseTexture, new Rectangle()); + texture.__rrSpriteOwned = true; + this.texture = texture; + if (old && old.__rrSpriteOwned && !old.destroyed) { + old.destroy(); + } + } + } else { + this.texture.frame = new Rectangle(); + } } }; diff --git a/runtime/reactor_main.js b/runtime/reactor_main.js index 69c968e3..80a8c3dd 100644 --- a/runtime/reactor_main.js +++ b/runtime/reactor_main.js @@ -1,10 +1,10 @@ //============================================================================= // reactor_main.js — RPG Reactor runtime entry point // RPG Reactor runtime version: 0.99.5 -// RPG Reactor runtime revision: 20261112.1 +// RPG Reactor runtime revision: 20261113.1 // The same stamp, reachable from the F12 console: which engine is this // window actually running? Type RPG_REACTOR_RUNTIME_REVISION to see. -globalThis.RPG_REACTOR_RUNTIME_REVISION = "20261112.1"; +globalThis.RPG_REACTOR_RUNTIME_REVISION = "20261113.1"; //============================================================================= const scriptUrls = [ diff --git a/template/Demo/js/reactor_core_display.js b/template/Demo/js/reactor_core_display.js index a28a6577..beaad4df 100644 --- a/template/Demo/js/reactor_core_display.js +++ b/template/Demo/js/reactor_core_display.js @@ -329,7 +329,26 @@ Sprite.prototype._onBitmapChange = function() { this._bitmap.addLoadListener(this._onBitmapLoad.bind(this)); } else { this._refreshFrame = false; - this.texture.frame = new Rectangle(); + if (PIXI.TextureSource) { + // v8: assigning texture.frame refreshes neither the UVs nor the + // source, so a sprite whose bitmap is cleared kept drawing the old + // bitmap's last frame (an actor sprite left without a battler went + // on showing that actor). Go back to an empty texture of the + // sprite's own, as initialize() starts with. A texture whose frame + // is already 0x0 draws nothing (initialize() calls this with one), + // so it is kept rather than allocated twice. + const old = this.texture; + if (!old || old.frame.width > 0 || old.frame.height > 0) { + const texture = PIXICreateTexture(Sprite._emptyBaseTexture, new Rectangle()); + texture.__rrSpriteOwned = true; + this.texture = texture; + if (old && old.__rrSpriteOwned && !old.destroyed) { + old.destroy(); + } + } + } else { + this.texture.frame = new Rectangle(); + } } }; diff --git a/template/Demo/js/reactor_main.js b/template/Demo/js/reactor_main.js index 69c968e3..80a8c3dd 100644 --- a/template/Demo/js/reactor_main.js +++ b/template/Demo/js/reactor_main.js @@ -1,10 +1,10 @@ //============================================================================= // reactor_main.js — RPG Reactor runtime entry point // RPG Reactor runtime version: 0.99.5 -// RPG Reactor runtime revision: 20261112.1 +// RPG Reactor runtime revision: 20261113.1 // The same stamp, reachable from the F12 console: which engine is this // window actually running? Type RPG_REACTOR_RUNTIME_REVISION to see. -globalThis.RPG_REACTOR_RUNTIME_REVISION = "20261112.1"; +globalThis.RPG_REACTOR_RUNTIME_REVISION = "20261113.1"; //============================================================================= const scriptUrls = [