781 lines
29 KiB
JavaScript
781 lines
29 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;
|
||
|
||
/**
|
||
* The combat feedback palette (both contexts, one place):
|
||
* shield — a field absorbing (cyan)
|
||
* hull — armor taking the hit (orange)
|
||
* down — the player's death (hot red)
|
||
* respawn — the player reforming (cool cyan)
|
||
*/
|
||
export const FX_COLORS = {
|
||
shield: 0x7ce8ff,
|
||
hull: 0xffa04d,
|
||
kill: 0xff5d6f,
|
||
down: 0xff2d6f,
|
||
respawn: 0x7ce8ff,
|
||
xp: 0xffd84d, // the reward gold (the bar's energy color)
|
||
};
|
||
|
||
function drawSoftDot(ctx, size) {
|
||
const c = size / 2;
|
||
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
|
||
g.addColorStop(0.0, 'rgba(255,255,255,1.0)');
|
||
g.addColorStop(0.22, 'rgba(255,255,255,0.85)');
|
||
g.addColorStop(0.55, 'rgba(255,255,255,0.26)');
|
||
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, 0, size, size);
|
||
}
|
||
|
||
/**
|
||
* The muzzle flare — white alpha (tinted at use), laid with the firing
|
||
* direction along +x: a soft core bloom at the muzzle, a bright spike
|
||
* out the bore, and a small cross glint. Reads as a directional flash.
|
||
*/
|
||
function drawFlare(ctx, w, h) {
|
||
const c = h / 2;
|
||
// Core bloom at the muzzle.
|
||
let g = ctx.createRadialGradient(c, c, 0, c, c, 30);
|
||
g.addColorStop(0.0, 'rgba(255,255,255,0.95)');
|
||
g.addColorStop(0.45, 'rgba(255,255,255,0.4)');
|
||
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(c - 30, c - 30, 60, 60);
|
||
// The bore spike (the firing direction, +x).
|
||
g = ctx.createLinearGradient(c, 0, w, 0);
|
||
g.addColorStop(0.0, 'rgba(255,255,255,0.0)');
|
||
g.addColorStop(0.4, 'rgba(255,255,255,0.35)');
|
||
g.addColorStop(1.0, 'rgba(255,255,255,0.95)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(c, c - 4, w - c, 8);
|
||
g = ctx.createLinearGradient(c, 0, w, 0);
|
||
g.addColorStop(0.0, 'rgba(255,255,255,0.0)');
|
||
g.addColorStop(1.0, 'rgba(255,255,255,0.45)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(c, c - 10, w - c, 20);
|
||
// Cross glint.
|
||
g = ctx.createLinearGradient(0, c - 20, 0, c + 20);
|
||
g.addColorStop(0.0, 'rgba(255,255,255,0.0)');
|
||
g.addColorStop(0.5, 'rgba(255,255,255,0.5)');
|
||
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(c - 14, c - 20, 28, 40);
|
||
}
|
||
|
||
/**
|
||
* The shield-sphere impact (white alpha, tinted cyan at use): a faint
|
||
* FULL CIRCLE (the sphere) + a bright ARC centered on angle 0 (the
|
||
* highlighted hit region) + a glow at the arc's midpoint (the impact
|
||
* point). Rotating the texture by the laser's incoming bearing puts the
|
||
* bright arc exactly where the shield took it. `span` rad = the arc's
|
||
* width; baked into the texture key so a config change re-cuts it.
|
||
*/
|
||
function shieldTextureKey(span) {
|
||
return `__combat_shield_s${span.toFixed(2)}`;
|
||
}
|
||
|
||
function drawShield(ctx, w, h, span) {
|
||
const c = w / 2;
|
||
const R = 96; // the sphere's radius in texture space
|
||
// The faint full circle — "a sphere".
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.30)';
|
||
ctx.lineWidth = 5;
|
||
ctx.beginPath();
|
||
ctx.arc(c, c, R, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
// The bright arc (the hit region), centered on angle 0 (east).
|
||
const a0 = -span / 2;
|
||
const a1 = span / 2;
|
||
ctx.lineCap = 'round';
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.45)';
|
||
ctx.lineWidth = 20;
|
||
ctx.beginPath();
|
||
ctx.arc(c, c, R, a0, a1);
|
||
ctx.stroke();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
|
||
ctx.lineWidth = 7;
|
||
ctx.beginPath();
|
||
ctx.arc(c, c, R, a0, a1);
|
||
ctx.stroke();
|
||
// The impact-point glow, at the arc's midpoint (east).
|
||
const g = ctx.createRadialGradient(c + R, c, 0, c + R, c, 30);
|
||
g.addColorStop(0.0, 'rgba(255,255,255,0.95)');
|
||
g.addColorStop(0.5, 'rgba(255,255,255,0.4)');
|
||
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(c + R - 30, c - 30, 60, 60);
|
||
}
|
||
|
||
/**
|
||
* The craft-shield arc (enemy-side shield read — white alpha, tinted at
|
||
* use): a bright arc segment only, centered on angle 0. Rotated to the
|
||
* laser's incoming bearing it sits on the enemy's shield where it was hit.
|
||
*/
|
||
const ARC_KEY = '__combat_arc';
|
||
|
||
function drawArc(ctx, w, h) {
|
||
const c = w / 2;
|
||
const R = 84;
|
||
const a0 = -0.85;
|
||
const a1 = 0.85;
|
||
ctx.lineCap = 'round';
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
||
ctx.lineWidth = 22;
|
||
ctx.beginPath();
|
||
ctx.arc(c, c, R, a0, a1);
|
||
ctx.stroke();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
|
||
ctx.lineWidth = 8;
|
||
ctx.beginPath();
|
||
ctx.arc(c, c, R, a0, a1);
|
||
ctx.stroke();
|
||
}
|
||
|
||
/**
|
||
* Small hand-rolled FX for combat (both contexts): laser muzzles, impact
|
||
* events, spark streams, shield-sphere reads, and the ship-kill burst.
|
||
* Plain additive images stepped in update() — deliberately NOT the
|
||
* particles emitter (this build's emitter velocity semantics are fiddly,
|
||
* see the note in js/visuals/EngineFlare.js), so the look is
|
||
* deterministic, cheap, and needs no tween stepping.
|
||
*
|
||
* All items are world-space (the arena camera is fixed, the main game's
|
||
* world scrolls), depths 11–22 (above ships (10), under the HUD (≥50)).
|
||
* Every created object is assigned to the WORLD camera pass
|
||
* (js/visuals/UiCameras.js) so the UI pass (bar, feed) never double-draws
|
||
* or shakes with it — a no-op while the split doesn't exist.
|
||
*/
|
||
export class Fx {
|
||
/** @param {object} scene the combat scene */
|
||
constructor(scene) {
|
||
this.scene = scene;
|
||
this.items = []; // { img, t0, life, kind, ... }
|
||
// Every craft that ever took a white hit-flash (the periodic
|
||
// whiteness-purge's reach — js/combat/Fx.js → purgeCraftTints).
|
||
this.crafts = new Set();
|
||
// The context may hand over its live roster (player + pack) so the
|
||
// purge covers EVERY craft, not just ones we flashed — a stuck white
|
||
// state is a stuck white state, whatever caused it.
|
||
this.purgeRoster = null;
|
||
this._purgeClock = 0;
|
||
Fx.ensureDot(scene);
|
||
}
|
||
|
||
/** Assign a world object to the world pass (no-op without the split). */
|
||
_world(o) {
|
||
if (o) assignWorld(this.scene, o);
|
||
return o;
|
||
}
|
||
|
||
/** The shared white soft dot (tint reads as its color). @returns {string} the key */
|
||
static ensureDot(scene) {
|
||
if (scene.textures.exists(DOT_KEY)) return DOT_KEY;
|
||
canvasTexture(scene, DOT_KEY, DOT_SIZE, DOT_SIZE, (ctx, w, h) => drawSoftDot(ctx, Math.min(w, h)));
|
||
return DOT_KEY;
|
||
}
|
||
|
||
/** The muzzle flare texture (shared). @returns {string} the key */
|
||
static ensureFlare(scene) {
|
||
const key = '__combat_flare';
|
||
if (scene.textures.exists(key)) return key;
|
||
canvasTexture(scene, key, 128, 128, (ctx, w, h) => drawFlare(ctx, w, h));
|
||
return key;
|
||
}
|
||
|
||
/** The shield-sphere impact texture (span-keyed). @returns {string} the key */
|
||
static ensureShield(scene, span) {
|
||
const key = shieldTextureKey(span);
|
||
if (scene.textures.exists(key)) return key;
|
||
canvasTexture(scene, key, 256, 256, (ctx, w, h) => drawShield(ctx, w, h, span));
|
||
return key;
|
||
}
|
||
|
||
/** The shared laser-bolt texture (the shard debris reuses it). @returns {string} the key */
|
||
static ensureBolt(scene) {
|
||
return Bullet.ensureTexture(scene);
|
||
}
|
||
|
||
/** The craft-shield arc texture (shared). @returns {string} the key */
|
||
static ensureArc(scene) {
|
||
if (scene.textures.exists(ARC_KEY)) return ARC_KEY;
|
||
canvasTexture(scene, ARC_KEY, 256, 256, (ctx, w, h) => drawArc(ctx, w, h));
|
||
return ARC_KEY;
|
||
}
|
||
|
||
// ----------------------------------------------------------- the basics
|
||
|
||
/** An additive glow: `size` px across at full strength, fading over `life` ms. */
|
||
flash(x, y, color, size = 40, life = 220) {
|
||
const img = this._world(this.scene
|
||
.add.image(x, y, DOT_KEY)
|
||
.setTint(toColor(color, 0xffffff))
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(20));
|
||
this.items.push({ img, t0: 0, life, kind: 'flash', grow: size / DOT_SIZE });
|
||
return img;
|
||
}
|
||
|
||
/** A growing, fading disc — the shockwave read (a stand-in for a stroke ring). */
|
||
ring(x, y, color, size = 180, life = 450) {
|
||
const img = this._world(this.scene
|
||
.add.image(x, y, DOT_KEY)
|
||
.setTint(toColor(color, 0xffffff))
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(19)
|
||
.setScale(6 / DOT_SIZE)
|
||
.setAlpha(0.8));
|
||
this.items.push({ img, t0: 0, life, kind: 'ring', from: 6 / DOT_SIZE, to: size / DOT_SIZE });
|
||
return img;
|
||
}
|
||
|
||
/**
|
||
* A burst of sparks (small dots on random headings, decaying). `dirAngle`
|
||
* + `cone` focus the burst along a heading (impact reads); omitted = the
|
||
* classic all-round burst (the kill/respawn cues).
|
||
*/
|
||
sparks(x, y, color, count = 12, speed = 220, life = 500, dirAngle = null, cone = 0.9) {
|
||
for (let i = 0; i < count; i++) {
|
||
const a = dirAngle == null
|
||
? Math.random() * Math.PI * 2
|
||
: dirAngle + (Math.random() * 2 - 1) * cone;
|
||
const sp = speed * (0.4 + Math.random() * 0.8);
|
||
const s = 4 + Math.random() * 5;
|
||
const img = this._world(this.scene
|
||
.add.image(x, y, DOT_KEY)
|
||
.setTint(toColor(color, 0xffffff))
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(21)
|
||
.setScale(s / DOT_SIZE));
|
||
this.items.push({
|
||
img,
|
||
t0: 0,
|
||
life: life * (0.6 + Math.random() * 0.6),
|
||
kind: 'spark',
|
||
vx: Math.cos(a) * sp,
|
||
vy: Math.sin(a) * sp,
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A floating text cue (damage numbers, DOWN / REFORMED). Pops in a touch
|
||
* large, rises, and fades over `life` ms. Needs scene.add.text (real
|
||
* scenes have it; the test stubs don't — a missing text seam is a
|
||
* graceful no-op).
|
||
*/
|
||
text(x, y, str, color, size = 15, life = 500, rise = 26) {
|
||
if (typeof this.scene.add?.text !== 'function') return null;
|
||
const t = this._world(this.scene.add.text(x, y, str, {
|
||
fontFamily: fontStack('body'),
|
||
fontSize: `${size}px`,
|
||
fontStyle: 'bold',
|
||
color: toCss(color, '#ffffff'), // canvas text needs a CSS string
|
||
stroke: '#02131c',
|
||
strokeThickness: 4,
|
||
}).setOrigin(0.5).setDepth(22).setAlpha(0));
|
||
this.items.push({ img: t, t0: 0, life, kind: 'text', rise });
|
||
return t;
|
||
}
|
||
|
||
/**
|
||
* A damage number (both contexts): the points just dealt, floating up
|
||
* off the point of impact — cyan when the shields took it, orange
|
||
* when the armor took it.
|
||
*/
|
||
damage(x, y, amount, kind = 'hull') {
|
||
const n = Math.max(0, Math.round(Number(amount) || 0));
|
||
const color = kind === 'shield' ? FX_COLORS.shield : FX_COLORS.hull;
|
||
this.text(x, y - 10, String(n), color, 15, 520, 26);
|
||
}
|
||
|
||
/**
|
||
* An XP gain (the kill's reward — js/combat/CombatSystem.js banks it on
|
||
* the kill): the points earned, floating up off the wreckage in the
|
||
* reward gold (FX_COLORS.xp) — a touch smaller + slower than a damage
|
||
* number, so it reads as a REWARD landing, not another hit.
|
||
*/
|
||
xp(x, y, amount) {
|
||
const n = Math.max(0, Math.round(Number(amount) || 0));
|
||
this.text(x, y - 24, `+${n}`, FX_COLORS.xp, 14, 750, 32);
|
||
}
|
||
|
||
// ------------------------------------------------------------ the lasers
|
||
|
||
/**
|
||
* The muzzle flash — a directional burst out the bore: a bright spike
|
||
* along the firing axis + a white core. `angle` = the firing direction
|
||
* (rad); `size` px = the flare's full extent (weapon.muzzle).
|
||
*/
|
||
muzzle(x, y, angle, color, size = 46, life = 130) {
|
||
const flare = this._world(this.scene
|
||
.add.image(x, y, Fx.ensureFlare(this.scene))
|
||
.setTint(toColor(color, 0xffffff))
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(21)
|
||
.setAngle(angle * 180 / Math.PI));
|
||
const s0 = Math.max(0.2, size / 128);
|
||
flare.setScale(s0);
|
||
this.items.push({ img: flare, t0: 0, life, kind: 'muzzle', scale0: s0, alpha0: 0.95 });
|
||
// The hot-white core at the muzzle.
|
||
this.flash(x, y, 0xffffff, Math.max(14, size * 0.5), life * 0.8);
|
||
return flare;
|
||
}
|
||
|
||
// ----------------------------------------------------------- the impacts
|
||
|
||
/**
|
||
* A hit on an enemy craft — the full impact read at the point of
|
||
* contact:
|
||
* - a hot core flash + the layer's bloom,
|
||
* - directional sparks ALONG THE BULLET'S TRAVEL VECTOR,
|
||
* - a SHIELD hit: a cyan shockwave + a bright arc ripple on the
|
||
* craft's shield (the `__combat_arc` read, at the incoming bearing),
|
||
* - a HULL hit: hot shards flung along the travel vector + a small
|
||
* orange ring,
|
||
* - and a white hit-flash on the craft itself (the tintFill pulse,
|
||
* or a white bloom when the build can't fill-tint).
|
||
*
|
||
* @param {object} ship the enemy (needs .x/.y/.hitRadius; the flash
|
||
* wants .setTint/.setTintFill)
|
||
* @param {number} x impact point
|
||
* @param {number} y impact point
|
||
* @param {number} travelAngle the bullet's flight direction (rad)
|
||
* @param {string} kind 'shield' | 'hull'
|
||
* @param {number} damage the damage just dealt (scales the read)
|
||
*/
|
||
enemyHit(ship, x, y, travelAngle, kind, damage) {
|
||
const d = Math.max(1, Number(damage) || 8);
|
||
const k = Math.min(1.8, 0.8 + d / 30);
|
||
const color = kind === 'shield' ? FX_COLORS.shield : FX_COLORS.hull;
|
||
|
||
// The core + the layer's bloom (sized by the damage).
|
||
this.flash(x, y, 0xffffff, Math.max(26, 30 * k), 150);
|
||
this.flash(x, y, color, 46 * k, 240);
|
||
|
||
// Directional sparks along the bullet's travel vector.
|
||
const n = Math.round(7 + Math.min(9, d * 0.7));
|
||
this.sparks(x, y, color, n, kind === 'shield' ? 250 : 320, kind === 'shield' ? 460 : 560, travelAngle, kind === 'shield' ? 0.95 : 0.65);
|
||
this.sparks(x, y, 0xffffff, 3, 200, 320, travelAngle, 0.8);
|
||
|
||
if (kind === 'shield') {
|
||
// The cyan shockwave off the shield + the arc ripple on the craft.
|
||
this.ring(x, y, color, Math.max(48, 54 * k), 380);
|
||
if (ship) {
|
||
this.craftShieldArc(ship, travelAngle);
|
||
this.craftFlash(ship);
|
||
}
|
||
} else {
|
||
// The armor taking it: a tight ring + hot shards flung the way
|
||
// the bolt was going.
|
||
this.ring(x, y, color, Math.max(34, 40 * k), 300);
|
||
this.shards(x, y, travelAngle, color, 4, 340, 520);
|
||
if (ship) this.craftFlash(ship);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The craft's shield read: a bright arc on the enemy's shield, centered
|
||
* on the laser's INCOMING bearing (the side that faced the shooter),
|
||
* popping and fading fast.
|
||
*/
|
||
craftShieldArc(ship, travelAngle) {
|
||
const r = Math.max(8, Number(ship.hitRadius) || 20);
|
||
const img = this._world(this.scene
|
||
.add.image(ship.x, ship.y, Fx.ensureArc(this.scene))
|
||
.setTint(toColor(FX_COLORS.shield, 0xffffff))
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(12)
|
||
.setAngle((travelAngle + Math.PI) * 180 / Math.PI));
|
||
const s0 = (r + 10) / 84;
|
||
img.setScale(s0 * 0.8);
|
||
this.items.push({ img, t0: 0, life: 300, kind: 'arcFlash', scale0: s0, alpha0: 0.9 });
|
||
return img;
|
||
}
|
||
|
||
/**
|
||
* Hot, fast, elongated fragments flung along `angle` ± a cone — the
|
||
* hull-hit debris. Reuses the bolt texture at shard scale.
|
||
*/
|
||
shards(x, y, angle, color, count = 4, speed = 320, life = 500) {
|
||
const key = Fx.ensureBolt(this.scene);
|
||
for (let i = 0; i < count; i++) {
|
||
const a = angle + (Math.random() * 2 - 1) * 0.7;
|
||
const sp = speed * (0.5 + Math.random() * 0.9);
|
||
const img = this._world(this.scene
|
||
.add.image(x, y, key)
|
||
.setTint(toColor(color, 0xffffff))
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(21)
|
||
.setAngle(a * 180 / Math.PI));
|
||
const s = 0.16 + Math.random() * 0.14;
|
||
img.setScale(s, s * 0.5);
|
||
this.items.push({
|
||
img,
|
||
t0: 0,
|
||
life: life * (0.7 + Math.random() * 0.6),
|
||
kind: 'spark',
|
||
vx: Math.cos(a) * sp,
|
||
vy: Math.sin(a) * sp,
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The white hit-flash on a craft (the "it landed on the ship" read).
|
||
* THIS BUILD (Phaser 4.2.1 Giedi): `setTintFill` is a REMOVED no-op
|
||
* (it only console.errors), but the render pipeline still honors a
|
||
* solid fill via `tintMode` 1 (color = the tint color) — so the flash
|
||
* is: tint white + tintMode 1 for the beat, then restore both. (A
|
||
* sprite without those seams — the test stubs — gets the white bloom.)
|
||
* Note: the item's `img` is the SHIP — the expiry/clear paths must
|
||
* only `restore()` it, never `destroy()` it.
|
||
*/
|
||
craftFlash(ship, life = 150) {
|
||
if (!ship) return null;
|
||
if (typeof ship.setTint === 'function' && typeof ship.setTintMode === 'function') {
|
||
// OVERLAPPING HITS: a second hit inside the flash window must
|
||
// EXTEND the pending flash, not stack a second item — a stacked
|
||
// item would capture the in-flash state (tintMode 1) as its
|
||
// "restore" value and re-apply the white AFTER expiry, leaving the
|
||
// living craft hot-white forever (the "turns white and stays
|
||
// white" bug). The first item's restore holds the true pre-flash
|
||
// state, so extending it is always correct.
|
||
for (let i = this.items.length - 1; i >= 0; i--) {
|
||
const it = this.items[i];
|
||
if (it.kind === 'tintFlash' && it.img === ship) {
|
||
it.life = Math.max(it.life, it.t0 + life);
|
||
return ship;
|
||
}
|
||
}
|
||
try {
|
||
const tint = ship.tint;
|
||
const mode = Number.isFinite(ship.tintMode) ? ship.tintMode : 0;
|
||
ship.setTint(0xffffff);
|
||
ship.setTintMode(1); // solid fill with the tint color (white)
|
||
this.crafts.add(ship); // the purge keeps this one honest
|
||
this.items.push({
|
||
img: ship,
|
||
t0: 0,
|
||
life,
|
||
kind: 'tintFlash',
|
||
// Each write is guarded on its own — a throw in one must not
|
||
// skip the other (a half-restored craft is a white craft).
|
||
restore: () => {
|
||
try { ship.setTintMode(mode); } catch { /* gone */ }
|
||
try { ship.setTint(tint); } catch { /* gone */ }
|
||
},
|
||
});
|
||
return ship;
|
||
} catch {
|
||
/* fall through to the bloom */
|
||
}
|
||
}
|
||
this.crafts.add(ship);
|
||
const r = Math.max(10, Number(ship.hitRadius) || 18);
|
||
return this.flash(ship.x, ship.y, 0xffffff, r * 2.6, life);
|
||
}
|
||
|
||
/**
|
||
* Settle a craft's pending white flash RIGHT NOW — the kill/destroy
|
||
* seam. A craft must never die, or be hidden and reused, while still
|
||
* holding the white-fill state: the hit is over (the kill burst or the
|
||
* reformed craft supersedes it), and the craft goes out in its normal
|
||
* colors (a sprite that dies mid-tint can leak the white state into
|
||
* its reuse — the "revived enemies are hot white and stay that way"
|
||
* edge case). Idempotent; safe on a destroyed craft.
|
||
*/
|
||
releaseCraft(ship) {
|
||
if (!ship) return;
|
||
for (let i = this.items.length - 1; i >= 0; i--) {
|
||
const it = this.items[i];
|
||
if (it.kind !== 'tintFlash' || it.img !== ship) continue;
|
||
if (it.restore) {
|
||
try { it.restore(); } catch { /* the craft may already be gone */ }
|
||
}
|
||
this.items.splice(i, 1);
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------ the player's shield
|
||
|
||
/** The shield-sphere tuning (data/combat.json → combat.fx.shield). */
|
||
shieldCfg() {
|
||
const s = config.section('combat.fx.shield', {});
|
||
const shipRadius = Number(s.shipRadius) || 0;
|
||
return {
|
||
radius: Math.max(16, Number(s.radius) || (shipRadius ? shipRadius + 17 : 40)),
|
||
arcSpan: Math.max(0.6, Number(s.arcSpan) || 1.9),
|
||
ripple: Math.max(30, Number(s.ripple) || 90),
|
||
decayMs: Math.max(180, Number(s.decayMs) || 520),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* THE SHIELD read — the player's sphere struck from the laser's
|
||
* incoming bearing, with the hit region highlighted:
|
||
* - a brief full-sphere outline flash (a sphere being hit),
|
||
* - a bright ARC SEGMENT centered on the impact bearing (the
|
||
* highlighted region),
|
||
* - a radial ripple from the impact point,
|
||
* - a spark burst off the surface,
|
||
* - and a white hit-flash on the ship.
|
||
* Intensity scales with the damage; everything decays over ~0.5 s.
|
||
*
|
||
* @param {object} ship the player's ship (needs .x/.y/.radius)
|
||
* @param {number} bearing angle from the ship's center toward the
|
||
* laser's source (rad) — where the shield was struck
|
||
* @param {number} damage the damage just dealt (scales the read)
|
||
* @returns {{ix: number, iy: number, k: number}} the impact point + intensity
|
||
*/
|
||
shieldImpact(ship, bearing, damage) {
|
||
const cfg = this.shieldCfg();
|
||
const r = cfg.radius;
|
||
const d = Math.max(1, Number(damage) || 8);
|
||
const k = Math.min(1.9, 0.75 + d / 28);
|
||
const ix = ship.x + Math.cos(bearing) * r;
|
||
const iy = ship.y + Math.sin(bearing) * r;
|
||
const shield = FX_COLORS.shield;
|
||
|
||
// 1) The sphere + the highlighted arc, one texture (the arc is baked
|
||
// at angle 0; rotating by the bearing centers it on the impact).
|
||
const span = cfg.arcSpan;
|
||
const sph = this._world(this.scene
|
||
.add.image(ship.x, ship.y, Fx.ensureShield(this.scene, span))
|
||
.setTint(toColor(shield, 0xffffff))
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(19)
|
||
.setAngle(bearing * 180 / Math.PI));
|
||
const s0 = (r + 8) / 96;
|
||
sph.setScale(s0 * 0.94);
|
||
this.items.push({ img: sph, t0: 0, life: cfg.decayMs, kind: 'shieldSphere', scale0: s0, alpha0: Math.min(1, 0.55 + 0.4 * k) });
|
||
|
||
// 2) The impact read at the surface point.
|
||
this.flash(ix, iy, 0xffffff, 26 * k, 150);
|
||
this.flash(ix, iy, shield, 40 * k, 260);
|
||
this.ring(ix, iy, shield, cfg.ripple * k, 420);
|
||
|
||
// 3) Sparks off the surface, biased outward along the bearing.
|
||
this.sparks(ix, iy, shield, Math.round(6 + Math.min(8, d * 0.6)), 240, 480, bearing, 0.8);
|
||
this.sparks(ix, iy, 0xffffff, 3, 190, 340, bearing, 0.7);
|
||
|
||
// 4) The ship flinches (a white hit-flash).
|
||
this.craftFlash(ship, 160);
|
||
|
||
return { ix, iy, k };
|
||
}
|
||
|
||
/**
|
||
* The shield-COLLAPSE cue (shields gone → the hull takes it): the sphere
|
||
* implodes, a shockwave rolls out, and the surface sheds sparks all
|
||
* the way around.
|
||
*/
|
||
shieldBreak(ship) {
|
||
if (!ship) return;
|
||
const r = this.shieldCfg().radius;
|
||
const shield = FX_COLORS.shield;
|
||
|
||
// The implosion — a ring that grows IN and fades.
|
||
const img = this._world(this.scene
|
||
.add.image(ship.x, ship.y, DOT_KEY)
|
||
.setTint(toColor(shield, 0xffffff))
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(19)
|
||
.setScale((r * 1.25) / DOT_SIZE)
|
||
.setAlpha(0.9));
|
||
this.items.push({ img, t0: 0, life: 400, kind: 'ringIn', from: (r * 1.25) / DOT_SIZE, to: 4 / DOT_SIZE });
|
||
|
||
// The shockwave + the surface shedding.
|
||
this.ring(ship.x, ship.y, shield, r * 2.4, 460);
|
||
this.sparks(ship.x, ship.y, shield, 14, 230, 520);
|
||
this.sparks(ship.x, ship.y, 0xffffff, 6, 180, 380);
|
||
this.flash(ship.x, ship.y, 0xffffff, 56, 180);
|
||
}
|
||
|
||
// -------------------------------------------------------------- the cues
|
||
|
||
/** The big one — a ship's death: core flash + colored bloom + wave + sparks. */
|
||
explode(x, y, color) {
|
||
this.flash(x, y, 0xffffff, 90, 160);
|
||
this.flash(x, y, color, 150, 420);
|
||
this.ring(x, y, color, 200, 480);
|
||
this.delayedRing(x, y, color, 300, 620, 140);
|
||
this.sparks(x, y, color, 26, 320, 750);
|
||
}
|
||
|
||
/**
|
||
* A ring that waits `delay` ms before it starts (the kill burst's
|
||
* second shockwave). Spawned invisible until its delay runs out.
|
||
*/
|
||
delayedRing(x, y, color, size, life, delay) {
|
||
const img = this._world(this.scene
|
||
.add.image(x, y, DOT_KEY)
|
||
.setTint(toColor(color, 0xffffff))
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(19)
|
||
.setScale(6 / DOT_SIZE)
|
||
.setAlpha(0));
|
||
this.items.push({ img, t0: 0, life, kind: 'ring', from: 6 / DOT_SIZE, to: size / DOT_SIZE, delay });
|
||
return img;
|
||
}
|
||
|
||
/**
|
||
* The player-DOWN cue: the death burst in hot red + a big DOWN hold
|
||
* (the "the hull broke" beat before the reform clock).
|
||
*/
|
||
down(x, y) {
|
||
this.flash(x, y, 0xffffff, 120, 180);
|
||
this.flash(x, y, FX_COLORS.down, 200, 520);
|
||
this.ring(x, y, FX_COLORS.down, 260, 600);
|
||
this.sparks(x, y, FX_COLORS.down, 24, 300, 800);
|
||
this.text(x, y - 34, 'DOWN', FX_COLORS.down, 26, 950, 10);
|
||
}
|
||
|
||
/** The player-RESPAWN cue: a clean cyan bloom + the REFORMED pop. */
|
||
respawn(x, y) {
|
||
this.flash(x, y, 0xffffff, 90, 140);
|
||
this.flash(x, y, FX_COLORS.respawn, 160, 420);
|
||
this.ring(x, y, FX_COLORS.respawn, 220, 500);
|
||
this.text(x, y - 30, 'REFORMED', FX_COLORS.respawn, 16, 850, 12);
|
||
}
|
||
|
||
// ----------------------------------------------------------------- stepping
|
||
|
||
/** Step every live item (ms). Dead ones are destroyed. */
|
||
update(deltaMs) {
|
||
const dt = deltaMs / 1000;
|
||
for (let i = this.items.length - 1; i >= 0; i--) {
|
||
const it = this.items[i];
|
||
it.t0 += deltaMs;
|
||
const eff = it.t0 - (it.delay || 0);
|
||
if (eff <= 0) continue; // still in its delay — stays hidden
|
||
const t = Math.min(1, eff / it.life);
|
||
if (t >= 1) {
|
||
if (it.restore) {
|
||
try { it.restore(); } catch { /* the object may already be gone */ }
|
||
}
|
||
// 'tintFlash' items OWN the craft (img = the ship) — restore it,
|
||
// NEVER destroy it (every other kind owns its image).
|
||
if (it.kind !== 'tintFlash') it.img.destroy();
|
||
this.items.splice(i, 1);
|
||
continue;
|
||
}
|
||
if (it.kind === 'flash') {
|
||
it.img.setScale(it.grow * (0.6 + 0.4 * t));
|
||
it.img.setAlpha(1 - t);
|
||
} else if (it.kind === 'ring') {
|
||
it.img.setScale(it.from + (it.to - it.from) * t);
|
||
it.img.setAlpha(0.8 * (1 - t));
|
||
} else if (it.kind === 'ringIn') {
|
||
it.img.setScale(it.from + (it.to - it.from) * (1 - Math.pow(1 - t, 2)));
|
||
it.img.setAlpha(0.9 * (1 - t));
|
||
} else if (it.kind === 'text') {
|
||
// Pop in a touch large, rise, and fade.
|
||
const fadeIn = Math.min(1, eff / Math.max(1, it.life * 0.15));
|
||
const fadeOut = t > 0.6 ? 1 - (t - 0.6) / 0.4 : 1;
|
||
it.img.setAlpha(0.95 * fadeIn * fadeOut);
|
||
if (typeof it.img.setScale === 'function') {
|
||
const settle = Math.max(0, 1 - eff / Math.max(1, it.life * 0.3));
|
||
it.img.setScale(1 + 0.5 * settle * settle);
|
||
}
|
||
it.img.y -= (it.rise / it.life) * deltaMs;
|
||
} else if (it.kind === 'spark') {
|
||
it.img.x += it.vx * dt;
|
||
it.img.y += it.vy * dt;
|
||
const decay = Math.max(0, 1 - 2.2 * dt);
|
||
it.vx *= decay;
|
||
it.vy *= decay;
|
||
it.img.setAlpha(1 - t);
|
||
} else if (it.kind === 'muzzle') {
|
||
it.img.setAlpha(it.alpha0 * Math.pow(1 - t, 1.4));
|
||
it.img.setScale(it.scale0 * (1 + 1.1 * t));
|
||
} else if (it.kind === 'shieldSphere') {
|
||
it.img.setAlpha(it.alpha0 * Math.pow(1 - t, 1.3));
|
||
it.img.setScale(it.scale0 * (1 + 0.08 * t));
|
||
} else if (it.kind === 'arcFlash') {
|
||
const a = t < 0.3 ? t / 0.3 : 1 - (t - 0.3) / 0.7;
|
||
it.img.setAlpha(it.alpha0 * Math.max(0, a));
|
||
it.img.setScale(it.scale0 * (0.85 + 0.4 * t));
|
||
}
|
||
// 'tintFlash' needs no per-frame work (the pulse is set at spawn,
|
||
// restored on expiry above).
|
||
}
|
||
// THE WHITENESS PURGE (the user-requested last line of defense):
|
||
// a flash is a beat, never a state — a few times a second, re-assert
|
||
// the identity tint on every tracked craft that has no live flash.
|
||
// This re-syncs the craft (and anything that latched its white state)
|
||
// no matter how the stick got there, in BOTH contexts (they share
|
||
// this update path).
|
||
this._purgeClock += deltaMs;
|
||
if (this._purgeClock >= 250) {
|
||
this._purgeClock = 0;
|
||
this.purgeCraftTints();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* THE PERIODIC WHITENESS PURGE (the user-requested last line of
|
||
* defense): a few times a second, from update(), every combat craft —
|
||
* the tracked flashes we know about AND the context's live roster —
|
||
* must be in normal colors. A craft with a live flash is the expiry
|
||
* path's to restore, so it's left alone; everything else is forced
|
||
* back to identity (settleCraftTint). Destroyed crafts are pruned so
|
||
* the tracking set can't leak. Cheap: a handful of setter calls.
|
||
*/
|
||
purgeCraftTints() {
|
||
const targets = new Set(this.crafts);
|
||
if (typeof this.purgeRoster === 'function') {
|
||
try { for (const c of this.purgeRoster()) if (c) targets.add(c); } catch { /* roster seam */ }
|
||
}
|
||
if (targets.size === 0) return;
|
||
const live = new Set();
|
||
for (const it of this.items) {
|
||
if (it.kind === 'tintFlash') live.add(it.img);
|
||
}
|
||
for (const c of targets) {
|
||
if (!c) continue;
|
||
if (c.destroyed) {
|
||
this.crafts.delete(c); // dead — drop the reference (GC)
|
||
continue;
|
||
}
|
||
if (live.has(c)) continue; // live flash: legitimate, expiry restores
|
||
settleCraftTint(c);
|
||
}
|
||
}
|
||
|
||
/** Drop everything (a range reset) — restoring any craft flash. */
|
||
clear() {
|
||
for (const it of this.items) {
|
||
if (it.restore) {
|
||
try { it.restore(); } catch { /* gone */ }
|
||
} else {
|
||
it.img.destroy();
|
||
}
|
||
}
|
||
this.items.length = 0;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Return a craft's tint to identity (white corners + multiply mode).
|
||
* The reuse seams (revive/respawn) call this so a craft can NEVER
|
||
* reappear while still holding the white hit-flash state — the last
|
||
* line of defense for the "revived enemies are hot white" edge case.
|
||
* Safe on stubs and destroyed sprites. (Neither craft class tints for
|
||
* coloring — their colors are baked into the art — so identity is the
|
||
* always-correct default.)
|
||
*/
|
||
export function settleCraftTint(ship) {
|
||
if (!ship) return;
|
||
try { if (typeof ship.setTintMode === 'function') ship.setTintMode(0); } catch { /* stub */ }
|
||
try { if (typeof ship.setTint === 'function') ship.setTint(0xffffff); } catch { /* stub */ }
|
||
}
|