orbit/js/combat/Fx.js

1445 lines
57 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;
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 */ }
}