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 */ } }