From ecff203159489d1fee2cb98153881b8da620c6ff Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 16 Sep 2026 01:06:02 -0600 Subject: [PATCH] Feedback VFX pass: damage numbers, color-coded hit flashes, kill burst, down/respawn cues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- js/combat/CombatScene.js | 16 ++++--- js/combat/CombatSystem.js | 16 ++++--- js/combat/Fx.js | 94 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 13 deletions(-) diff --git a/js/combat/CombatScene.js b/js/combat/CombatScene.js index 525ab83..534aba9 100644 --- a/js/combat/CombatScene.js +++ b/js/combat/CombatScene.js @@ -9,7 +9,7 @@ const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; import { Ship } from '../entities/Ship.js'; import { EnemyShip } from './EnemyShip.js'; import { Bullet } from './Bullet.js'; -import { Fx } from './Fx.js'; +import { Fx, FX_COLORS } from './Fx.js'; import { StarDrift } from './StarDrift.js'; import { applyDamage, handleFeel } from './Combat.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)) { const preShields = e.shields; 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'); combatSfxEnemyHit(this, preShields, b.damage); if (killed) this.killEnemy(e); @@ -555,6 +557,7 @@ export class CombatScene extends Phaser.Scene { const preShields = b.shields; const ramSelf = Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0; 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); const killed = b.damage(ramSelf); if (killed) this.killEnemy(b); @@ -593,7 +596,10 @@ export class CombatScene extends Phaser.Scene { const { dead, shieldHit, hullHit } = applyDamage(armor, n); this.hull = armor.hull; 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). combatSfx(this, 'impact'); if (shieldHit > 0) combatSfx(this, 'shield'); @@ -604,7 +610,7 @@ export class CombatScene extends Phaser.Scene { killPlayer() { this.playerDead = true; 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'); this.ship.stop(); this.ship.setVisible(false); @@ -624,7 +630,7 @@ export class CombatScene extends Phaser.Scene { this.ship.setVisible(true); this.playerDead = false; 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'); } diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index acb8f01..744ef0a 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -1,7 +1,7 @@ import { config } from '../config/Config.js'; import { EnemyShip } from './EnemyShip.js'; import { Bullet } from './Bullet.js'; -import { Fx } from './Fx.js'; +import { Fx, FX_COLORS } from './Fx.js'; import { applyDamage } from './Combat.js'; import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js'; @@ -320,7 +320,9 @@ export class CombatSystem { if (this.hitsSwept(b, e, e.hitRadius, delta)) { const preShields = e.shields; 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'); combatSfxEnemyHit(this.scene, preShields, b.damage); if (killed) this.killEnemy(e, this._now()); @@ -398,6 +400,7 @@ export class CombatSystem { const preShields = b.shields; const ramSelf = Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0; 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); const killed = b.damage(ramSelf); if (killed) this.killEnemy(b, t); @@ -414,7 +417,10 @@ export class CombatSystem { const { dead, shieldHit, hullHit } = applyDamage(armor, n); this.hull = armor.hull; 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). combatSfx(this.scene, 'impact'); if (shieldHit > 0) combatSfx(this.scene, 'shield'); @@ -428,7 +434,7 @@ export class CombatSystem { this.playerDown = true; this.hull = 0; 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.setVisible(false); } @@ -447,7 +453,7 @@ export class CombatSystem { this.lastHitAt = 0; this.respawnAt = 0; 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'); if (this.onPlayerUp) this.onPlayerUp(); } diff --git a/js/combat/Fx.js b/js/combat/Fx.js index 0b3629e..e7e088d 100644 --- a/js/combat/Fx.js +++ b/js/combat/Fx.js @@ -1,10 +1,26 @@ 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 { 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); @@ -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. */ 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.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. */ @@ -103,7 +186,9 @@ export class Fx { for (let i = this.items.length - 1; i >= 0; i--) { const it = this.items[i]; 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) { it.img.destroy(); this.items.splice(i, 1); @@ -115,6 +200,9 @@ export class Fx { } 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;