diff --git a/dev/shots/combat-main.png b/dev/shots/combat-main.png index 02d0341..dfc50ba 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 a1c2081..8df4d0e 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 d83da17..851a426 100644 --- a/js/combat/CombatScene.js +++ b/js/combat/CombatScene.js @@ -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(); diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index 268a972..3c2c42b 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -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 = []; diff --git a/js/combat/EnemyShip.js b/js/combat/EnemyShip.js index 2244e70..ac1bc64 100644 --- a/js/combat/EnemyShip.js +++ b/js/combat/EnemyShip.js @@ -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; diff --git a/js/combat/Fx.js b/js/combat/Fx.js index d19d0af..066924c 100644 --- a/js/combat/Fx.js +++ b/js/combat/Fx.js @@ -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 */ } +}