Fix white-flash sticking bug with overlap guard and periodic tint purge
- Prevent overlapping hit flashes from stacking, which captured the in-flash state as the restore value and left crafts permanently white - Track flashed crafts in a Set so the expiry path can always settle them - Add purgeCraftTints(), called every ~250 ms from update(), that re-asserts normal colors on any craft (tracked or roster) not holding a live flash - Wire purgeRoster in both CombatScene and CombatSystem so the purge covers the full player + enemy pack, not just Fx's own flashes - Prune destroyed crafts from the tracking set to avoid leaked references - Add tests covering overlap extension, kill-seam settle, periodic purge, roster coverage, and dead-craft pruning
This commit is contained in:
parent
db75222ca2
commit
63bb6508bb
|
|
@ -887,6 +887,70 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
|
|||
check('waves/system: destroy clears the run', combat.waveRun === null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- fx: white-flash integrity
|
||||
{
|
||||
const { Fx } = await import(pathToFileURL(join(__dirname, '../js/combat/Fx.js')).href);
|
||||
const fxScene = makeScene();
|
||||
const fx = new Fx(fxScene);
|
||||
// A craft with the real tint seams (the stub Sprite lacks setTintMode).
|
||||
const craft = {
|
||||
x: 100, y: 100, hitRadius: 12, tint: 16777215, tintMode: 0,
|
||||
setTint(t) { this.tint = t; return this; },
|
||||
setTintMode(m) { this.tintMode = m; return this; },
|
||||
};
|
||||
fx.craftFlash(craft, 120);
|
||||
check('fx: the craft flash goes white (tintMode 1)', craft.tintMode === 1);
|
||||
check('fx: the craft is tracked for the whiteness purge', fx.crafts.has(craft));
|
||||
|
||||
// OVERLAPPING HITS (the "turns white and stays white" bug): a second
|
||||
// hit inside the flash window must EXTEND, never stack — a stacked
|
||||
// item captures the in-flash state (mode 1) as its restore value.
|
||||
fx.update(60);
|
||||
fx.craftFlash(craft, 120);
|
||||
check('fx: overlapping hits do not stack a second flash',
|
||||
fx.items.filter((i) => i.kind === 'tintFlash').length === 1);
|
||||
fx.update(200); // past the extended expiry
|
||||
check('fx: after expiry the craft is back in normal colors (no white stick)',
|
||||
craft.tintMode === 0 && craft.tint === 16777215);
|
||||
|
||||
// The kill seam settles the flash immediately.
|
||||
fx.craftFlash(craft, 120);
|
||||
fx.releaseCraft(craft);
|
||||
check('fx: releaseCraft settles the flash at the kill seam',
|
||||
craft.tintMode === 0 && fx.items.filter((i) => i.kind === 'tintFlash').length === 0);
|
||||
|
||||
// THE PERIODIC PURGE (the backstop): a stuck white craft is reset,
|
||||
// a live flash is left alone, dead crafts are pruned.
|
||||
craft.tintMode = 1; // simulate a leak no seam caught
|
||||
fx.purgeCraftTints();
|
||||
check('fx: the purge resets a stuck white craft', craft.tintMode === 0);
|
||||
fx.craftFlash(craft, 400);
|
||||
fx.purgeCraftTints();
|
||||
check('fx: the purge leaves a live flash alone', craft.tintMode === 1);
|
||||
fx.update(500); // expiry inside this step
|
||||
check('fx: the live flash still restores itself on expiry', craft.tintMode === 0);
|
||||
const dead = {
|
||||
x: 0, y: 0, hitRadius: 10, tint: 16777215, tintMode: 0, destroyed: true,
|
||||
setTint(t) { this.tint = t; return this; },
|
||||
setTintMode(m) { this.tintMode = m; return this; },
|
||||
};
|
||||
fx.crafts.add(dead);
|
||||
fx.purgeCraftTints();
|
||||
check('fx: the purge prunes destroyed crafts (no leaked references)', !fx.crafts.has(dead));
|
||||
|
||||
// The roster callback (both contexts wire it): a craft that NEVER took
|
||||
// a flash but somehow holds the white state is still re-asserted —
|
||||
// the purge covers the whole pack, not just Fx's own flashes.
|
||||
const straggler = {
|
||||
x: 0, y: 0, tint: 16777215, tintMode: 1,
|
||||
setTint(t) { this.tint = t; return this; },
|
||||
setTintMode(m) { this.tintMode = m; return this; },
|
||||
};
|
||||
fx.purgeRoster = () => [straggler];
|
||||
fx.purgeCraftTints();
|
||||
check('fx: the purge covers the roster (not just tracked flashes)', straggler.tintMode === 0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ done
|
||||
|
||||
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 203 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
|
@ -143,6 +143,11 @@ export class CombatScene extends Phaser.Scene {
|
|||
|
||||
this.bullets = [];
|
||||
this.fx = new Fx(this);
|
||||
// The whiteness-purge roster: the purge (Fx.purgeCraftTints, every
|
||||
// ~250 ms) must cover the WHOLE pack — a craft that somehow ends up
|
||||
// holding the white hit-flash state is re-asserted back to its
|
||||
// normal colors no matter how the stick got there.
|
||||
this.fx.purgeRoster = () => [this.ship, ...this.enemies];
|
||||
|
||||
// The range console (HUD).
|
||||
this.createHud();
|
||||
|
|
|
|||
|
|
@ -111,6 +111,11 @@ export class CombatSystem {
|
|||
this.nextFireAt = 0; // ms — the player's auto-fire cooldown stamp
|
||||
|
||||
this.fx = new Fx(scene);
|
||||
// The whiteness-purge roster: the purge (Fx.purgeCraftTints, every
|
||||
// ~250 ms) must cover the WHOLE pack — a craft that somehow ends up
|
||||
// holding the white hit-flash state is re-asserted back to its
|
||||
// normal colors no matter how the stick got there.
|
||||
this.fx.purgeRoster = () => [this.ship, ...this.enemies];
|
||||
|
||||
this._active = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,6 +158,14 @@ export class Fx {
|
|||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
this.items = []; // { img, t0, life, kind, ... }
|
||||
// Every craft that ever took a white hit-flash (the periodic
|
||||
// whiteness-purge's reach — js/combat/Fx.js → purgeCraftTints).
|
||||
this.crafts = new Set();
|
||||
// The context may hand over its live roster (player + pack) so the
|
||||
// purge covers EVERY craft, not just ones we flashed — a stuck white
|
||||
// state is a stuck white state, whatever caused it.
|
||||
this.purgeRoster = null;
|
||||
this._purgeClock = 0;
|
||||
Fx.ensureDot(scene);
|
||||
}
|
||||
|
||||
|
|
@ -422,11 +430,26 @@ export class Fx {
|
|||
craftFlash(ship, life = 150) {
|
||||
if (!ship) return null;
|
||||
if (typeof ship.setTint === 'function' && typeof ship.setTintMode === 'function') {
|
||||
// OVERLAPPING HITS: a second hit inside the flash window must
|
||||
// EXTEND the pending flash, not stack a second item — a stacked
|
||||
// item would capture the in-flash state (tintMode 1) as its
|
||||
// "restore" value and re-apply the white AFTER expiry, leaving the
|
||||
// living craft hot-white forever (the "turns white and stays
|
||||
// white" bug). The first item's restore holds the true pre-flash
|
||||
// state, so extending it is always correct.
|
||||
for (let i = this.items.length - 1; i >= 0; i--) {
|
||||
const it = this.items[i];
|
||||
if (it.kind === 'tintFlash' && it.img === ship) {
|
||||
it.life = Math.max(it.life, it.t0 + life);
|
||||
return ship;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const tint = ship.tint;
|
||||
const mode = Number.isFinite(ship.tintMode) ? ship.tintMode : 0;
|
||||
ship.setTint(0xffffff);
|
||||
ship.setTintMode(1); // solid fill with the tint color (white)
|
||||
this.crafts.add(ship); // the purge keeps this one honest
|
||||
this.items.push({
|
||||
img: ship,
|
||||
t0: 0,
|
||||
|
|
@ -444,6 +467,7 @@ export class Fx {
|
|||
/* fall through to the bloom */
|
||||
}
|
||||
}
|
||||
this.crafts.add(ship);
|
||||
const r = Math.max(10, Number(ship.hitRadius) || 18);
|
||||
return this.flash(ship.x, ship.y, 0xffffff, r * 2.6, life);
|
||||
}
|
||||
|
|
@ -672,6 +696,47 @@ export class Fx {
|
|||
// 'tintFlash' needs no per-frame work (the pulse is set at spawn,
|
||||
// restored on expiry above).
|
||||
}
|
||||
// THE WHITENESS PURGE (the user-requested last line of defense):
|
||||
// a flash is a beat, never a state — a few times a second, re-assert
|
||||
// the identity tint on every tracked craft that has no live flash.
|
||||
// This re-syncs the craft (and anything that latched its white state)
|
||||
// no matter how the stick got there, in BOTH contexts (they share
|
||||
// this update path).
|
||||
this._purgeClock += deltaMs;
|
||||
if (this._purgeClock >= 250) {
|
||||
this._purgeClock = 0;
|
||||
this.purgeCraftTints();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* THE PERIODIC WHITENESS PURGE (the user-requested last line of
|
||||
* defense): a few times a second, from update(), every combat craft —
|
||||
* the tracked flashes we know about AND the context's live roster —
|
||||
* must be in normal colors. A craft with a live flash is the expiry
|
||||
* path's to restore, so it's left alone; everything else is forced
|
||||
* back to identity (settleCraftTint). Destroyed crafts are pruned so
|
||||
* the tracking set can't leak. Cheap: a handful of setter calls.
|
||||
*/
|
||||
purgeCraftTints() {
|
||||
const targets = new Set(this.crafts);
|
||||
if (typeof this.purgeRoster === 'function') {
|
||||
try { for (const c of this.purgeRoster()) if (c) targets.add(c); } catch { /* roster seam */ }
|
||||
}
|
||||
if (targets.size === 0) return;
|
||||
const live = new Set();
|
||||
for (const it of this.items) {
|
||||
if (it.kind === 'tintFlash') live.add(it.img);
|
||||
}
|
||||
for (const c of targets) {
|
||||
if (!c) continue;
|
||||
if (c.destroyed) {
|
||||
this.crafts.delete(c); // dead — drop the reference (GC)
|
||||
continue;
|
||||
}
|
||||
if (live.has(c)) continue; // live flash: legitimate, expiry restores
|
||||
settleCraftTint(c);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop everything (a range reset) — restoring any craft flash. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue