Combat SFX layer: seven distinct event voices, shared across both contexts

- js/combat/CombatSfx.js (new): ONE event→voice map + playback path for
  both contexts, using ONLY existing data/sfx.json files:
    fire=scan · impact=ui_click · shield=solar_buzz · hull=mining_split
    kill=thunder_01..04 (one at random) · down=deconstruct ·
    respawn=discovery
  plus combatSfxEnemyHit() (shield/hull decision in one place: a hit
  that breaks through to the hull plays both — the shield-pop read),
  loadCombatSfx() for the range's preload (the main game already loads
  all of sfx.json), and a capped dev log on window.__COMBAT_SFX__ so
  the wiring is verifiable headless (no audio manager) in both.
- js/combat/CombatScene.js: fire/enemy-fire/wall-clip/ram/lance/kill/
  down/respawn sites call the shared path; the player-hit voices
  (impact + shield/hull from applyDamage) funnel through damagePlayer.
- js/combat/CombatSystem.js: the same seven call sites, identical
  wiring (no per-context copies).
- dev/combat.test.mjs: sfx block — seven events mapped, voices exist in
  data/sfx.json, pairwise distinct, vol scaling, thunder pick, the
  shield/hull decision, the dev log, the master switch (0 failures).
- dev/combat.sfxcheck.mjs (new): CDP check that drives real events in
  /combat AND index.html?combat and asserts all seven events pass
  through the shared path — PASS in both.
This commit is contained in:
Brian Fertig 2026-09-16 00:47:45 -06:00
parent 5949bac861
commit 8fd94ce48c
5 changed files with 454 additions and 7 deletions

217
dev/combat.sfxcheck.mjs Normal file
View File

