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:
parent
8fd94ce48c
commit
ecff203159
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue