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