@ -0,0 +1,217 @@
/**
* Combat SFX wiring check (dev tool, run with Node + a CDP browser):
*
* node dev/combat.sfxcheck.mjs
*
* Drives REAL game events in BOTH combat contexts — the range
* (http://127.0.0.1:8080/combat) and the main game
* (http://127.0.0.1:8080/index.html?combat) — and asserts that each of
* the seven combat events (fire / impact / shield / hull / kill /
* down / respawn) went through the SHARED SFX path
* (js/combat/CombatSfx.js → window.__COMBAT_SFX__ dev log).
*
* Why a log: a headless browser has no audio manager, so the shared
* guards (js/utils/Sfx.js) stay silent — the dev log records the call
* itself, which is exactly the wiring under test. In a real browser
* the same calls play the data/sfx.json voices.
*
* Requires: the dev server on :8080 (node dev/server.mjs 8080) and a
* headless Chromium/Brave with CDP on 127.0.0.1:9333 (see dev/cdp-shot.mjs).
*/
const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333';
const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:8080';
const EVENTS = ['fire', 'impact', 'shield', 'hull', 'kill', 'down', 'respawn'];
function connect(target) {
const ws = new WebSocket(target.webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => {
const mid = ++id;
pending.set(mid, { res, rej });
ws.send(JSON.stringify({ id: mid, method, params }));
});
ws.onmessage = (m) => {
const x = JSON.parse(m.data);
if (x.id && pending.has(x.id)) {
const { res, rej } = pending.get(x.id);
pending.delete(x.id);
x.error ? rej(new Error(x.error.message)) : res(x.result);
}
};
const open = new Promise((r) => { ws.onopen = r; ws.onerror = () => r(); });
return { ws, send, open };
}
async function evalJs(c, expression, awaitPromise = true) {
const r = await c.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise });
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails, null, 2));
return r.result?.value;
}
async function openPage(path) {
const url = `${BASE}${path}`;
let target;
try {
target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json();
} catch {
target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json();
}
const c = connect(target);
await c.open;
await c.send('Runtime.enable');
return { c, target };
}
/**
* Drive the seven events in `scope` (a JS expression resolving to a
* scope object) and return the distinct events observed in the dev log.
*/
function driveScript(scopeExpr) {
return `(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const scope = ${scopeExpr};
if (!scope) return 'no scope';
const logBefore = (window.__COMBAT_SFX__ && window.__COMBAT_SFX__.log || []).length;
// fire — the cannon goes off (player side, full voice).
scope.fire();
// impact + shield — a hit absorbed by the shields.
scope.armShielded();
scope.hitPlayer(5);
// impact + hull — the shields are gone, the armor takes it.
scope.armBare();
scope.hitPlayer(5);
// kill — a hostile goes down.
scope.killOne();
// down — the player's hull breaks.
scope.killPlayer();
// respawn — the player reforms.
scope.respawn();
await sleep(80);
const log = (window.__COMBAT_SFX__ && window.__COMBAT_SFX__.log) || [];
const slice = log.slice(logBefore);
return [...new Set(slice.map((e) => e.event))];
})()`;
}
async function checkContext(name, path, scopeExpr, startExpr) {
const { c, target } = await openPage(path);
try {
// Give the game time to boot (assets, fonts).
let ready = false;
for (let i = 0; i < 80; i++) {
await new Promise((r) => setTimeout(r, 500));
try {
ready = await evalJs(c, 'Boolean(window.game)', false);
} catch { ready = false; }
if (ready) break;
}
if (!ready) throw new Error('game never booted');
// Start the run (the main game boots on the menu — the range doesn't).
if (startExpr) await evalJs(c, startExpr, false);
// Wait for the combat scope to exist (the hook lands in create()).
let scoped = false;
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 500));
try {
scoped = await evalJs(c, `Boolean(${scopeExpr})`, false);
} catch { scoped = false; }
if (scoped) break;
}
if (!scoped) throw new Error('combat scope never appeared');
await new Promise((r) => setTimeout(r, 800));
const events = await evalJs(c, driveScript(scopeExpr));
if (!Array.isArray(events)) throw new Error(`drive failed: ${events}`);
const missing = EVENTS.filter((e) => !events.includes(e));
if (missing.length) throw new Error(`missing events: ${missing.join(', ')}`);
console.log(`${name}: all seven events fired through the shared SFX path`);
return true;
} catch (err) {
console.error(`${name}: FAIL — ${err.message}`);
return false;
} finally {
try {
await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {});
} catch { /* ignore */ }
c.ws.close();
}
}
const scopeRange = `(() => {
const scene = window.game.scene.getScene('CombatScene');
const cfg = window.config;
return {
fire: () => scene.firePlayer(0, cfg.section('combat.player.weapon', {})),
armShielded: () => { scene.shields = scene.maxShields; scene.playerDead = false; scene.ship.setVisible(true); },
armBare: () => { scene.shields = 0; scene.hull = 50; },
hitPlayer: (n) => scene.damagePlayer(n),
killOne: () => {
const e = scene.enemies.find((x) => !x.dead);
if (e) { e.damage(9999); scene.killEnemy(e); }
},
killPlayer: () => {
if (scene.playerDead) { scene.respawnAt = 0; scene.maybeRespawn(scene.time.now); }
scene.shields = 0; scene.hull = 1; scene.damagePlayer(9999);
},
respawn: () => { scene.respawnAt = 0; scene.maybeRespawn(scene.time.now); },
};
})()`;
const scopeMain = `(() => {
const oc = window.__ORBIT_COMBAT__;
if (!oc) return null;
const sys = oc.system;
return {
fire: () => {
const t = sys.nearestEnemy(sys.ship.x, sys.ship.y);
if (t) sys.firePlayerAt(sys.ship, t, sys._now());
},
armShielded: () => { sys.shields = sys.maxShields; sys.playerDown = false; sys.ship.setVisible(true); },
armBare: () => { sys.shields = 0; sys.hull = 50; },
hitPlayer: (n) => sys.damagePlayer(n),
killOne: () => {
const e = sys.enemies.find((x) => !x.dead);
if (e) { e.damage(9999); sys.killEnemy(e, sys._now()); }
},
killPlayer: () => {
if (sys.playerDown) sys.revivePlayer(sys._now());
sys.shields = 0; sys.hull = 1; sys.damagePlayer(9999);
},
respawn: () => sys.revivePlayer(sys._now()),
};
})()`;
const results = [];
results.push(await checkContext('range (/combat)', '/combat', scopeRange));
results.push(await checkContext(
'main (index.html?combat)',
'/index.html?combat',
scopeMain,
`(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
for (let i = 0; i < 120; i++) {
const m = window.game && window.game.scene && window.game.scene.getScene('MenuScene');
if (m && typeof m.seedValue === 'string') { m.startNewGame(); return 'started'; }
await sleep(500);
}
return 'menu never ready';
})()`,
));
if (results.every(Boolean)) {
console.log('combat SFX wiring: PASS (both contexts, all seven events)');
process.exit(0);
} else {
console.log('combat SFX wiring: FAIL');
process.exit(1);
}

