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'; 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, }; 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); } /** * Small hand-rolled FX for the combat range: muzzle flashes, hit glows, * spark streams, 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), depths 19–21 * (above ships (10), under the HUD (100)). */ export class Fx { /** @param {object} scene the combat scene */ constructor(scene) { this.scene = scene; this.items = []; // { img, t0, life, kind, ... } Fx.ensureDot(scene); } /** 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; } /** An additive glow: `size` px across at full strength, fading over `life` ms. */ flash(x, y, color, size = 40, life = 220) { const img = 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.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). */ sparks(x, y, color, count = 12, speed = 220, life = 500) { for (let i = 0; i < count; i++) { const a = Math.random() * Math.PI * 2; const sp = speed * (0.4 + Math.random() * 0.8); const s = 4 + Math.random() * 5; const img = 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). 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.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.95); 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); } /** 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.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); } /** 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) { 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 === 'text') { it.img.y -= (it.rise / it.life) * deltaMs; it.img.setAlpha(Math.min(0.95, 1.6 * (1 - t))); } 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); } } } /** Drop everything (a range reset). */ clear() { for (const it of this.items) it.img.destroy(); this.items.length = 0; } }