Feedback VFX pass: damage numbers, color-coded hit flashes, kill burst, down/respawn cues

All from the one shared Fx path (js/combat/Fx.js) — both contexts call
the same cues, so the read never drifts:

- Fx.damage(): floating damage numbers on every hit, cyan when the
  shields took it / orange when the armor did (both enemy + player).
- Hit flashes are now color-coded by the layer that took the hit
  (FX_COLORS.shield / .hull) instead of a flat white.
- Fx.explode() (the kill burst) gains a second delayed shockwave ring
  + a denser spark burst.
- Fx.down(): the player-DOWN cue — hot-red burst + big DOWN hold.
- Fx.respawn(): the REFORMED cue — clean cyan bloom + pop.
- Fx.text() is a graceful no-op without scene.add.text (the Node test
  stubs), and canvas text gets a CSS string (toCss) per the v4 Giedi
  fillStyle quirk.

CombatScene + CombatSystem wire every hit/kill/down/respawn site to the
shared cues. dev/combat.test.mjs stays 0 failures; CDP screenshots
(/tmp/vfx-cues-*.png) show the damage numbers, kill burst, DOWN and
REFORMED cues live in both /combat and index.html?combat.
This commit is contained in:
Brian Fertig 2026-09-16 01:06:02 -06:00
parent 8fd94ce48c
commit ecff203159
3 changed files with 113 additions and 13 deletions

View File

