orbit/js/combat/Fx.js

223 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Phaser from '../vendor/phaser.js';
import { toColor, toCss } from '../utils/Color.js';
import { canvasTexture } from '../utils/Textures.js';
import { fontStack } from '../utils/Theme.js';
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;
}
}