orbit/js/combat/Fx.js

781 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Phaser from '../vendor/phaser.js';
import { toColor, toCss } from '../utils/Color.js';
import { canvasTexture } from '../utils/Textures.js';
import { fontStack } from '../utils/Theme.js';
import { config } from '../config/Config.js';
import { assignWorld } from '../visuals/UiCameras.js';
import { Bullet } from './Bullet.js';
const DOT_KEY = '__combat_dot';
const DOT_SIZE = 64;
/**
* The combat feedback palette (both contexts, one place):
* shield — a field absorbing (cyan)
* hull — armor taking the hit (orange)
* down — the player's death (hot red)
* respawn — the player reforming (cool cyan)
*/
export const FX_COLORS = {
shield: 0x7ce8ff,
hull: 0xffa04d,
kill: 0xff5d6f,
down: 0xff2d6f,
respawn: 0x7ce8ff,
xp: 0xffd84d, // the reward gold (the bar's energy color)
};
function drawSoftDot(ctx, size) {
const c = size / 2;
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
g.addColorStop(0.0, 'rgba(255,255,255,1.0)');
g.addColorStop(0.22, 'rgba(255,255,255,0.85)');
g.addColorStop(0.55, 'rgba(255,255,255,0.26)');
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, size, size);
}
/**
* The muzzle flare — white alpha (tinted at use), laid with the firing
* direction along +x: a soft core bloom at the muzzle, a bright spike
* out the bore, and a small cross glint. Reads as a directional flash.
*/
function drawFlare(ctx, w, h) {
const c = h / 2;
// Core bloom at the muzzle.
let g = ctx.createRadialGradient(c, c, 0, c, c, 30);
g.addColorStop(0.0, 'rgba(255,255,255,0.95)');
g.addColorStop(0.45, 'rgba(255,255,255,0.4)');
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
ctx.fillStyle = g;
ctx.fillRect(c - 30, c - 30, 60, 60);
// The bore spike (the firing direction, +x).
g = ctx.createLinearGradient(c, 0, w, 0);
g.addColorStop(0.0, 'rgba(255,255,255,0.0)');
g.addColorStop(0.4, 'rgba(255,255,255,0.35)');
g.addColorStop(1.0, 'rgba(255,255,255,0.95)');
ctx.fillStyle = g;
ctx.fillRect(c, c - 4, w - c, 8);
g = ctx.createLinearGradient(c, 0, w, 0);
g.addColorStop(0.0, 'rgba(255,255,255,0.0)');
g.addColorStop(1.0, 'rgba(255,255,255,0.45)');
ctx.fillStyle = g;
ctx.fillRect(c, c - 10, w - c, 20);
// Cross glint.
g = ctx.createLinearGradient(0, c - 20, 0, c + 20);
g.addColorStop(0.0, 'rgba(255,255,255,0.0)');
g.addColorStop(0.5, 'rgba(255,255,255,0.5)');
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
ctx.fillStyle = g;
ctx.fillRect(c - 14, c - 20, 28, 40);
}
/**
* The shield-sphere impact (white alpha, tinted cyan at use): a faint
* FULL CIRCLE (the sphere) + a bright ARC centered on angle 0 (the
* highlighted hit region) + a glow at the arc's midpoint (the impact
* point). Rotating the texture by the laser's incoming bearing puts the
* bright arc exactly where the shield took it. `span` rad = the arc's
* width; baked into the texture key so a config change re-cuts it.
*/
function shieldTextureKey(span) {
return `__combat_shield_s${span.toFixed(2)}`;
}
function drawShield(ctx, w, h, span) {
const c = w / 2;
const R = 96; // the sphere's radius in texture space
// The faint full circle — "a sphere".
ctx.strokeStyle = 'rgba(255,255,255,0.30)';
ctx.lineWidth = 5;
ctx.beginPath();
ctx.arc(c, c, R, 0, Math.PI * 2);
ctx.stroke();
// The bright arc (the hit region), centered on angle 0 (east).
const a0 = -span / 2;
const a1 = span / 2;
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(255,255,255,0.45)';
ctx.lineWidth = 20;
ctx.beginPath();
ctx.arc(c, c, R, a0, a1);
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.lineWidth = 7;
ctx.beginPath();
ctx.arc(c, c, R, a0, a1);
ctx.stroke();
// The impact-point glow, at the arc's midpoint (east).
const g = ctx.createRadialGradient(c + R, c, 0, c + R, c, 30);
g.addColorStop(0.0, 'rgba(255,255,255,0.95)');
g.addColorStop(0.5, 'rgba(255,255,255,0.4)');
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
ctx.fillStyle = g;
ctx.fillRect(c + R - 30, c - 30, 60, 60);
}
/**
* The craft-shield arc (enemy-side shield read — white alpha, tinted at
* use): a bright arc segment only, centered on angle 0. Rotated to the
* laser's incoming bearing it sits on the enemy's shield where it was hit.
*/
const ARC_KEY = '__combat_arc';
function drawArc(ctx, w, h) {
const c = w / 2;
const R = 84;
const a0 = -0.85;
const a1 = 0.85;
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 22;
ctx.beginPath();
ctx.arc(c, c, R, a0, a1);
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.lineWidth = 8;
ctx.beginPath();
ctx.arc(c, c, R, a0, a1);
ctx.stroke();
}
/**
* Small hand-rolled FX for combat (both contexts): laser muzzles, impact
* events, spark streams, shield-sphere reads, and the ship-kill burst.
* Plain additive images stepped in update() — deliberately NOT the
* particles emitter (this build's emitter velocity semantics are fiddly,
* see the note in js/visuals/EngineFlare.js), so the look is
* deterministic, cheap, and needs no tween stepping.
*
* All items are world-space (the arena camera is fixed, the main game's
* world scrolls), depths 11–22 (above ships (10), under the HUD (≥50)).
* Every created object is assigned to the WORLD camera pass
* (js/visuals/UiCameras.js) so the UI pass (bar, feed) never double-draws
* or shakes with it — a no-op while the split doesn't exist.
*/
export class Fx {
/** @param {object} scene the combat scene */
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);
}
/** Assign a world object to the world pass (no-op without the split). */
_world(o) {
if (o) assignWorld(this.scene, o);
return o;
}
/** The shared white soft dot (tint reads as its color). @returns {string} the key */
static ensureDot(scene) {
if (scene.textures.exists(DOT_KEY)) return DOT_KEY;
canvasTexture(scene, DOT_KEY, DOT_SIZE, DOT_SIZE, (ctx, w, h) => drawSoftDot(ctx, Math.min(w, h)));
return DOT_KEY;
}
/** The muzzle flare texture (shared). @returns {string} the key */
static ensureFlare(scene) {
const key = '__combat_flare';
if (scene.textures.exists(key)) return key;
canvasTexture(scene, key, 128, 128, (ctx, w, h) => drawFlare(ctx, w, h));
return key;
}
/** The shield-sphere impact texture (span-keyed). @returns {string} the key */
static ensureShield(scene, span) {
const key = shieldTextureKey(span);
if (scene.textures.exists(key)) return key;
canvasTexture(scene, key, 256, 256, (ctx, w, h) => drawShield(ctx, w, h, span));
return key;
}
/** The shared laser-bolt texture (the shard debris reuses it). @returns {string} the key */
static ensureBolt(scene) {
return Bullet.ensureTexture(scene);
}
/** The craft-shield arc texture (shared). @returns {string} the key */
static ensureArc(scene) {
if (scene.textures.exists(ARC_KEY)) return ARC_KEY;
canvasTexture(scene, ARC_KEY, 256, 256, (ctx, w, h) => drawArc(ctx, w, h));
return ARC_KEY;
}
// ----------------------------------------------------------- the basics
/** An additive glow: `size` px across at full strength, fading over `life` ms. */
flash(x, y, color, size = 40, life = 220) {
const img = this._world(this.scene
.add.image(x, y, DOT_KEY)
.setTint(toColor(color, 0xffffff))
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(20));
this.items.push({ img, t0: 0, life, kind: 'flash', grow: size / DOT_SIZE });
return img;
}
/** A growing, fading disc — the shockwave read (a stand-in for a stroke ring). */
ring(x, y, color, size = 180, life = 450) {
const img = this._world(this.scene
.add.image(x, y, DOT_KEY)
.setTint(toColor(color, 0xffffff))
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(19)
.setScale(6 / DOT_SIZE)
.setAlpha(0.8));
this.items.push({ img, t0: 0, life, kind: 'ring', from: 6 / DOT_SIZE, to: size / DOT_SIZE });
return img;
}
/**
* A burst of sparks (small dots on random headings, decaying). `dirAngle`
* + `cone` focus the burst along a heading (impact reads); omitted = the
* classic all-round burst (the kill/respawn cues).
*/
sparks(x, y, color, count = 12, speed = 220, life = 500, dirAngle = null, cone = 0.9) {
for (let i = 0; i < count; i++) {
const a = dirAngle == null
? Math.random() * Math.PI * 2
: dirAngle + (Math.random() * 2 - 1) * cone;
const sp = speed * (0.4 + Math.random() * 0.8);
const s = 4 + Math.random() * 5;
const img = this._world(this.scene
.add.image(x, y, DOT_KEY)
.setTint(toColor(color, 0xffffff))
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(21)
.setScale(s / DOT_SIZE));
this.items.push({
img,
t0: 0,
life: life * (0.6 + Math.random() * 0.6),
kind: 'spark',
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp,
});
}
}
/**
* A floating text cue (damage numbers, DOWN / REFORMED). Pops in a touch
* large, rises, and fades over `life` ms. Needs scene.add.text (real
* scenes have it; the test stubs don't — a missing text seam is a
* graceful no-op).
*/
text(x, y, str, color, size = 15, life = 500, rise = 26) {
if (typeof this.scene.add?.text !== 'function') return null;
const t = this._world(this.scene.add.text(x, y, str, {
fontFamily: fontStack('body'),
fontSize: `${size}px`,
fontStyle: 'bold',
color: toCss(color, '#ffffff'), // canvas text needs a CSS string
stroke: '#02131c',
strokeThickness: 4,
}).setOrigin(0.5).setDepth(22).setAlpha(0));
this.items.push({ img: t, t0: 0, life, kind: 'text', rise });
return t;
}
/**
* A damage number (both contexts): the points just dealt, floating up
* off the point of impact — cyan when the shields took it, orange
* when the armor took it.
*/
damage(x, y, amount, kind = 'hull') {
const n = Math.max(0, Math.round(Number(amount) || 0));
const color = kind === 'shield' ? FX_COLORS.shield : FX_COLORS.hull;
this.text(x, y - 10, String(n), color, 15, 520, 26);
}
/**
* An XP gain (the kill's reward — js/combat/CombatSystem.js banks it on
* the kill): the points earned, floating up off the wreckage in the
* reward gold (FX_COLORS.xp) — a touch smaller + slower than a damage
* number, so it reads as a REWARD landing, not another hit.
*/
xp(x, y, amount) {
const n = Math.max(0, Math.round(Number(amount) || 0));
this.text(x, y - 24, `+${n}`, FX_COLORS.xp, 14, 750, 32);
}
// ------------------------------------------------------------ the lasers
/**
* The muzzle flash — a directional burst out the bore: a bright spike
* along the firing axis + a white core. `angle` = the firing direction
* (rad); `size` px = the flare's full extent (weapon.muzzle).
*/
muzzle(x, y, angle, color, size = 46, life = 130) {
const flare = this._world(this.scene
.add.image(x, y, Fx.ensureFlare(this.scene))
.setTint(toColor(color, 0xffffff))
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(21)
.setAngle(angle * 180 / Math.PI));
const s0 = Math.max(0.2, size / 128);
flare.setScale(s0);
this.items.push({ img: flare, t0: 0, life, kind: 'muzzle', scale0: s0, alpha0: 0.95 });
// The hot-white core at the muzzle.
this.flash(x, y, 0xffffff, Math.max(14, size * 0.5), life * 0.8);
return flare;
}
// ----------------------------------------------------------- the impacts
/**
* A hit on an enemy craft — the full impact read at the point of
* contact:
* - a hot core flash + the layer's bloom,
* - directional sparks ALONG THE BULLET'S TRAVEL VECTOR,
* - a SHIELD hit: a cyan shockwave + a bright arc ripple on the
* craft's shield (the `__combat_arc` read, at the incoming bearing),
* - a HULL hit: hot shards flung along the travel vector + a small
* orange ring,
* - and a white hit-flash on the craft itself (the tintFill pulse,
* or a white bloom when the build can't fill-tint).
*
* @param {object} ship the enemy (needs .x/.y/.hitRadius; the flash
* wants .setTint/.setTintFill)
* @param {number} x impact point
* @param {number} y impact point
* @param {number} travelAngle the bullet's flight direction (rad)
* @param {string} kind 'shield' | 'hull'
* @param {number} damage the damage just dealt (scales the read)
*/
enemyHit(ship, x, y, travelAngle, kind, damage) {
const d = Math.max(1, Number(damage) || 8);
const k = Math.min(1.8, 0.8 + d / 30);
const color = kind === 'shield' ? FX_COLORS.shield : FX_COLORS.hull;
// The core + the layer's bloom (sized by the damage).
this.flash(x, y, 0xffffff, Math.max(26, 30 * k), 150);
this.flash(x, y, color, 46 * k, 240);
// Directional sparks along the bullet's travel vector.
const n = Math.round(7 + Math.min(9, d * 0.7));
this.sparks(x, y, color, n, kind === 'shield' ? 250 : 320, kind === 'shield' ? 460 : 560, travelAngle, kind === 'shield' ? 0.95 : 0.65);
this.sparks(x, y, 0xffffff, 3, 200, 320, travelAngle, 0.8);
if (kind === 'shield') {
// The cyan shockwave off the shield + the arc ripple on the craft.
this.ring(x, y, color, Math.max(48, 54 * k), 380);
if (ship) {
this.craftShieldArc(ship, travelAngle);
this.craftFlash(ship);
}
} else {
// The armor taking it: a tight ring + hot shards flung the way
// the bolt was going.
this.ring(x, y, color, Math.max(34, 40 * k), 300);
this.shards(x, y, travelAngle, color, 4, 340, 520);
if (ship) this.craftFlash(ship);
}
}
/**
* The craft's shield read: a bright arc on the enemy's shield, centered
* on the laser's INCOMING bearing (the side that faced the shooter),
* popping and fading fast.
*/
craftShieldArc(ship, travelAngle) {
const r = Math.max(8, Number(ship.hitRadius) || 20);
const img = this._world(this.scene
.add.image(ship.x, ship.y, Fx.ensureArc(this.scene))
.setTint(toColor(FX_COLORS.shield, 0xffffff))
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(12)
.setAngle((travelAngle + Math.PI) * 180 / Math.PI));
const s0 = (r + 10) / 84;
img.setScale(s0 * 0.8);
this.items.push({ img, t0: 0, life: 300, kind: 'arcFlash', scale0: s0, alpha0: 0.9 });
return img;
}
/**
* Hot, fast, elongated fragments flung along `angle` ± a cone — the
* hull-hit debris. Reuses the bolt texture at shard scale.
*/
shards(x, y, angle, color, count = 4, speed = 320, life = 500) {
const key = Fx.ensureBolt(this.scene);
for (let i = 0; i < count; i++) {
const a = angle + (Math.random() * 2 - 1) * 0.7;
const sp = speed * (0.5 + Math.random() * 0.9);
const img = this._world(this.scene
.add.image(x, y, key)
.setTint(toColor(color, 0xffffff))
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(21)
.setAngle(a * 180 / Math.PI));
const s = 0.16 + Math.random() * 0.14;
img.setScale(s, s * 0.5);
this.items.push({
img,
t0: 0,
life: life * (0.7 + Math.random() * 0.6),
kind: 'spark',
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp,
});
}
}
/**
* The white hit-flash on a craft (the "it landed on the ship" read).
* THIS BUILD (Phaser 4.2.1 Giedi): `setTintFill` is a REMOVED no-op
* (it only console.errors), but the render pipeline still honors a
* solid fill via `tintMode` 1 (color = the tint color) — so the flash
* is: tint white + tintMode 1 for the beat, then restore both. (A
* sprite without those seams — the test stubs — gets the white bloom.)
* Note: the item's `img` is the SHIP — the expiry/clear paths must
* only `restore()` it, never `destroy()` it.
*/
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,
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: () => {
try { ship.setTintMode(mode); } catch { /* gone */ }
try { ship.setTint(tint); } catch { /* gone */ }
},
});
return ship;
} catch {
/* 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);
}
/**
* 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). */
shieldCfg() {
const s = config.section('combat.fx.shield', {});
const shipRadius = Number(s.shipRadius) || 0;
return {
radius: Math.max(16, Number(s.radius) || (shipRadius ? shipRadius + 17 : 40)),
arcSpan: Math.max(0.6, Number(s.arcSpan) || 1.9),
ripple: Math.max(30, Number(s.ripple) || 90),
decayMs: Math.max(180, Number(s.decayMs) || 520),
};
}
/**
* THE SHIELD read — the player's sphere struck from the laser's
* incoming bearing, with the hit region highlighted:
* - a brief full-sphere outline flash (a sphere being hit),
* - a bright ARC SEGMENT centered on the impact bearing (the
* highlighted region),
* - a radial ripple from the impact point,
* - a spark burst off the surface,
* - and a white hit-flash on the ship.
* Intensity scales with the damage; everything decays over ~0.5 s.
*
* @param {object} ship the player's ship (needs .x/.y/.radius)
* @param {number} bearing angle from the ship's center toward the
* laser's source (rad) — where the shield was struck
* @param {number} damage the damage just dealt (scales the read)
* @returns {{ix: number, iy: number, k: number}} the impact point + intensity
*/
shieldImpact(ship, bearing, damage) {
const cfg = this.shieldCfg();
const r = cfg.radius;
const d = Math.max(1, Number(damage) || 8);
const k = Math.min(1.9, 0.75 + d / 28);
const ix = ship.x + Math.cos(bearing) * r;
const iy = ship.y + Math.sin(bearing) * r;
const shield = FX_COLORS.shield;
// 1) The sphere + the highlighted arc, one texture (the arc is baked
// at angle 0; rotating by the bearing centers it on the impact).
const span = cfg.arcSpan;
const sph = this._world(this.scene
.add.image(ship.x, ship.y, Fx.ensureShield(this.scene, span))
.setTint(toColor(shield, 0xffffff))
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(19)
.setAngle(bearing * 180 / Math.PI));
const s0 = (r + 8) / 96;
sph.setScale(s0 * 0.94);
this.items.push({ img: sph, t0: 0, life: cfg.decayMs, kind: 'shieldSphere', scale0: s0, alpha0: Math.min(1, 0.55 + 0.4 * k) });
// 2) The impact read at the surface point.
this.flash(ix, iy, 0xffffff, 26 * k, 150);
this.flash(ix, iy, shield, 40 * k, 260);
this.ring(ix, iy, shield, cfg.ripple * k, 420);
// 3) Sparks off the surface, biased outward along the bearing.
this.sparks(ix, iy, shield, Math.round(6 + Math.min(8, d * 0.6)), 240, 480, bearing, 0.8);
this.sparks(ix, iy, 0xffffff, 3, 190, 340, bearing, 0.7);
// 4) The ship flinches (a white hit-flash).
this.craftFlash(ship, 160);
return { ix, iy, k };
}
/**
* The shield-COLLAPSE cue (shields gone → the hull takes it): the sphere
* implodes, a shockwave rolls out, and the surface sheds sparks all
* the way around.
*/
shieldBreak(ship) {
if (!ship) return;
const r = this.shieldCfg().radius;
const shield = FX_COLORS.shield;
// The implosion — a ring that grows IN and fades.
const img = this._world(this.scene
.add.image(ship.x, ship.y, DOT_KEY)
.setTint(toColor(shield, 0xffffff))
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(19)
.setScale((r * 1.25) / DOT_SIZE)
.setAlpha(0.9));
this.items.push({ img, t0: 0, life: 400, kind: 'ringIn', from: (r * 1.25) / DOT_SIZE, to: 4 / DOT_SIZE });
// The shockwave + the surface shedding.
this.ring(ship.x, ship.y, shield, r * 2.4, 460);
this.sparks(ship.x, ship.y, shield, 14, 230, 520);
this.sparks(ship.x, ship.y, 0xffffff, 6, 180, 380);
this.flash(ship.x, ship.y, 0xffffff, 56, 180);
}
// -------------------------------------------------------------- the cues
/** The big one — a ship's death: core flash + colored bloom + wave + sparks. */
explode(x, y, color) {
this.flash(x, y, 0xffffff, 90, 160);
this.flash(x, y, color, 150, 420);
this.ring(x, y, color, 200, 480);
this.delayedRing(x, y, color, 300, 620, 140);
this.sparks(x, y, color, 26, 320, 750);
}
/**
* A ring that waits `delay` ms before it starts (the kill burst's
* second shockwave). Spawned invisible until its delay runs out.
*/
delayedRing(x, y, color, size, life, delay) {
const img = this._world(this.scene
.add.image(x, y, DOT_KEY)
.setTint(toColor(color, 0xffffff))
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(19)
.setScale(6 / DOT_SIZE)
.setAlpha(0));
this.items.push({ img, t0: 0, life, kind: 'ring', from: 6 / DOT_SIZE, to: size / DOT_SIZE, delay });
return img;
}
/**
* The player-DOWN cue: the death burst in hot red + a big DOWN hold
* (the "the hull broke" beat before the reform clock).
*/
down(x, y) {
this.flash(x, y, 0xffffff, 120, 180);
this.flash(x, y, FX_COLORS.down, 200, 520);
this.ring(x, y, FX_COLORS.down, 260, 600);
this.sparks(x, y, FX_COLORS.down, 24, 300, 800);
this.text(x, y - 34, 'DOWN', FX_COLORS.down, 26, 950, 10);
}
/** The player-RESPAWN cue: a clean cyan bloom + the REFORMED pop. */
respawn(x, y) {
this.flash(x, y, 0xffffff, 90, 140);
this.flash(x, y, FX_COLORS.respawn, 160, 420);
this.ring(x, y, FX_COLORS.respawn, 220, 500);
this.text(x, y - 30, 'REFORMED', FX_COLORS.respawn, 16, 850, 12);
}
// ----------------------------------------------------------------- stepping
/** Step every live item (ms). Dead ones are destroyed. */
update(deltaMs) {
const dt = deltaMs / 1000;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
it.t0 += deltaMs;
const eff = it.t0 - (it.delay || 0);
if (eff <= 0) continue; // still in its delay — stays hidden
const t = Math.min(1, eff / it.life);
if (t >= 1) {
if (it.restore) {
try { it.restore(); } catch { /* the object may already be gone */ }
}
// 'tintFlash' items OWN the craft (img = the ship) — restore it,
// NEVER destroy it (every other kind owns its image).
if (it.kind !== 'tintFlash') it.img.destroy();
this.items.splice(i, 1);
continue;
}
if (it.kind === 'flash') {
it.img.setScale(it.grow * (0.6 + 0.4 * t));
it.img.setAlpha(1 - t);
} else if (it.kind === 'ring') {
it.img.setScale(it.from + (it.to - it.from) * t);
it.img.setAlpha(0.8 * (1 - t));
} else if (it.kind === 'ringIn') {
it.img.setScale(it.from + (it.to - it.from) * (1 - Math.pow(1 - t, 2)));
it.img.setAlpha(0.9 * (1 - t));
} else if (it.kind === 'text') {
// Pop in a touch large, rise, and fade.
const fadeIn = Math.min(1, eff / Math.max(1, it.life * 0.15));
const fadeOut = t > 0.6 ? 1 - (t - 0.6) / 0.4 : 1;
it.img.setAlpha(0.95 * fadeIn * fadeOut);
if (typeof it.img.setScale === 'function') {
const settle = Math.max(0, 1 - eff / Math.max(1, it.life * 0.3));
it.img.setScale(1 + 0.5 * settle * settle);
}
it.img.y -= (it.rise / it.life) * deltaMs;
} else if (it.kind === 'spark') {
it.img.x += it.vx * dt;
it.img.y += it.vy * dt;
const decay = Math.max(0, 1 - 2.2 * dt);
it.vx *= decay;
it.vy *= decay;
it.img.setAlpha(1 - t);
} else if (it.kind === 'muzzle') {
it.img.setAlpha(it.alpha0 * Math.pow(1 - t, 1.4));
it.img.setScale(it.scale0 * (1 + 1.1 * t));
} else if (it.kind === 'shieldSphere') {
it.img.setAlpha(it.alpha0 * Math.pow(1 - t, 1.3));
it.img.setScale(it.scale0 * (1 + 0.08 * t));
} else if (it.kind === 'arcFlash') {
const a = t < 0.3 ? t / 0.3 : 1 - (t - 0.3) / 0.7;
it.img.setAlpha(it.alpha0 * Math.max(0, a));
it.img.setScale(it.scale0 * (0.85 + 0.4 * t));
}
// '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. */
clear() {
for (const it of this.items) {
if (it.restore) {
try { it.restore(); } catch { /* gone */ }
} else {
it.img.destroy();
}
}
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 */ }
}