@ -9,7 +9,7 @@ const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
import { Ship } from '../entities/Ship.js'; import { Ship } from '../entities/Ship.js';
import { EnemyShip } from './EnemyShip.js'; import { EnemyShip } from './EnemyShip.js';
import { Bullet } from './Bullet.js'; import { Bullet } from './Bullet.js';
import { Fx } from './Fx.js'; import { Fx, FX_COLORS } from './Fx.js';
import { StarDrift } from './StarDrift.js'; import { StarDrift } from './StarDrift.js';
import { applyDamage, handleFeel } from './Combat.js'; import { applyDamage, handleFeel } from './Combat.js';
import { combatSfx, combatSfxEnemyHit, loadCombatSfx } from './CombatSfx.js'; import { combatSfx, combatSfxEnemyHit, loadCombatSfx } from './CombatSfx.js';
@ -483,7 +483,9 @@ export class CombatScene extends Phaser.Scene {
if (this.hitsSwept(b, e, e.hitRadius, delta)) { if (this.hitsSwept(b, e, e.hitRadius, delta)) {
const preShields = e.shields; const preShields = e.shields;
const killed = e.damage(b.damage); const killed = e.damage(b.damage);
this.fx.flash(b.x, b.y, 0xff9db4, 34, 130); const kind = preShields > 0 ? 'shield' : 'hull';
this.fx.flash(b.x, b.y, FX_COLORS[kind], 34, 130);
this.fx.damage(b.x, b.y, b.damage, kind);
combatSfx(this, 'impact'); combatSfx(this, 'impact');
combatSfxEnemyHit(this, preShields, b.damage); combatSfxEnemyHit(this, preShields, b.damage);
if (killed) this.killEnemy(e); if (killed) this.killEnemy(e);
@ -555,6 +557,7 @@ export class CombatScene extends Phaser.Scene {
const preShields = b.shields; const preShields = b.shields;
const ramSelf = Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0; const ramSelf = Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0;
this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0); // impact + shield/hull this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0); // impact + shield/hull
this.fx.damage(b.x, b.y, ramSelf, preShields > 0 ? 'shield' : 'hull');
combatSfxEnemyHit(this, preShields, ramSelf); combatSfxEnemyHit(this, preShields, ramSelf);
const killed = b.damage(ramSelf); const killed = b.damage(ramSelf);
if (killed) this.killEnemy(b); if (killed) this.killEnemy(b);
@ -593,7 +596,10 @@ export class CombatScene extends Phaser.Scene {
const { dead, shieldHit, hullHit } = applyDamage(armor, n); const { dead, shieldHit, hullHit } = applyDamage(armor, n);
this.hull = armor.hull; this.hull = armor.hull;
this.shields = armor.shields; this.shields = armor.shields;
this.fx.flash(this.ship.x, this.ship.y, 0xffffff, 60, 140); // The hit-flash reads the layer that took it (cyan = shields, orange = hull).
const kind = shieldHit > 0 ? 'shield' : 'hull';
this.fx.flash(this.ship.x, this.ship.y, FX_COLORS[kind], 60, 140);
this.fx.damage(this.ship.x, this.ship.y, n, kind); // the damage number (both contexts)
// The player-hit voices (one place: lances AND rams funnel here). // The player-hit voices (one place: lances AND rams funnel here).
combatSfx(this, 'impact'); combatSfx(this, 'impact');
if (shieldHit > 0) combatSfx(this, 'shield'); if (shieldHit > 0) combatSfx(this, 'shield');
@ -604,7 +610,7 @@ export class CombatScene extends Phaser.Scene {
killPlayer() { killPlayer() {
this.playerDead = true; this.playerDead = true;
this.hull = 0; this.hull = 0;
this.fx.explode(this.ship.x, this.ship.y, themeColor('neon', 0x00e5ff)); this.fx.down(this.ship.x, this.ship.y); // the DOWN cue (burst + hold)
combatSfx(this, 'down'); combatSfx(this, 'down');
this.ship.stop(); this.ship.stop();
this.ship.setVisible(false); this.ship.setVisible(false);
@ -624,7 +630,7 @@ export class CombatScene extends Phaser.Scene {
this.ship.setVisible(true); this.ship.setVisible(true);
this.playerDead = false; this.playerDead = false;
this.clearBullets(); this.clearBullets();
this.fx.flash(this.ship.x, this.ship.y, themeColor('neon', 0x00e5ff), 70, 260); this.fx.respawn(this.ship.x, this.ship.y); // the REFORMED cue
combatSfx(this, 'respawn'); combatSfx(this, 'respawn');
} }

View File

@ -1,7 +1,7 @@
import { config } from '../config/Config.js'; import { config } from '../config/Config.js';
import { EnemyShip } from './EnemyShip.js'; import { EnemyShip } from './EnemyShip.js';
import { Bullet } from './Bullet.js'; import { Bullet } from './Bullet.js';
import { Fx } from './Fx.js'; import { Fx, FX_COLORS } from './Fx.js';
import { applyDamage } from './Combat.js'; import { applyDamage } from './Combat.js';
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js'; import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js';
@ -320,7 +320,9 @@ export class CombatSystem {
if (this.hitsSwept(b, e, e.hitRadius, delta)) { if (this.hitsSwept(b, e, e.hitRadius, delta)) {
const preShields = e.shields; const preShields = e.shields;
const killed = e.damage(b.damage); const killed = e.damage(b.damage);
this.fx.flash(b.x, b.y, '#ff9db4', 34, 130); const kind = preShields > 0 ? 'shield' : 'hull';
this.fx.flash(b.x, b.y, FX_COLORS[kind], 34, 130);
this.fx.damage(b.x, b.y, b.damage, kind);
combatSfx(this.scene, 'impact'); combatSfx(this.scene, 'impact');
combatSfxEnemyHit(this.scene, preShields, b.damage); combatSfxEnemyHit(this.scene, preShields, b.damage);
if (killed) this.killEnemy(e, this._now()); if (killed) this.killEnemy(e, this._now());
@ -398,6 +400,7 @@ export class CombatSystem {
const preShields = b.shields; const preShields = b.shields;
const ramSelf = Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0; const ramSelf = Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0;
this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0); // impact + shield/hull this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0); // impact + shield/hull
this.fx.damage(b.x, b.y, ramSelf, preShields > 0 ? 'shield' : 'hull');
combatSfxEnemyHit(this.scene, preShields, ramSelf); combatSfxEnemyHit(this.scene, preShields, ramSelf);
const killed = b.damage(ramSelf); const killed = b.damage(ramSelf);
if (killed) this.killEnemy(b, t); if (killed) this.killEnemy(b, t);
@ -414,7 +417,10 @@ export class CombatSystem {
const { dead, shieldHit, hullHit } = applyDamage(armor, n); const { dead, shieldHit, hullHit } = applyDamage(armor, n);
this.hull = armor.hull; this.hull = armor.hull;
this.shields = armor.shields; this.shields = armor.shields;
if (this.ship) this.fx.flash(this.ship.x, this.ship.y, '#ffffff', 60, 140); // The hit-flash reads the layer that took it (cyan = shields, orange = hull).
const kind = shieldHit > 0 ? 'shield' : 'hull';
if (this.ship) this.fx.flash(this.ship.x, this.ship.y, FX_COLORS[kind], 60, 140);
if (this.ship) this.fx.damage(this.ship.x, this.ship.y, n, kind); // the damage number (both contexts)
// The player-hit voices (one place: lances AND rams funnel here). // The player-hit voices (one place: lances AND rams funnel here).
combatSfx(this.scene, 'impact'); combatSfx(this.scene, 'impact');
if (shieldHit > 0) combatSfx(this.scene, 'shield'); if (shieldHit > 0) combatSfx(this.scene, 'shield');
@ -428,7 +434,7 @@ export class CombatSystem {
this.playerDown = true; this.playerDown = true;
this.hull = 0; this.hull = 0;
if (this.ship) { if (this.ship) {
this.fx.explode(this.ship.x, this.ship.y, '#7ce8ff'); this.fx.down(this.ship.x, this.ship.y); // the DOWN cue (burst + hold)
this.ship.stop(); this.ship.stop();
this.ship.setVisible(false); this.ship.setVisible(false);
} }
@ -447,7 +453,7 @@ export class CombatSystem {
this.lastHitAt = 0; this.lastHitAt = 0;
this.respawnAt = 0; this.respawnAt = 0;
if (this.ship) this.ship.setVisible(true); if (this.ship) this.ship.setVisible(true);
if (this.ship) this.fx.flash(this.ship.x, this.ship.y, '#7ce8ff', 80, 300); if (this.ship) this.fx.respawn(this.ship.x, this.ship.y); // the REFORMED cue
combatSfx(this.scene, 'respawn'); combatSfx(this.scene, 'respawn');
if (this.onPlayerUp) this.onPlayerUp(); if (this.onPlayerUp) this.onPlayerUp();
} }

View File

@ -1,10 +1,26 @@
import Phaser from '../vendor/phaser.js'; import Phaser from '../vendor/phaser.js';
import { toColor } from '../utils/Color.js'; import { toColor, toCss } from '../utils/Color.js';
import { canvasTexture } from '../utils/Textures.js'; import { canvasTexture } from '../utils/Textures.js';
import { fontStack } from '../utils/Theme.js';
const DOT_KEY = '__combat_dot'; const DOT_KEY = '__combat_dot';
const DOT_SIZE = 64; 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) { function drawSoftDot(ctx, size) {
const c = size / 2; const c = size / 2;
const g = ctx.createRadialGradient(c, c, 0, c, c, c); const g = ctx.createRadialGradient(c, c, 0, c, c, c);
@ -89,12 +105,79 @@ export class Fx {
} }
} }
/**
* 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. */ /** The big one — a ship's death: core flash + colored bloom + wave + sparks. */
explode(x, y, color) { explode(x, y, color) {
this.flash(x, y, 0xffffff, 90, 160); this.flash(x, y, 0xffffff, 90, 160);
this.flash(x, y, color, 150, 420); this.flash(x, y, color, 150, 420);
this.ring(x, y, color, 200, 480); this.ring(x, y, color, 200, 480);
this.sparks(x, y, color, 22, 300, 700); 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. */ /** Step every live item (ms). Dead ones are destroyed. */
@ -103,7 +186,9 @@ export class Fx {
for (let i = this.items.length - 1; i >= 0; i--) { for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i]; const it = this.items[i];
it.t0 += deltaMs; it.t0 += deltaMs;
const t = Math.min(1, it.t0 / it.life); 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 (t >= 1) {
it.img.destroy(); it.img.destroy();
this.items.splice(i, 1); this.items.splice(i, 1);
@ -115,6 +200,9 @@ export class Fx {
} else if (it.kind === 'ring') { } else if (it.kind === 'ring') {
it.img.setScale(it.from + (it.to - it.from) * t); it.img.setScale(it.from + (it.to - it.from) * t);
it.img.setAlpha(0.8 * (1 - 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') { } else if (it.kind === 'spark') {
it.img.x += it.vx * dt; it.img.x += it.vx * dt;
it.img.y += it.vy * dt; it.img.y += it.vy * dt;