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:
Brian Fertig 2026-09-17 16:50:33 -06:00
parent db75222ca2
commit 63bb6508bb
6 changed files with 139 additions and 0 deletions

View File

@ -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

View File

@ -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();

View File

@ -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;
}

View File

@ -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. */