Fix enemy ships reviving with stuck white hit-flash tint

- Add Fx.releaseCraft() to settle a craft's pending white tintFlash
  immediately on kill/destroy, so sprites never die mid-flash
- Add exported settleCraftTint() helper that resets tint mode and tint
  to identity; call it on revive (EnemyShip), respawn, and reuse seams
- Guard restore callbacks against destroyed sprites with try/catch so a
  half-restored craft can't leak white state
- Update combat screenshots
This commit is contained in:
Brian Fertig 2026-09-17 08:13:47 -06:00
parent 716f9270ae
commit db75222ca2
6 changed files with 69 additions and 7 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -10,7 +10,7 @@ import { Ship } from '../entities/Ship.js';
import { EnemyShip } from './EnemyShip.js';
import { enemySheetList } from './EnemyTypes.js';
import { Bullet } from './Bullet.js';
import { Fx, FX_COLORS } from './Fx.js';
import { Fx, FX_COLORS, settleCraftTint } from './Fx.js';
import { StarDrift } from './StarDrift.js';
import { applyDamage, handleFeel, pickTarget } from './Combat.js';
import { combatSfx, combatSfxEnemyHit, loadCombatSfx } from './CombatSfx.js';
@ -414,6 +414,9 @@ export class CombatScene extends Phaser.Scene {
killEnemy(e) {
if (e._killFx) return; // idempotent — damage() sets e.dead first, so
e._killFx = true; // the dead flag can't be the "already counted" guard
// Settle any pending white hit-flash NOW — the craft dies in its
// normal colors, never mid-flash (the reuse seam's edge case).
this.fx.releaseCraft(e);
this.kills += 1;
this.fx.explode(e.x, e.y, 0xff5d6f);
combatShake(this, 'kill'); // the world kick (the bar stays still)
@ -681,6 +684,7 @@ export class CombatScene extends Phaser.Scene {
killPlayer() {
this.playerDead = true;
this.hull = 0;
this.fx.releaseCraft(this.ship); // out in normal colors, never mid-flash
this.fx.down(this.ship.x, this.ship.y); // the DOWN cue (burst + hold)
combatSfx(this, 'down');
this.ship.stop();
@ -699,6 +703,7 @@ export class CombatScene extends Phaser.Scene {
this.ship.y = this.h / 2;
this.ship.rotation = 0;
this.ship.stop();
settleCraftTint(this.ship); // never reappear holding a white hit-flash
this.ship.setVisible(true);
this.playerDead = false;
this.clearBullets();

View File

@ -2,7 +2,7 @@ import { config } from '../config/Config.js';
import { EnemyShip } from './EnemyShip.js';
import { enemyTypeConfig } from './EnemyTypes.js';
import { Bullet } from './Bullet.js';
import { Fx, FX_COLORS } from './Fx.js';
import { Fx, FX_COLORS, settleCraftTint } from './Fx.js';
import { applyDamage, pickTarget } from './Combat.js';
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js';
import { combatShake } from './CamShake.js';
@ -256,7 +256,10 @@ export class CombatSystem {
}
if (destroy) {
for (const e of this.enemies) {
if (e.wave && !e.destroyed) e.destroy();
if (e.wave && !e.destroyed) {
this.fx.releaseCraft(e); // out in normal colors, never mid-flash
e.destroy();
}
}
this.enemies = this.enemies.filter((e) => !e.wave);
}
@ -699,6 +702,7 @@ export class CombatSystem {
this.playerDown = true;
this.hull = 0;
if (this.ship) {
this.fx.releaseCraft(this.ship); // out in normal colors, never mid-flash
this.fx.down(this.ship.x, this.ship.y); // the DOWN cue (burst + hold)
this.ship.stop();
this.ship.setVisible(false);
@ -717,7 +721,10 @@ export class CombatSystem {
this.shields = this.maxShields;
this.lastHitAt = 0;
this.respawnAt = 0;
if (this.ship) this.ship.setVisible(true);
if (this.ship) {
settleCraftTint(this.ship); // never reappear holding a white hit-flash
this.ship.setVisible(true);
}
if (this.ship) this.fx.respawn(this.ship.x, this.ship.y); // the REFORMED cue
combatSfx(this.scene, 'respawn');
if (this.onPlayerUp) this.onPlayerUp();
@ -733,6 +740,9 @@ export class CombatSystem {
killEnemy(e, time) {
if (e._killFx) return;
e._killFx = true;
// Settle any pending white hit-flash NOW — the craft dies in its
// normal colors, never mid-flash (the reuse seam's edge case).
this.fx.releaseCraft(e);
this.kills += 1;
this.fx.explode(e.x, e.y, '#ff5d6f');
combatShake(this.scene, 'kill'); // the world kick (the bar stays still)
@ -802,7 +812,10 @@ export class CombatSystem {
/** Tear the combat down (scene destroy). */
destroy() {
this.waveRun = null;
for (const e of this.enemies) e.destroy();
for (const e of this.enemies) {
this.fx.releaseCraft(e); // settle flashes before the craft goes
e.destroy();
}
for (const b of this.bullets) if (!b.dead) b.destroy();
this.enemies = [];
this.bullets = [];

View File

@ -2,6 +2,7 @@ import Phaser from '../vendor/phaser.js';
import { toColor } from '../utils/Color.js';
import { assignWorld } from '../visuals/UiCameras.js';
import { enemyTypeConfig } from './EnemyTypes.js';
import { settleCraftTint } from './Fx.js';
import { applyDamage, headingOf, orbitStep } from './Combat.js';
const FALLBACK_W = 76; // the procedural wedge (it faces east)
@ -239,6 +240,11 @@ export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
this.hull = this.maxHull;
this.shields = this.maxShields;
this.dead = false;
// THE REUSE SEAM: this is the SAME sprite that died (the range and
// the main game both revive in place). If a white hit-flash was
// still pending when it went down, settle the tint here too — a
// revived craft must never come back hot white.
settleCraftTint(this);
if (this.body) {
this.body.velocity.x = 0;
this.body.velocity.y = 0;

View File

@ -432,9 +432,11 @@ export class Fx {
t0: 0,
life,
kind: 'tintFlash',
// Each write is guarded on its own — a throw in one must not
// skip the other (a half-restored craft is a white craft).
restore: () => {
ship.setTintMode(mode);
ship.setTint(tint);
try { ship.setTintMode(mode); } catch { /* gone */ }
try { ship.setTint(tint); } catch { /* gone */ }
},
});
return ship;
@ -446,6 +448,27 @@ export class Fx {
return this.flash(ship.x, ship.y, 0xffffff, r * 2.6, life);
}
/**
* Settle a craft's pending white flash RIGHT NOW — the kill/destroy
* seam. A craft must never die, or be hidden and reused, while still
* holding the white-fill state: the hit is over (the kill burst or the
* reformed craft supersedes it), and the craft goes out in its normal
* colors (a sprite that dies mid-tint can leak the white state into
* its reuse — the "revived enemies are hot white and stay that way"
* edge case). Idempotent; safe on a destroyed craft.
*/
releaseCraft(ship) {
if (!ship) return;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (it.kind !== 'tintFlash' || it.img !== ship) continue;
if (it.restore) {
try { it.restore(); } catch { /* the craft may already be gone */ }
}
this.items.splice(i, 1);
}
}
// ------------------------------------------------------ the player's shield
/** The shield-sphere tuning (data/combat.json → combat.fx.shield). */
@ -663,3 +686,18 @@ export class Fx {
this.items.length = 0;
}
}
/**
* Return a craft's tint to identity (white corners + multiply mode).
* The reuse seams (revive/respawn) call this so a craft can NEVER
* reappear while still holding the white hit-flash state — the last
* line of defense for the "revived enemies are hot white" edge case.
* Safe on stubs and destroyed sprites. (Neither craft class tints for
* coloring — their colors are baked into the art — so identity is the
* always-correct default.)
*/
export function settleCraftTint(ship) {
if (!ship) return;
try { if (typeof ship.setTintMode === 'function') ship.setTintMode(0); } catch { /* stub */ }
try { if (typeof ship.setTint === 'function') ship.setTint(0xffffff); } catch { /* stub */ }
}