View File

@ -126,6 +126,9 @@ const { applyDamage, headingOf, orbitStep, handleFeel } = await import(
const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href);
const { Bullet } = await import(pathToFileURL(join(__dirname, '../js/combat/Bullet.js')).href);
const { CombatSystem } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href);
const {
COMBAT_SFX, combatSfx, combatSfxEnemyHit,
} = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSfx.js')).href);
let failures = 0;
const check = (label, cond) => {
@ -403,6 +406,89 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
check('system: destroy clears pack + tracers', combat.enemies.length === 0 && combat.bullets.length === 0);
}
// ------------------------------------------------------------------ SFX
{
// A scene with just enough of a sound manager for the shared guards to
// pass (js/utils/Sfx.js) — every play lands in `played`.
const makeSfxScene = () => {
const played = [];
return {
played,
sound: { play: (key, o) => played.push({ key, volume: o.volume }) },
cache: { audio: { has: () => true } },
};
};
const sfxNames = config.get('sfx') ?? {};
check('sfx: the seven combat events, each mapped',
Object.keys(COMBAT_SFX).sort().join(',') ===
'down,fire,hull,impact,kill,respawn,shield');
check('sfx: every mapped voice exists in data/sfx.json',
Object.values(COMBAT_SFX).flat().every((n) => typeof sfxNames[n] === 'string'));
const sets = Object.values(COMBAT_SFX).map((ns) => new Set(ns));
check('sfx: the events\u2019 voices are pairwise distinct',
sets.every((a, i) => sets.every((b, j) => i === j || ![...a].some((n) => b.has(n)))));
const s1 = makeSfxScene();
check('sfx: fire → the scan ping, base volume',
combatSfx(s1, 'fire') === 'fire' &&
s1.played.length === 1 &&
s1.played[0].key === 'sfx_scan' &&
s1.played[0].volume === Number(config.get('sfx.volume', 0.55)));
const s2 = makeSfxScene();
combatSfx(s2, 'fire', { vol: 0.45 }); // enemy lances play quieter
check('sfx: vol scales the volume',
Math.abs(s2.played[0].volume - Number(config.get('sfx.volume', 0.55)) * 0.45) < 1e-9);
const killKeys = new Set();
for (let i = 0; i < 60; i++) {
const sc = makeSfxScene();
combatSfx(sc, 'kill');
killKeys.add(sc.played[0]?.key);
}
check('sfx: kill picks one of the thunder set',
killKeys.size >= 2 && [...killKeys].every((k) => /^sfx_thunder_0[1-4]$/.test(k)));
const sU = makeSfxScene();
check('sfx: unknown event is a no-op', combatSfx(sU, 'nope') === null && sU.played.length === 0);
// The shield/hull decision (one shared helper, both contexts):
// in front of the hit the craft has `preShields` shield points.
const hit = (pre, dmg) => {
const sc = makeSfxScene();
combatSfxEnemyHit(sc, pre, dmg);
return sc.played.map((p) => p.key.replace('sfx_', ''));
};
check('sfx: full shields → the shield voice only',
JSON.stringify(hit(40, 10)) === JSON.stringify(['solar_buzz']));
check('sfx: no shields → the hull voice only',
JSON.stringify(hit(0, 10)) === JSON.stringify(['mining_split']));
check('sfx: shield breaks through → BOTH (the pop reads)',
JSON.stringify(hit(5, 10)) === JSON.stringify(['solar_buzz', 'mining_split']));
// The dev log: every call is remembered (capped) + mirrored to window.
combatSfx(makeSfxScene(), 'down');
const after = window.__COMBAT_SFX__ ?? null;
check('sfx: dev log records the call (window.__COMBAT_SFX__)',
after && after.last === 'down' &&
after.log.length >= 1 && after.log.length <= 64 &&
after.log[after.log.length - 1].event === 'down');
// The master switch: off → nothing plays, but the log still records
// the call (headless verification depends on it).
const saved = config.data.sfx.enabled;
config.data.sfx.enabled = false;
const sOff = makeSfxScene();
combatSfx(sOff, 'kill');
config.data.sfx.enabled = saved;
check('sfx: master switch off → silent, still logged',
sOff.played.length === 0 && (window.__COMBAT_SFX__?.last === 'kill'));
}
// ------------------------------------------------------------------ done
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);

