diff --git a/dev/combat.test.mjs b/dev/combat.test.mjs index b3f0bec..aceaa9b 100644 --- a/dev/combat.test.mjs +++ b/dev/combat.test.mjs @@ -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`); diff --git a/dev/shots/combat-main.png b/dev/shots/combat-main.png index dfc50ba..a735b98 100644 Binary files a/dev/shots/combat-main.png and b/dev/shots/combat-main.png differ diff --git a/dev/shots/combat-range.png b/dev/shots/combat-range.png index 8df4d0e..db5c89f 100644 Binary files a/dev/shots/combat-range.png and b/dev/shots/combat-range.png differ diff --git a/js/combat/CombatScene.js b/js/combat/CombatScene.js index 851a426..4d4e5c9 100644 --- a/js/combat/CombatScene.js +++ b/js/combat/CombatScene.js @@ -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(); diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index 3c2c42b..24b804b 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -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; } diff --git a/js/combat/Fx.js b/js/combat/Fx.js index 066924c..0a26756 100644 --- a/js/combat/Fx.js +++ b/js/combat/Fx.js @@ -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. */