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