View File

@ -12,6 +12,7 @@ import { Bullet } from './Bullet.js';
import { Fx } from './Fx.js';
import { StarDrift } from './StarDrift.js';
import { applyDamage, handleFeel } from './Combat.js';
import { combatSfx, combatSfxEnemyHit, loadCombatSfx } from './CombatSfx.js';
/**
* The COMBAT SANDBOX — Orbit's standalone combat range (combat.html /
@ -72,6 +73,10 @@ export class CombatScene extends Phaser.Scene {
frameHeight: cfg.enemy?.frameHeight ?? 128,
});
}
// The combat voices (js/combat/CombatSfx.js — the shared event→sound
// map; the main game loads these same keys itself in GameScene).
loadCombatSfx(this);
}
// ---------------------------------------------------------------- create
@ -304,6 +309,7 @@ export class CombatScene extends Phaser.Scene {
const y = this.ship.y + Math.sin(heading) * nose;
this.spawnBullet(x, y, angle, wpn, 'player');
this.fx.flash(x, y, wpn.color ?? 0x00e5ff, 30, 110);
combatSfx(this, 'fire');
}
updateThrustGlow(ship, heading, thrusting, dt) {
@ -370,6 +376,7 @@ export class CombatScene extends Phaser.Scene {
'enemy',
);
this.fx.flash(e.x + Math.cos(toPlayer) * nose, e.y + Math.sin(toPlayer) * nose, wpn.color ?? 0xff2d6f, 28, 100);
combatSfx(this, 'fire', { vol: 0.45 }); // the same blaster voice, quieter
}
killEnemy(e) {
@ -377,6 +384,7 @@ export class CombatScene extends Phaser.Scene {
e._killFx = true; // the dead flag can't be the "already counted" guard
this.kills += 1;
this.fx.explode(e.x, e.y, 0xff5d6f);
combatSfx(this, 'kill');
e.setVisible(false);
if (e.body) {
e.body.velocity.x = 0;
@ -433,7 +441,10 @@ export class CombatScene extends Phaser.Scene {
}
// Walls clip tracers (a bullet never outlives the arena).
if (b.x < this.inset || b.x > this.w - this.inset || b.y < this.inset || b.y > this.h - this.inset) {
if (b.consume()) this.bullets.splice(i, 1);
if (b.consume()) {
this.bullets.splice(i, 1);
combatSfx(this, 'impact', { vol: 0.5 }); // the tracer dies on the wall
}
}
}
}
@ -470,8 +481,11 @@ export class CombatScene extends Phaser.Scene {
for (const e of this.enemies) {
if (e.dead) continue;
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);
combatSfx(this, 'impact');
combatSfxEnemyHit(this, preShields, b.damage);
if (killed) this.killEnemy(e);
if (b.consume()) this.bullets.splice(i, 1);
break;
@ -538,8 +552,11 @@ export class CombatScene extends Phaser.Scene {
const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2;
this.fx.flash(midX, midY, 0xffffff, 46, 160);
this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0);
const killed = b.damage(Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0);
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
combatSfxEnemyHit(this, preShields, ramSelf);
const killed = b.damage(ramSelf);
if (killed) this.killEnemy(b);
}
}
@ -573,10 +590,14 @@ export class CombatScene extends Phaser.Scene {
if (this.playerDead) return;
this.lastHitAt = this.time.now;
const armor = { hull: this.hull, shields: this.shields };
const { dead } = applyDamage(armor, n);
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 player-hit voices (one place: lances AND rams funnel here).
combatSfx(this, 'impact');
if (shieldHit > 0) combatSfx(this, 'shield');
if (hullHit > 0) combatSfx(this, 'hull');
if (dead) this.killPlayer();
}
@ -584,6 +605,7 @@ export class CombatScene extends Phaser.Scene {
this.playerDead = true;
this.hull = 0;
this.fx.explode(this.ship.x, this.ship.y, themeColor('neon', 0x00e5ff));
combatSfx(this, 'down');
this.ship.stop();
this.ship.setVisible(false);
if (config.get('combat.sandbox.respawn', true)) {
@ -603,6 +625,7 @@ export class CombatScene extends Phaser.Scene {
this.playerDead = false;
this.clearBullets();
this.fx.flash(this.ship.x, this.ship.y, themeColor('neon', 0x00e5ff), 70, 260);
combatSfx(this, 'respawn');
}
clearBullets() {

105
js/combat/CombatSfx.js Normal file
View File

@ -0,0 +1,105 @@
import { config } from '../config/Config.js';
import { playSfxOn, playSfxOneOfOn } from '../utils/Sfx.js';
/**
* Combat SFX — ONE shared event→voice map for both combat contexts (the
* range: js/combat/CombatScene.js, and the main game: js/combat/CombatSystem.js).
* The contexts call `combatSfx(scene, event)` at their event sites; the
* map + the playback path live HERE, so the two contexts can never drift.
*
* Every event gets a DISTINCT voice from the EXISTING data/sfx.json set
* (no new sound files):
*
* fire scan the blaster ping (player cannon; enemy lances
* play the same voice, quieter via `vol`)
* impact ui_click the light tick of a tracer striking
* shield solar_buzz the energy shimmer of a field absorbing
* hull mining_split the heavy crack of armor taking the hit
* kill thunder_01..04 one at random — the boom of a ship dying
* down deconstruct the player's death (the collapse voice)
* respawn discovery the player reforming (the "new object in
* view" voice)
*
* Playback goes through the shared Sfx.js guards (master switch, audio
* manager, cache) — a headless scene with no manager simply doesn't
* sound, but the dev log below still records the call.
*/
export const COMBAT_SFX = {
fire: ['scan'],
impact: ['ui_click'],
shield: ['solar_buzz'],
hull: ['mining_split'],
kill: ['thunder_01', 'thunder_02', 'thunder_03', 'thunder_04'],
down: ['deconstruct'],
respawn: ['discovery'],
};
// ---------------------------------------------------------------- dev log
//
// Dev hook (documented in the README's combat section): every combatSfx
// call is recorded here — capped, mirrored to window.__COMBAT_SFX__ —
// so the wiring is verifiable in a headless browser (no audio manager)
// in BOTH contexts: dev/combat.sfxcheck.mjs drives real game events and
// reads the log back. It observes the real call path; it never plays
// sound itself.
const LOG_CAP = 64;
const log = [];
function remember(entry) {
log.push(entry);
if (log.length > LOG_CAP) log.splice(0, log.length - LOG_CAP);
if (typeof window !== 'undefined') {
window.__COMBAT_SFX__ = { log: log.slice(), last: entry.event };
}
}
/**
* Play the combat voice for `event` on `scene`.
*
* @param {object} scene a Phaser scene (any of the combat contexts')
* @param {string} event a COMBAT_SFX key
* @param {object} [o] `vol` = 0..1 multiplier (enemy fire plays quieter)
* @returns {string|null} the event name (null for an unknown event)
*/
export function combatSfx(scene, event, o = {}) {
const names = COMBAT_SFX[event];
if (!names) return null;
const vol = Math.min(1, Math.max(0, Number(o.vol) || 1));
const pass = { ...o, volume: Number(config.get('sfx.volume', 0.55)) * vol };
delete pass.vol;
if (names.length > 1) playSfxOneOfOn(scene, names, pass);
else playSfxOn(scene, names[0], pass);
remember({ event, names, t: Date.now() });
return event;
}
/**
* A hostile took a `dmg`-point hit with `preShields` shield points in
* front of it (read BEFORE the damage landed): the shield/hull voices —
* a hit that BREAKS through to the hull plays both, which reads as the
* shield popping. The `impact` tick is the caller's (it also plays on
* walls and rams).
*/
export function combatSfxEnemyHit(scene, preShields, dmg) {
if (preShields > 0) combatSfx(scene, 'shield');
if (preShields < dmg) combatSfx(scene, 'hull');
}
/**
* Queue the combat voices onto a scene's loader (the range's preload —
* the main game already loads ALL of data/sfx.json in GameScene).
* Skipped when the master switch is off (same rule as GameScene).
*/
export function loadCombatSfx(scene) {
if (!scene || !scene.load || !config.get('sfx.enabled', true)) return;
const seen = new Set();
for (const names of Object.values(COMBAT_SFX)) {
for (const n of names) {
if (seen.has(n)) continue;
seen.add(n);
const path = config.get(`sfx.${n}`, '');
if (typeof path === 'string' && path) scene.load.audio(`sfx_${n}`, path);
}
}
}

