223 lines
7.4 KiB
JavaScript
223 lines
7.4 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';
|
||
|
||
const DOT_KEY = '__combat_dot';
|
||
const DOT_SIZE = 64;
|
||
|
||
/**
|
||
* The combat feedback palette (both contexts, one place):
|
||
* shield — a field absorbing (cyan)
|
||
* hull — armor taking the hit (orange)
|
||
* down — the player's death (hot red)
|
||
* respawn — the player reforming (cool cyan)
|
||
*/
|
||
export const FX_COLORS = {
|
||
shield: 0x7ce8ff,
|
||
hull: 0xffa04d,
|
||
kill: 0xff5d6f,
|
||
down: 0xff2d6f,
|
||
respawn: 0x7ce8ff,
|
||
};
|
||
|
||
function drawSoftDot(ctx, size) {
|
||
const c = size / 2;
|
||
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
|
||
g.addColorStop(0.0, 'rgba(255,255,255,1.0)');
|
||
g.addColorStop(0.22, 'rgba(255,255,255,0.85)');
|
||
g.addColorStop(0.55, 'rgba(255,255,255,0.26)');
|
||
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, 0, size, size);
|
||
}
|
||
|
||
/**
|
||
* Small hand-rolled FX for the combat range: muzzle flashes, hit glows,
|
||
* spark streams, and the ship-kill burst. Plain additive images stepped
|
||
* in update() — deliberately NOT the particles emitter (this build's
|
||
* emitter velocity semantics are fiddly, see the note in
|
||
* js/visuals/EngineFlare.js), so the look is deterministic, cheap, and
|
||
* needs no tween stepping.
|
||
*
|
||
* All items are world-space (the arena camera is fixed), depths 19–21
|
||
* (above ships (10), under the HUD (100)).
|
||
*/
|
||
export class Fx {
|
||
/** @param {object} scene the combat scene */
|
||
constructor(scene) {
|
||
this.scene = scene;
|
||
this.items = []; // { img, t0, life, kind, ... }
|
||
Fx.ensureDot(scene);
|
||
}
|
||
|
||
/** The shared white soft dot (tint reads as its color). @returns {string} the key */
|
||
static ensureDot(scene) {
|
||
if (scene.textures.exists(DOT_KEY)) return DOT_KEY;
|
||
canvasTexture(scene, DOT_KEY, DOT_SIZE, DOT_SIZE, (ctx, w, h) => drawSoftDot(ctx, Math.min(w, h)));
|
||
return DOT_KEY;
|
||
}
|
||
|
||
/** An additive glow: `size` px across at full strength, fading over `life` ms. */
|
||
flash(x, y, color, size = 40, life = 220) {
|
||
const img = this.scene
|
||
.add.image(x, y, DOT_KEY)
|
||
.setTint(toColor(color, 0xffffff))
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(20);
|
||
this.items.push({ img, t0: 0, life, kind: 'flash', grow: size / DOT_SIZE });
|
||
return img;
|
||
}
|
||
|
||
/** A growing, fading disc — the shockwave read (a stand-in for a stroke ring). */
|
||
ring(x, y, color, size = 180, life = 450) {
|
||
const img = this.scene
|
||
.add.image(x, y, DOT_KEY)
|
||
.setTint(toColor(color, 0xffffff))
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(19)
|
||
.setScale(6 / DOT_SIZE)
|
||
.setAlpha(0.8);
|
||
this.items.push({ img, t0: 0, life, kind: 'ring', from: 6 / DOT_SIZE, to: size / DOT_SIZE });
|
||
return img;
|
||
}
|
||
|
||
/** A burst of sparks (small dots on random headings, decaying). */
|
||
sparks(x, y, color, count = 12, speed = 220, life = 500) {
|
||
for (let i = 0; i < count; i++) {
|
||
const a = Math.random() * Math.PI * 2;
|
||
const sp = speed * (0.4 + Math.random() * 0.8);
|
||
const s = 4 + Math.random() * 5;
|
||
const img = this.scene
|
||
.add.image(x, y, DOT_KEY)
|
||
.setTint(toColor(color, 0xffffff))
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(21)
|
||
.setScale(s / DOT_SIZE);
|
||
this.items.push({
|
||
img,
|
||
t0: 0,
|
||
life: life * (0.6 + Math.random() * 0.6),
|
||
kind: 'spark',
|
||
vx: Math.cos(a) * sp,
|
||
vy: Math.sin(a) * sp,
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A floating text cue (damage numbers, DOWN / REFORMED). Rises and
|
||
* fades over `life` ms. Needs scene.add.text (real scenes have it;
|
||
* the test stubs don't — a missing text seam is a graceful no-op).
|
||
*/
|
||
text(x, y, str, color, size = 15, life = 500, rise = 26) {
|
||
if (typeof this.scene.add?.text !== 'function') return null;
|
||
const t = this.scene.add.text(x, y, str, {
|
||
fontFamily: fontStack('body'),
|
||
fontSize: `${size}px`,
|
||
fontStyle: 'bold',
|
||
color: toCss(color, '#ffffff'), // canvas text needs a CSS string
|
||
stroke: '#02131c',
|
||
strokeThickness: 4,
|
||
}).setOrigin(0.5).setDepth(22).setAlpha(0.95);
|
||
this.items.push({ img: t, t0: 0, life, kind: 'text', rise });
|
||
return t;
|
||
}
|
||
|
||
/**
|
||
* A damage number (both contexts): the points just dealt, floating up
|
||
* off the point of impact — cyan when the shields took it, orange
|
||
* when the armor took it.
|
||
*/
|
||
damage(x, y, amount, kind = 'hull') {
|
||
const n = Math.max(0, Math.round(Number(amount) || 0));
|
||
const color = kind === 'shield' ? FX_COLORS.shield : FX_COLORS.hull;
|
||
this.text(x, y - 10, String(n), color, 15, 520, 26);
|
||
}
|
||
|
||
/** The big one — a ship's death: core flash + colored bloom + wave + sparks. */
|
||
explode(x, y, color) {
|
||
this.flash(x, y, 0xffffff, 90, 160);
|
||
this.flash(x, y, color, 150, 420);
|
||
this.ring(x, y, color, 200, 480);
|
||
this.delayedRing(x, y, color, 300, 620, 140);
|
||
this.sparks(x, y, color, 26, 320, 750);
|
||
}
|
||
|
||
/**
|
||
* A ring that waits `delay` ms before it starts (the kill burst's
|
||
* second shockwave). Spawned invisible until its delay runs out.
|
||
*/
|
||
delayedRing(x, y, color, size, life, delay) {
|
||
const img = this.scene
|
||
.add.image(x, y, DOT_KEY)
|
||
.setTint(toColor(color, 0xffffff))
|
||
.setBlendMode(Phaser.BlendModes.ADD)
|
||
.setDepth(19)
|
||
.setScale(6 / DOT_SIZE)
|
||
.setAlpha(0);
|
||
this.items.push({ img, t0: 0, life, kind: 'ring', from: 6 / DOT_SIZE, to: size / DOT_SIZE, delay });
|
||
return img;
|
||
}
|
||
|
||
/**
|
||
* The player-DOWN cue: the death burst in hot red + a big DOWN hold
|
||
* (the "the hull broke" beat before the reform clock).
|
||
*/
|
||
down(x, y) {
|
||
this.flash(x, y, 0xffffff, 120, 180);
|
||
this.flash(x, y, FX_COLORS.down, 200, 520);
|
||
this.ring(x, y, FX_COLORS.down, 260, 600);
|
||
this.sparks(x, y, FX_COLORS.down, 24, 300, 800);
|
||
this.text(x, y - 34, 'DOWN', FX_COLORS.down, 26, 950, 10);
|
||
}
|
||
|
||
/** The player-RESPAWN cue: a clean cyan bloom + the REFORMED pop. */
|
||
respawn(x, y) {
|
||
this.flash(x, y, 0xffffff, 90, 140);
|
||
this.flash(x, y, FX_COLORS.respawn, 160, 420);
|
||
this.ring(x, y, FX_COLORS.respawn, 220, 500);
|
||
this.text(x, y - 30, 'REFORMED', FX_COLORS.respawn, 16, 850, 12);
|
||
}
|
||
|
||
/** Step every live item (ms). Dead ones are destroyed. */
|
||
update(deltaMs) {
|
||
const dt = deltaMs / 1000;
|
||
for (let i = this.items.length - 1; i >= 0; i--) {
|
||
const it = this.items[i];
|
||
it.t0 += deltaMs;
|
||
const eff = it.t0 - (it.delay || 0);
|
||
if (eff <= 0) continue; // still in its delay — stays hidden
|
||
const t = Math.min(1, eff / it.life);
|
||
if (t >= 1) {
|
||
it.img.destroy();
|
||
this.items.splice(i, 1);
|
||
continue;
|
||
}
|
||
if (it.kind === 'flash') {
|
||
it.img.setScale(it.grow * (0.6 + 0.4 * t));
|
||
it.img.setAlpha(1 - t);
|
||
} else if (it.kind === 'ring') {
|
||
it.img.setScale(it.from + (it.to - it.from) * t);
|
||
it.img.setAlpha(0.8 * (1 - t));
|
||
} else if (it.kind === 'text') {
|
||
it.img.y -= (it.rise / it.life) * deltaMs;
|
||
it.img.setAlpha(Math.min(0.95, 1.6 * (1 - t)));
|
||
} else if (it.kind === 'spark') {
|
||
it.img.x += it.vx * dt;
|
||
it.img.y += it.vy * dt;
|
||
const decay = Math.max(0, 1 - 2.2 * dt);
|
||
it.vx *= decay;
|
||
it.vy *= decay;
|
||
it.img.setAlpha(1 - t);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Drop everything (a range reset). */
|
||
clear() {
|
||
for (const it of this.items) it.img.destroy();
|
||
this.items.length = 0;
|
||
}
|
||
}
|