diff --git a/dev/combat.sfxcheck.mjs b/dev/combat.sfxcheck.mjs new file mode 100644 index 0000000..3de7601 --- /dev/null +++ b/dev/combat.sfxcheck.mjs @@ -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); +} diff --git a/dev/combat.test.mjs b/dev/combat.test.mjs index 1b9b1e1..a56e065 100644 --- a/dev/combat.test.mjs +++ b/dev/combat.test.mjs @@ -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`); diff --git a/js/combat/CombatScene.js b/js/combat/CombatScene.js index acffd48..525ab83 100644 --- a/js/combat/CombatScene.js +++ b/js/combat/CombatScene.js @@ -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() { diff --git a/js/combat/CombatSfx.js b/js/combat/CombatSfx.js new file mode 100644 index 0000000..0457080 --- /dev/null +++ b/js/combat/CombatSfx.js @@ -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); + } + } +} diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index 90007c9..acb8f01 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -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;