View File

@ -3,6 +3,7 @@ import { EnemyShip } from './EnemyShip.js';
import { Bullet } from './Bullet.js';
import { Fx } from './Fx.js';
import { applyDamage } from './Combat.js';
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js';
const TAU = Math.PI * 2;
@ -248,6 +249,7 @@ export class CombatSystem {
const y = player.y + Math.sin(a) * nose;
this.spawnBullet(x, y, a, wpn, 'player');
this.fx.flash(x, y, wpn.color ?? '#00e5ff', 26, 110);
combatSfx(this.scene, 'fire');
}
/** A hostile's lance at the player (aimed at the ship, spread-jittered). */
@ -264,6 +266,7 @@ export class CombatSystem {
'enemy',
);
this.fx.flash(e.x + Math.cos(a) * nose, e.y + Math.sin(a) * nose, wpn.color ?? '#ff2d6f', 28, 100);
combatSfx(this.scene, 'fire', { vol: 0.45 }); // the same blaster voice, quieter
}
spawnBullet(x, y, angle, wpn, owner) {
@ -315,8 +318,11 @@ export class CombatSystem {
for (const e of this.enemies) {
if (e.dead) continue;
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);
combatSfx(this.scene, 'impact');
combatSfxEnemyHit(this.scene, preShields, b.damage);
if (killed) this.killEnemy(e, this._now());
if (b.consume()) this.bullets.splice(i, 1);
break;
@ -389,8 +395,11 @@ export class CombatSystem {
const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2;
this.fx.flash(midX, midY, '#ffffff', 46, 160);
this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0);
const killed = b.damage(Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0);
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
combatSfxEnemyHit(this.scene, preShields, ramSelf);
const killed = b.damage(ramSelf);
if (killed) this.killEnemy(b, t);
}
}
@ -402,10 +411,14 @@ export class CombatSystem {
if (this.playerDown) return;
this.lastHitAt = this._now();
const armor = { hull: this.hull, shields: this.shields };
const { dead } = applyDamage(armor, n);
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 player-hit voices (one place: lances AND rams funnel here).
combatSfx(this.scene, 'impact');
if (shieldHit > 0) combatSfx(this.scene, 'shield');
if (hullHit > 0) combatSfx(this.scene, 'hull');
if (this.onPlayerDamage) this.onPlayerDamage(n, { hull: this.hull, shields: this.shields });
if (dead) this.killPlayer();
}
@ -419,6 +432,7 @@ export class CombatSystem {
this.ship.stop();
this.ship.setVisible(false);
}
combatSfx(this.scene, 'down');
if (this.onPlayerDown) this.onPlayerDown();
if (this.playerRespawn) {
this.respawnAt = this._now() + this.playerRespawnDelay;
@ -434,6 +448,7 @@ export class CombatSystem {
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);
combatSfx(this.scene, 'respawn');
if (this.onPlayerUp) this.onPlayerUp();
}
@ -447,6 +462,7 @@ export class CombatSystem {
e._killFx = true;
this.kills += 1;
this.fx.explode(e.x, e.y, '#ff5d6f');
combatSfx(this.scene, 'kill');
e.setVisible(false);
if (e.body) {
e.body.velocity.x = 0;