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; const COMET_KEY = '__combat_comet'; const BLAST_KEY = '__combat_blast'; const BLAST_RING_KEY = '__combat_blast_ring'; const BLAST_RING_R = 114; // the rim's radius in the blast-ring texture (256 px) const BOLA_BAR_KEY = '__combat_bola_bar'; // _RAM_BLEND — why the ramming-charge images (comet head, ember trail, // blast fireball, blast ring) use SCREEN instead of ADD. In this Phaser // build the WebGL blend functions are (measured live off the renderer): // ADD = gl.blendFunc(ONE, DST_COLOR) → out = src + dst*dstColor // SCREEN = gl.blendFunc(ONE, ONE_MINUS_SRC_COLOR) → out = src + dst*(1-src) // The world (nebula/starfield) is SEMI-TRANSPARENT, so under ADD the whole // texture quad multiplies the background by its own sub-1 color — a visible // "dark box" behind every large fire sprite. SCREEN scales the background by // (1 - srcColor), which is 1 wherever the sprite is transparent, so the world // shows through untouched at the sprite's edges and the bright core still // glows. Pair it with COLOR+ALPHA textures (alpha → 0 at the rim), NOT opaque // fills, so the transparent rim is a true identity. /** * 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. */ /** * THE RAMMING CHARGE'S COMET HEAD (colored fire — drawn in, NOT tinted; * additive blending does the glow): a white-hot core, an amber bloom, and * a flame tongue trailing toward −x. Rotating the texture by the charge's * heading puts the flame BEHIND the hull. 256×256, head at center — the * head (≈96 px of hot core) fully swallows a ~14 px hull. */ function drawComet(ctx, w, h) { const c = Math.min(w, h) / 2; const L = c; const hx = c; const hy = c; // WHY COLOR + ALPHA (see the _RAM_BLEND note below the keys): // The ram images are composited with SCREEN — in this build that is // gl.blendFunc(ONE, ONE_MINUS_SRC_COLOR), i.e. `out = src + dst*(1-src)`. // A pixel's SRC COLOR is what controls how much of the world (dst) shows // through, so the non-fire regions must be TRANSPARENT (alpha 0 ⇒ // premultiplied src 0 ⇒ dst fully preserved). An OPAQUE fill (even a // black one) would still carry color/alpha that tints or covers the // world — that is the "dark box" bug. So every stop below fades the // alpha to 0 at the outer edge / tail tip, and the core is near-opaque. // (ADD — gl.blendFunc(ONE, DST_COLOR) — was wrong: its dst term scales the // nebula by its own color across the WHOLE texture quad, dimming the // world behind the sprite into a visible square.) // 1) The wide outer heat wash (hot center → dark red, alpha → 0). let g = ctx.createRadialGradient(hx, hy, 0, hx, hy, c); g.addColorStop(0.00, 'rgba(255,225,165,0.95)'); g.addColorStop(0.28, 'rgba(255,160,70,0.85)'); g.addColorStop(0.55, 'rgba(255,95,45,0.60)'); g.addColorStop(0.78, 'rgba(170,45,22,0.32)'); g.addColorStop(0.92, 'rgba(90,20,10,0.14)'); g.addColorStop(1.00, 'rgba(60,14,8,0.0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); // 2) The flame tongue — a fat amber body + a hot narrow core, each // thinning in BOTH width and alpha toward a fully-transparent point. const tail = (halfW, stops) => { const tg = ctx.createLinearGradient(hx + 12, hy, hx - L, hy); for (const [o, col] of stops) tg.addColorStop(o, col); ctx.fillStyle = tg; ctx.beginPath(); ctx.moveTo(hx + 12, hy - halfW); ctx.quadraticCurveTo(hx - L * 0.35, hy - halfW * 1.3, hx - L, hy); ctx.quadraticCurveTo(hx - L * 0.35, hy + halfW * 1.3, hx + 12, hy + halfW); ctx.closePath(); ctx.fill(); }; tail(26, [[0, 'rgba(255,195,105,0.90)'], [0.45, 'rgba(255,120,55,0.60)'], [0.78, 'rgba(150,40,20,0.28)'], [1, 'rgba(80,20,10,0.0)']]); tail(12, [[0, 'rgba(255,245,215,0.95)'], [0.45, 'rgba(255,210,130,0.70)'], [0.78, 'rgba(190,70,30,0.30)'], [1, 'rgba(100,30,14,0.0)']]); // 3) The white-hot core (the star at the head's center). g = ctx.createRadialGradient(hx, hy, 0, hx, hy, c * 0.5); g.addColorStop(0.0, 'rgba(255,255,255,1.0)'); g.addColorStop(0.5, 'rgba(255,246,216,0.95)'); g.addColorStop(0.85, 'rgba(255,210,150,0.55)'); g.addColorStop(1.0, 'rgba(255,180,110,0.0)'); ctx.fillStyle = g; ctx.fillRect(hx - c * 0.5, hy - c * 0.5, c, c); } /** * THE RAM'S ARRIVAL FIREBALL (colored fire, like the comet — drawn in, * not tinted): a white-hot center, an amber mid, a red outer wash, and * eight irregular flame spikes so the burst reads as EXPLOSION, not * circle. 256×256, ~180 px of visible fire. */ function drawBlast(ctx, w, h) { const c = Math.min(w, h) / 2; // COLOR + ALPHA (see drawComet). Non-fire regions are alpha 0 → no box. // The fire wash (white → amber → red → transparent). let g = ctx.createRadialGradient(c, c, 0, c, c, c); g.addColorStop(0.0, 'rgba(255,255,255,1.0)'); g.addColorStop(0.14, 'rgba(255,242,185,0.95)'); g.addColorStop(0.34, 'rgba(255,170,70,0.78)'); g.addColorStop(0.58, 'rgba(255,100,48,0.50)'); g.addColorStop(0.78, 'rgba(180,50,26,0.28)'); g.addColorStop(0.92, 'rgba(95,22,12,0.12)'); g.addColorStop(1.0, 'rgba(60,14,8,0.0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); // The flame spikes (each a tapered tongue fading to a transparent point). ctx.save(); ctx.translate(c, c); for (let i = 0; i < 8; i++) { ctx.rotate((Math.PI / 4) * i + (i % 2 ? 0.16 : -0.11)); const len = c * (0.82 + 0.14 * (i % 3)); const sg = ctx.createLinearGradient(0, 0, len, 0); sg.addColorStop(0.0, 'rgba(255,235,165,0.90)'); sg.addColorStop(0.5, 'rgba(255,150,65,0.55)'); sg.addColorStop(0.82, 'rgba(160,45,24,0.26)'); sg.addColorStop(1.0, 'rgba(90,22,12,0.0)'); ctx.fillStyle = sg; ctx.beginPath(); ctx.moveTo(0, -c * 0.10); ctx.quadraticCurveTo(len * 0.55, -c * 0.07, len, 0); ctx.quadraticCurveTo(len * 0.55, c * 0.07, 0, c * 0.10); ctx.closePath(); ctx.fill(); } ctx.restore(); // The searing center (re-asserted on top of the spikes). g = ctx.createRadialGradient(c, c, 0, c, c, c * 0.4); g.addColorStop(0.0, 'rgba(255,255,255,1.0)'); g.addColorStop(0.6, 'rgba(255,246,215,0.90)'); g.addColorStop(1.0, 'rgba(255,205,140,0.0)'); ctx.fillStyle = g; ctx.fillRect(c - c * 0.4, c - c * 0.4, c * 0.8, c * 0.8); } /** * THE DAMAGE-RADIUS RING (white alpha, tinted at use): a bright rim with * a soft inner fill — the one circle that says "everything inside here * took the hit". The rim sits at radius BLAST_RING_R so the caller can * scale it to land EXACTLY on the blast radius (skillBlast's radius). */ function drawBlastRing(ctx, w, h) { const c = Math.min(w, h) / 2; const R = BLAST_RING_R; // The faint inner fill (the blast AREA, readable at a glance). const g = ctx.createRadialGradient(c, c, 0, c, c, R); g.addColorStop(0.0, 'rgba(255,255,255,0.30)'); g.addColorStop(0.80, 'rgba(255,255,255,0.22)'); g.addColorStop(0.92, 'rgba(255,255,255,0.85)'); g.addColorStop(1.0, 'rgba(255,255,255,0.0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); // The bright rim (a wide soft pass under a thin hot one). ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 16; ctx.beginPath(); ctx.arc(c, c, R, 0, Math.PI * 2); ctx.stroke(); ctx.strokeStyle = 'rgba(255,255,255,0.95)'; ctx.lineWidth = 5; ctx.beginPath(); ctx.arc(c, c, R, 0, Math.PI * 2); ctx.stroke(); } 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. */ /** * A jagged bolt radiating OUTWARD from near the hull (midpoint * displacement, three refinement passes) — the strung craft's discharge * (stunArms). Local coords: the origin is the craft's center, the hull * at `r`. Random per strike — combat sparks, not the sky's seeded * lightning (js/visuals/NebulaLightning.js). * * @returns {{pts: [number, number][], age: number, life: number}} */ function jagBolt(r) { const a0 = Math.random() * Math.PI * 2; const len = r * (2.0 + Math.random() * 1.5); const sx = Math.cos(a0) * r * 0.3; const sy = Math.sin(a0) * r * 0.3; const ex = Math.cos(a0) * len; const ey = Math.sin(a0) * len; let pts = [[sx, sy], [ex, ey]]; let disp = len * 0.3; for (let pass = 0; pass < 3; pass++) { const next = [pts[0]]; for (let i = 0; i < pts.length - 1; i++) { const ax = pts[i][0], ay = pts[i][1]; const bx = pts[i + 1][0], by = pts[i + 1][1]; const mx = (ax + bx) / 2, my = (ay + by) / 2; const nx = -(by - ay), ny = bx - ax; const nl = Math.hypot(nx, ny) || 1; const off = (Math.random() * 2 - 1) * disp; next.push([mx + (nx / nl) * off, my + (ny / nl) * off]); } next.push(pts[pts.length - 1]); pts = next; disp *= 0.5; } return { pts, age: 0, life: 140 + Math.random() * 180 }; } 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 comet-head texture (colored fire — never tinted). @returns {string} the key */ static ensureComet(scene) { if (scene.textures.exists(COMET_KEY)) return COMET_KEY; canvasTexture(scene, COMET_KEY, 256, 256, (ctx, w, h) => drawComet(ctx, w, h)); return COMET_KEY; } /** The arrival-fireball texture (colored fire — never tinted). @returns {string} the key */ static ensureBlast(scene) { if (scene.textures.exists(BLAST_KEY)) return BLAST_KEY; canvasTexture(scene, BLAST_KEY, 256, 256, (ctx, w, h) => drawBlast(ctx, w, h)); return BLAST_KEY; } /** The damage-radius ring texture (white alpha, tinted at use). @returns {string} the key */ static ensureBlastRing(scene) { if (scene.textures.exists(BLAST_RING_KEY)) return BLAST_RING_KEY; canvasTexture(scene, BLAST_RING_KEY, 256, 256, (ctx, w, h) => drawBlastRing(ctx, w, h)); return BLAST_RING_KEY; } /** 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 bolas' STRING texture (a soft bar along +x, white alpha — tinted at * use): the line the two orbs of a `boleadoras` set spin on. @returns {string} */ static ensureBar(scene) { if (scene.textures.exists(BOLA_BAR_KEY)) return BOLA_BAR_KEY; canvasTexture(scene, BOLA_BAR_KEY, 128, 16, (ctx, w, h) => { const c = h / 2; const g = ctx.createLinearGradient(0, 0, 0, h); g.addColorStop(0.0, 'rgba(255,255,255,0.0)'); g.addColorStop(0.5, 'rgba(255,255,255,0.85)'); g.addColorStop(1.0, 'rgba(255,255,255,0.0)'); ctx.fillStyle = g; ctx.fillRect(10, c - 3, w - 20, 6); ctx.beginPath(); ctx.arc(10, c, 4, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(w - 10, c, 4, 0, Math.PI * 2); ctx.fill(); }); return BOLA_BAR_KEY; } // ----------------------------------------------------------- the basics /** A glow: `size` px across at full strength, fading over `life` ms. SCREEN * (see the _RAM_BLEND note) so the glow never dims the world behind it. */ 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.SCREEN) .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). * SCREEN (see _RAM_BLEND): this disc grows large, so ADD would dim the whole * quad into a visible dark box over the nebula. */ 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.SCREEN) .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.SCREEN) // see _RAM_BLEND note .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 ram's fire /** * A lingering EMBER (the comet trail's grain): a small additive dot that * drifts slowly and cools — it grows a touch as it fades (a fireball * expanding as it cools), with a SLOW quadratic fade so the wake lingers * for its whole ~1 s life instead of popping out. Depth 11 (just above * the hull at 10) so the trail sits ON the wake's path, not under the * blast's rings. */ ember(x, y, color, size = 12, life = 1000, drift = 26, alpha0 = 0.9) { const a = Math.random() * Math.PI * 2; const sp = drift * (0.3 + Math.random() * 0.7); const s0 = Math.max(2, size) / DOT_SIZE; const img = this._world(this.scene .add.image(x, y, DOT_KEY) .setTint(toColor(color, 0xffffff)) .setBlendMode(Phaser.BlendModes.SCREEN) // see _RAM_BLEND note .setDepth(11) .setScale(s0)); this.items.push({ img, t0: 0, life: life * (0.75 + Math.random() * 0.5), kind: 'ember', vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, s0, alpha0, }); return img; } /** * One frame of the charge's WAKE (called every dash frame from * js/combat/CombatSystem.js → _stepDash): a white-hot lead dot on the * hull's path, the hot core a hair behind it, and two strays thrown a * little to the side — so the trail reads as a voluminous fireball * shedding embers, not a single streak. */ trailEmbers(x, y, angle, color) { const cos = Math.cos(angle); const sin = Math.sin(angle); this.ember(x, y, 0xffffff, 11, 1050); this.ember(x - cos * 6, y - sin * 6, color, 16, 1150); for (let i = 0; i < 2; i++) { const off = (Math.random() * 2 - 1) * 11; this.ember(x - sin * off, y + cos * off, i === 0 ? color : 0xffffff, 7 + Math.random() * 7, 950); } } /** * THE COMET HEAD — the fireball that rides over the hull for the whole * charge. One additive image (drawComet's colored fire) that FLOWING * follows `target` every frame, holds `angle` (the charge heading — the * flame tongue trails behind), and FLICKERS (two incommensurate sines * on the scale) so it breathes like a flame, then fades out its last * 140 ms. `cometStop()` kills it early (the ram landing). Returns a * handle for that — Fx items are otherwise owned by the update loop. * * @param {object} ship the hull to ride (needs .x/.y/.radius) * @param {number} angle the charge heading (rad) * @param {number} lifeMs how long it burns (the charge's duration) * @returns {{img: object}} the handle (pass to cometStop) */ cometStart(ship, angle, lifeMs = 800) { const r = Math.max(10, Number(ship?.radius) || 14); const img = this._world(this.scene .add.image(ship?.x ?? 0, ship?.y ?? 0, Fx.ensureComet(this.scene)) .setBlendMode(Phaser.BlendModes.SCREEN) // see _RAM_BLEND note .setDepth(11) // just above the hull (10) — the fire sits ON the ship .setAngle(angle * 180 / Math.PI)); // The head's hot core (~96 px in texture space at the 256 canvas) must // fully swallow the hull: target head radius ≈ 1.8× the hull's — a // COMET, not a spark. The tail (128 px) then streams ~half a screen // of flame behind the ship at this scale. const s0 = Math.max(44, r * 4) / 128; img.setScale(s0); this.items.push({ img, t0: 0, life: Math.max(1, Number(lifeMs) || 300), kind: 'comet', target: ship, s0, }); return { img }; } /** Kill a live comet NOW (the impact supersedes it). Safe twice. */ cometStop(handle) { if (!handle) return; for (let i = this.items.length - 1; i >= 0; i--) { const it = this.items[i]; if (it.kind === 'comet' && it.img === handle.img) { it.img.destroy(); this.items.splice(i, 1); } } } // ------------------------------------------------------- the Wraith's snare /** * A BOLA SET (data/skills.json → effect.kind 'boleadoras'): two glowing * orbs — the skill sheet's own snaring beads — spinning on the flight axis * with the soft string spanning them (ensureBar's bar, tinted the data's * stringColor). One LIVE handle: the combat system drives it every frame * (bolaStep) and kills it on the snag (bolaStop). It is NOT an Fx item — * its life is the projectile's (the snag's clock), not a fixed timer's. * * @param {number} x spawn * @param {number} y spawn * @param {object} [fx] the data's fx block (ballColor / stringColor / * ballSize / stringRadius — every one read with a neutral fallback) * @returns {object} the handle (pass to bolaStep / bolaStop) */ bolaStart(x, y, fx = {}) { const ballColor = toColor(fx?.ballColor, 0xffffff); const stringColor = toColor(fx?.stringColor, 0xffffff); const ballSize = Math.max(4, Number(fx?.ballSize) || 12); const r = Math.max(6, Number(fx?.stringRadius) || 14); const orb = this._world(this.scene .add.image(x, y, DOT_KEY) .setTint(ballColor) .setBlendMode(Phaser.BlendModes.SCREEN) // see the _RAM_BLEND note .setDepth(12) .setScale(ballSize / DOT_SIZE)); const b2 = this._world(this.scene .add.image(x, y, DOT_KEY) .setTint(ballColor) .setBlendMode(Phaser.BlendModes.SCREEN) .setDepth(12) .setScale(ballSize / DOT_SIZE)); const str = this._world(this.scene .add.image(x, y, Fx.ensureBar(this.scene)) .setTint(stringColor) .setBlendMode(Phaser.BlendModes.SCREEN) .setDepth(11) .setAlpha(0.85) .setScale(r / 64, 1)); const handle = { b1: orb, b2, str, r, phase: Math.random() * Math.PI * 2, spin: 14, dead: false }; this.bolaStep(handle, 0, x, y, 0); return handle; } /** * Re-seat a bola set (called every flight frame from the combat system): * the orbs advance their spin on the flight axis and the string always * spans the two of them (its length is the full diameter — a spinning * pair stays a pair). Pure geometry + position writes, no state. * * @param {object} handle a bolaStart's * @param {number} dt frame delta (s) — the spin's clock * @param {number} x the set's centre * @param {number} y the set's centre * @param {number} angle the travel heading (rad) */ bolaStep(handle, dt, x, y, angle) { if (!handle || handle.dead) return; handle.phase += (Number(handle.spin) || 14) * (Number(dt) || 0); const { b1, b2, str, r } = handle; // The orbs' offset direction: the axis perpendicular to travel, rotated // by the phase — so the pair WHEELS around the line of flight. const px = -Math.sin(angle), py = Math.cos(angle); const dx = Math.cos(angle), dy = Math.sin(angle); const c = Math.cos(handle.phase), s = Math.sin(handle.phase); const ox = (px * c + dx * s) * r; const oy = (py * c + dy * s) * r; b1.x = x + ox; b1.y = y + oy; b2.x = x - ox; b2.y = y - oy; str.x = x; str.y = y; str.setAngle((Math.atan2(oy, ox) * 180) / Math.PI); } /** Kill a bola set NOW (the snag or the fizzle supersedes it). Safe twice. */ bolaStop(handle) { if (!handle || handle.dead) return; handle.dead = true; for (const img of [handle.b1, handle.b2, handle.str]) { if (img && !img.destroyed) img.destroy(); } } /** * THE SNAG READ (a bola set lands on a craft): the cord's ring around the * victim at its own scale, hot sparks off the impact, the white hit-flash * — the craft was just caught, and it shows it. * @param {object} e the strung hostile (needs .x/.y/.hitRadius) * @param {number} color the data's ball color (int) */ snag(e, color) { const x = e.x; const y = e.y; const r = Math.max(Number(e?.hitRadius) || 20, 12); this.ring(x, y, color, r * 5, 420); this.sparks(x, y, color, 10, 240, 480); this.sparks(x, y, 0xffffff, 6, 180, 420); this.craftFlash(e); } /** * THE STRUNG CRAFT'S DISCHARGE (data/skills.json → 'boleadoras' stun; * its `fx.stunAlpha` dims the hull — the combat gate does that). * While the snare holds, the craft is a live circuit: jagged bolts * crackle off the hull on a strike clock, hot sparks tick, and every * fewth strike the hull STUTTERS (a white bloom). One world-pass * Graphics (glow + white-hot core in the data's ball color), redrawn * each frame; the item's life IS the snare's remaining clock, so the * discharge dies exactly when the snare's does. * * @param {object} e the strung craft (.x/.y/.hitRadius/.dead) * @param {number} color the data's ball color (int) * @param {number} lifeMs the snare's remaining time (ms) * @returns {object} the item (killStunArms settles it early) */ stunArms(e, color, lifeMs) { const col = toColor(color, 0xb489ff); const gfx = this._world(this.scene.add.graphics()) .setBlendMode(Phaser.BlendModes.SCREEN) .setDepth(22); const it = { img: gfx, t0: 0, life: Math.max(60, lifeMs), kind: 'stunArms', ship: e, color: col, r: Math.max(10, Number(e?.hitRadius) || 18), bolts: [], strikeIn: 30, // the first crackle is near-instant sparkIn: 120, strikeCount: 0, }; // The expiry seam: the combat side keeps a pointer (e._stunArms) — // flag the item killed so a later re-snag makes a fresh discharge. it.restore = () => { it.killed = true; }; this.items.push(it); return it; } /** * Settle a craft's discharge NOW (the kill / revive / release seams). * The bolts are gone, the hull is free — idempotent, safe when the * discharge already expired. */ killStunArms(e) { for (let i = this.items.length - 1; i >= 0; i--) { const it = this.items[i]; if (it.kind !== 'stunArms' || it.ship !== e) continue; it.killed = true; if (it.img && !it.img.destroyed) it.img.destroy(); this.items.splice(i, 1); } } // ----------------------------------------------------------- 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 LINGERING SHOCK RING (the kill burst's second shockwave's cousin): * like ring() but with a `delay` before it starts. 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 DAMAGE-RADIUS RING (the ram's arrival signature): a bright rim * with a soft fill that expands fast and eases out to land EXACTLY on * `radius` (the blast's damage reach — the one honest circle in the * scene). Tinted `color`; eases with a decaying velocity (a shockwave * outrunning its own energy) over `life` ms. */ blastRing(x, y, color, radius, life = 480) { const img = this._world(this.scene .add.image(x, y, Fx.ensureBlastRing(this.scene)) .setTint(toColor(color, 0xffffff)) .setBlendMode(Phaser.BlendModes.SCREEN) // see _RAM_BLEND note .setDepth(19) .setScale(6 / 256) .setAlpha(0)); this.items.push({ img, t0: 0, life, kind: 'blastRing', from: 6 / 256, to: Math.max(6, Number(radius) || 6) / BLAST_RING_R, }); return img; } /** * THE RAM'S ARRIVAL (the impact the whole charge is building to): * 1. a white-hot core igniting at the stop point, * 2. the FIREBALL — colored fire (drawBlast) blooming out and cooling * over ~half a second, * 3. the DAMAGE-RADIUS ring settling exactly on the blast reach * (the visible damage area, tinted the data's ringColor), * 4. a wide ripple + a delayed ember-colored ring (the shock passing * beyond the kill zone), * 5. debris — fast sparks in the ring's color, a white-hot flash * burst, and slow embers that drift and cool for ~1 s. * Pure visuals (the voice is the caller's — skillBlast plays the data's * sfxEvent + the thunder boom). `fx` = the skill's fx block from * data/skills.json (every key has a fallback). */ ramBlast(x, y, radius, fx = {}) { const R = Math.max(24, Number(radius) || 0); const ringColor = toColor(fx.ringColor, 0xffd84d); const fireColor = toColor(fx.trailColor, 0xff3a4d); const ringLife = Math.max(300, Number(fx.ringLifeMs) || 520); // 1) The ignition — the white-hot stop point. this.flash(x, y, 0xffffff, R * 1.4, 170); // 2) The fireball (colored, additive — the impact's body). const fb = this._world(this.scene .add.image(x, y, Fx.ensureBlast(this.scene)) .setBlendMode(Phaser.BlendModes.SCREEN) // see _RAM_BLEND note .setDepth(20)); const s0 = R / 80; // the ~180 px fire wash covers ≈2× the blast radius fb.setScale(s0 * 0.55); this.items.push({ img: fb, t0: 0, life: 520, kind: 'fireball', s0 }); // 3) The honest circle — the damage radius itself. this.blastRing(x, y, ringColor, R, ringLife); // 4) The shock outrunning the kill zone. this.ring(x, y, ringColor, R * 2.1, Math.round(ringLife * 1.15)); this.delayedRing(x, y, fireColor, R * 2.8, 640, 130); // 5) Debris — fast + white-hot + slow-cooling. this.sparks(x, y, ringColor, 24, 430, 620); this.sparks(x, y, 0xffffff, 8, 300, 380); this.sparks(x, y, fireColor, 16, 150, 950); } /** * 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 === 'ember') { // The wake's grain — drift, grow as it cools, slow quadratic fade. it.img.x += it.vx * dt; it.img.y += it.vy * dt; const decay = Math.max(0, 1 - 1.6 * dt); it.vx *= decay; it.vy *= decay; it.img.setAlpha(Math.pow(1 - t, 1.7) * (it.alpha0 ?? 0.9)); it.img.setScale(it.s0 * (1 + 0.5 * t)); } else if (it.kind === 'comet') { // The fireball riding the hull — follow, hold the heading, flicker. const tg = it.target; if (tg && !tg.destroyed) { it.img.x = tg.x; it.img.y = tg.y; } const ts = eff / 1000; const flick = 1 + 0.1 * Math.sin(ts * 46) + 0.06 * Math.sin(ts * 83 + 1.7); const endFade = Math.max(0, Math.min(1, (it.life - eff) / 140)); it.img.setScale(it.s0 * flick); it.img.setAlpha(0.95 * endFade); } else if (it.kind === 'fireball') { // The arrival fire — bloom out fast, cool (shrink the glow), fade. it.img.setScale(it.s0 * (0.55 + 1.15 * Math.pow(t, 0.8))); it.img.setAlpha(Math.pow(1 - t, 1.25)); } else if (it.kind === 'blastRing') { // The damage-radius ring — fast expansion, decaying velocity. const e = 1 - Math.pow(1 - t, 2.2); it.img.setScale(it.from + (it.to - it.from) * e); it.img.setAlpha(0.95 * Math.pow(1 - t, 1.4)); } 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)); } else if (it.kind === 'stunArms') { // THE STRUNG CRAFT'S DISCHARGE: the bolts re-strike on their own // crackle clock, sparks tick off the hull, and every fewth strike // the hull stutters — until the item's life (the snare's clock) // dies with it. The glow follows the craft each frame (a // re-anchor never orphans it). const ship = it.ship; const sx = ship ? ship.x : 0; const sy = ship ? ship.y : 0; const g = it.img; g.x = sx; g.y = sy; // the bolts age and die on their own lives (the crackle's fade) for (let bi = it.bolts.length - 1; bi >= 0; bi--) { const b = it.bolts[bi]; b.age += deltaMs; if (b.age >= b.life) it.bolts.splice(bi, 1); } // the CRACKLE: one or two fresh bolts off the hull, staggered it.strikeIn -= deltaMs; if (it.strikeIn <= 0) { it.strikeIn = 45 + Math.random() * 90; const n = 2 + (Math.random() < 0.55 ? 1 : 0) + (Math.random() < 0.3 ? 1 : 0); for (let k = 0; k < n; k++) it.bolts.push(jagBolt(it.r)); it.strikeCount += 1; // every fewth strike the hull STUTTERS: a white bloom + a // spark tick at the newest bolt's tip if (it.strikeCount % 3 === 1 && ship && !ship.dead) { this.flash(sx, sy, 0xffffff, it.r * 3.0, 140); const tipPts = it.bolts[it.bolts.length - 1].pts; const tip = tipPts[tipPts.length - 1]; this.sparks(sx + tip[0], sy + tip[1], 0xffffff, 4, 160, 300); } } // the SPARK TICKS: small colored sparks off the hull, on their // own slower clock it.sparkIn -= deltaMs; if (it.sparkIn <= 0) { it.sparkIn = 130 + Math.random() * 210; const a = Math.random() * Math.PI * 2; const px = sx + Math.cos(a) * it.r * 0.7; const py = sy + Math.sin(a) * it.r * 0.7; this.sparks(px, py, it.color, 3 + (Math.random() < 0.4 ? 3 : 0), 140, 280); } // REDRAW: a faint flickering charge at the center, then the glow // pass (the data's color, wide and soft) under the white-hot // cores — each bolt fading on its own life. g.clear(); const flick = 0.16 + 0.14 * Math.sin(eff * 0.045); g.fillStyle(it.color, Math.max(0, flick)); g.fillCircle(0, 0, it.r * 1.5); const fade = (b) => 1 - b.age / b.life; for (const b of it.bolts) { const f = fade(b); if (f <= 0) continue; g.lineStyle(5.5, it.color, 0.65 * f); for (let i = 1; i < b.pts.length; i++) { g.lineBetween(b.pts[i - 1][0], b.pts[i - 1][1], b.pts[i][0], b.pts[i][1]); } } for (const b of it.bolts) { const f = fade(b); if (f <= 0) continue; g.lineStyle(2.4, 0xffffff, 0.95 * f); for (let i = 1; i < b.pts.length; i++) { g.lineBetween(b.pts[i - 1][0], b.pts[i - 1][1], b.pts[i][0], b.pts[i][1]); } } } // '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 */ } }