diff --git a/README.md b/README.md index 6fd32e4..d8989a2 100644 --- a/README.md +++ b/README.md @@ -73,14 +73,16 @@ respawns, the bar — lives in **`data/combat.json`**: **player goes down**, and a REFORMED bloom on **respawn**. Shield reads cyan and hull reads orange in both the flash and the number, so a glance tells you which layer took the hit. -- **Feedback — SFX** — the same eight events each get a *distinct* voice - from the existing `data/sfx.json` set (no new sound files), through - one shared event→voice map + playback path (`js/combat/CombatSfx.js`) - so the two fronts can't drift: firing = `scan`, impact = `ui_click`, - shield = `solar_buzz`, hull = `mining_split`, kill = `thunder_01..04` +- **Feedback — SFX** — the same ten events each get a *distinct* voice, + through one shared event→voice map + playback path + (`js/combat/CombatSfx.js`) so the two fronts can't drift: the player's + cannon = `mass_cannon` (assets/fx/combat-mass-cannon.mp3), a hostile + taking the player's shot = `mass_cannon_hit` + (assets/fx/combat-mass-cannon-hit.mp3), the enemy's laser = `laser` + (assets/fx/combat-laser.mp3), impact = `ui_click`, shield = + `solar_buzz`, hull = `mining_split`, kill = `thunder_01..04` (one at random), down = `deconstruct`, respawn = `discovery`, - level-up = `solar_flare_01`. Enemy fire reuses the `scan` voice, - quieter. + level-up = `solar_flare_01`. - **Readability layer** — a shared HUD (`js/combat/CombatHud.js`, pure rules in `js/combat/Combat.js`) makes the fight *readable* in both fronts: a rotating **lock reticle** on the current target (the same @@ -111,7 +113,7 @@ respawns, the bar — lives in **`data/combat.json`**: wiring is verifiable headless in both fronts. CDP checkers drive the real game and read state back: `node dev/combat-flycheck.mjs` (flight feel — t60/t30, spin, stop distance, top speed), - `node dev/combat.sfxcheck.mjs` (all eight SFX events fire through the + `node dev/combat.sfxcheck.mjs` (all the SFX events fire through the shared path, in both the range and the main game), and `node dev/combat-shot.mjs [outDir]` (captures a labeled feedback frame per context through the production paths into `dev/shots/` — the diff --git a/data/sfx.json b/data/sfx.json index f2769fa..bd36d52 100644 --- a/data/sfx.json +++ b/data/sfx.json @@ -1,10 +1,13 @@ { - "_comment": "Sound effects. enabled = master switch (also skips the load). volume is 0..1. construct = text typing in (the dossier, menus decoding in); deconstruct = text UNDECODING (the dossier collapsing) — reserved for the text only, never for closing UI; discovery = a new object coming into view; mining = the mining arm powering up (the 'Extending Mining Arm...' reach); mining_loop = the hum that LOOPS while the mining beam is live (starts when the arm finishes extending, stops when the sequence ends — stopping mining itself plays no sound); scan = the DEEP SCAN pulse going out (the command deck's SCAN button); ui_hover = the tick on HOVER of a clickable item (menu/deck buttons, compass name tags, panel buttons, save slots); ui_click = the tick on CLICK of a clickable item (the SCAN slot plays its own sonar ping instead of ui_click); ui_window = the whoosh when a window OPENS (the mining pop-up, the comms/landing panel); ui_close = the tick when a UI element is CANCELLED/CLOSED (the menu sub-bar, the save vault, the confirm dialog). Convention: the key here IS the play name — playSfx('') plays the asset queued as 'sfx_'. mining_split = the asteroid CRACKING when it breaks (shatters into pieces, or splits in half) — once per break. thunder_01…thunder_04 = the HABITABLE system's storm STRIKE (the lightning going off) — one of the four at random, once per strike, with a short throttle so near-simultaneous clusters make one clap, not four; solar_buzz = the BINARY system's two-light trade passing its MIDPOINT (the star light's color change half-way between the two stars' hues — both crossings, A→B and B→A); solar_flare_01/02 = the RED DWARF's flare FLARING UP — one of the two at random, once per flare onset.", + "_comment": "Sound effects. enabled = master switch (also skips the load). volume is 0..1. construct = text typing in (the dossier, menus decoding in); deconstruct = text UNDECODING (the dossier collapsing) — reserved for the text only, never for closing UI; discovery = a new object coming into view; mining = the mining arm powering up (the 'Extending Mining Arm...' reach); mining_loop = the hum that LOOPS while the mining beam is live (starts when the arm finishes extending, stops when the sequence ends — stopping mining itself plays no sound); scan = the DEEP SCAN pulse going out (the command deck's SCAN button); ui_hover = the tick on HOVER of a clickable item (menu/deck buttons, compass name tags, panel buttons, save slots); ui_click = the tick on CLICK of a clickable item (the SCAN slot plays its own sonar ping instead of ui_click); ui_window = the whoosh when a window OPENS (the mining pop-up, the comms/landing panel); ui_close = the tick when a UI element is CANCELLED/CLOSED (the menu sub-bar, the save vault, the confirm dialog). Convention: the key here IS the play name — playSfx('') plays the asset queued as 'sfx_'. mining_split = the asteroid CRACKING when it breaks (shatters into pieces, or splits in half) — once per break. thunder_01…thunder_04 = the HABITABLE system's storm STRIKE (the lightning going off) — one of the four at random, once per strike, with a short throttle so near-simultaneous clusters make one clap, not four; solar_buzz = the BINARY system's two-light trade passing its MIDPOINT (the star light's color change half-way between the two stars' hues — both crossings, A→B and B→A); solar_flare_01/02 = the RED DWARF's flare FLARING UP — one of the two at random, once per flare onset; mass_cannon = the player's CANNON going off (combat — the new assets/fx/combat-mass-cannon.mp3); mass_cannon_hit = the player's tracer STRIKING a hostile (combat — the new assets/fx/combat-mass-cannon-hit.mp3); laser = the ENEMY's laser going off (combat — the new assets/fx/combat-laser.mp3).", "enabled": true, "volume": 1, "construct": "assets/fx/type-construct.mp3", "deconstruct": "assets/fx/type-deconstruct.mp3", "discovery": "assets/fx/discovery.mp3", + "laser": "assets/fx/combat-laser.mp3", + "mass_cannon": "assets/fx/combat-mass-cannon.mp3", + "mass_cannon_hit": "assets/fx/combat-mass-cannon-hit.mp3", "mining": "assets/fx/system-scan.mp3", "mining_loop": "assets/fx/mining-01.mp3", "mining_split": "assets/fx/mining-02.mp3", diff --git a/dev/combat.sfxcheck.mjs b/dev/combat.sfxcheck.mjs index 2c96c36..b928e74 100644 --- a/dev/combat.sfxcheck.mjs +++ b/dev/combat.sfxcheck.mjs @@ -6,10 +6,10 @@ * 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 + * the nine combat events (cannon_fire / cannon_hit / fire / impact / + * shield / hull / kill / down / respawn) went through the SHARED SFX path * (js/combat/CombatSfx.js → window.__COMBAT_SFX__ dev log) — and, in - * the main game, the EIGHTH event, levelup (the combat level landing — + * the main game, the TENTH event, levelup (the combat level landing — * the run's XP bank, fed through the dev hook). * * Why a log: a headless browser has no audio manager, so the shared @@ -22,7 +22,7 @@ */ 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_RANGE = ['fire', 'impact', 'shield', 'hull', 'kill', 'down', 'respawn']; +const EVENTS_RANGE = ['cannon_fire', 'cannon_hit', 'fire', 'impact', 'shield', 'hull', 'kill', 'down', 'respawn']; const EVENTS_MAIN = [...EVENTS_RANGE, 'levelup']; // the main game's XP bank function connect(target) { @@ -78,9 +78,15 @@ function driveScript(scopeExpr, withLevelUp = false) { const driveLevelUp = ${Boolean(withLevelUp)}; // injected at build time const logBefore = (window.__COMBAT_SFX__ && window.__COMBAT_SFX__.log || []).length; - // fire — the cannon goes off (player side, full voice). + // cannon_fire — the player's cannon goes off. scope.fire(); + // cannon_hit — the player's tracer strikes a hostile. + scope.hitEnemy(); + + // fire — the enemy's laser goes off (full volume). + scope.fireEnemy(); + // impact + shield — a hit absorbed by the shields. scope.armShielded(); scope.hitPlayer(5); @@ -161,6 +167,19 @@ const scopeRange = `(() => { const cfg = window.config; return { fire: () => scene.firePlayer(0, cfg.section('combat.player.weapon', {})), + hitEnemy: () => { + const e = scene.enemies.find((x) => !x.dead); + if (!e) return; + const wpn = cfg.section('combat.player.weapon', {}); + // A tracer parked 18px off the hostile's flank, aimed at it — the + // real collide() pass connects it (cannon_hit + the layer voices). + scene.spawnBullet(e.x - 18, e.y, 0, wpn, 'player'); + scene.collide(16); + }, + fireEnemy: () => { + const e = scene.enemies.find((x) => !x.dead); + if (e) scene.fireEnemy(e); // a hostile's lance at the player (the laser voice) + }, armShielded: () => { scene.shields = scene.maxShields; scene.playerDead = false; scene.ship.setVisible(true); }, armBare: () => { scene.shields = 0; scene.hull = 50; }, hitPlayer: (n) => scene.damagePlayer(n), @@ -185,6 +204,23 @@ const scopeMain = `(() => { const t = sys.nearestEnemy(sys.ship.x, sys.ship.y); if (t) sys.firePlayerAt(sys.ship, t, sys._now()); }, + hitEnemy: () => { + const e = sys.enemies.find((x) => !x.dead); + if (!e) return; + const v = sys.visibleView(); + const wpn = window.config.section('combat.player.weapon', {}); + // Park the hostile at the camera view's CENTER (the on-screen rule + // gates hits — the pack streams in from off-screen), then a tracer + // 18px off its flank, aimed at it — the real collide() pass + // connects it (cannon_hit + the layer voices). + if (v) { e.x = v.x + v.width / 2; e.y = v.y + v.height / 2; } + sys.spawnBullet(e.x - 18, e.y, 0, wpn, 'player'); + sys.collide(16, sys.ship); + }, + fireEnemy: () => { + const e = sys.enemies.find((x) => !x.dead); + if (e) sys.fireEnemy(e, sys.ship, sys._now()); // a hostile's lance (the laser voice) + }, armShielded: () => { sys.shields = sys.maxShields; sys.playerDown = false; sys.ship.setVisible(true); }, armBare: () => { sys.shields = 0; sys.hull = 50; }, hitPlayer: (n) => sys.damagePlayer(n), @@ -226,7 +262,7 @@ results.push(await checkContext( )); if (results.every(Boolean)) { - console.log('combat SFX wiring: PASS (both contexts — the range\'s seven, the main game\'s eight)'); + console.log('combat SFX wiring: PASS (both contexts — the range\'s nine, the main game\'s ten)'); process.exit(0); } else { console.log('combat SFX wiring: FAIL'); diff --git a/dev/combat.test.mjs b/dev/combat.test.mjs index 67fb7ac..67097ce 100644 --- a/dev/combat.test.mjs +++ b/dev/combat.test.mjs @@ -610,9 +610,9 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d); }; const sfxNames = config.get('sfx') ?? {}; - check('sfx: the eight combat events, each mapped', + check('sfx: the ten combat events, each mapped', Object.keys(COMBAT_SFX).sort().join(',') === - 'down,fire,hull,impact,kill,levelup,respawn,shield'); + 'cannon_fire,cannon_hit,down,fire,hull,impact,kill,levelup,respawn,shield'); check('sfx: every mapped voice exists in data/sfx.json', Object.values(COMBAT_SFX).flat().every((n) => typeof sfxNames[n] === 'string')); @@ -622,14 +622,23 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d); 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', + check('sfx: fire → the laser voice, base volume', combatSfx(s1, 'fire') === 'fire' && s1.played.length === 1 && - s1.played[0].key === 'sfx_scan' && + s1.played[0].key === 'sfx_laser' && s1.played[0].volume === Number(config.get('sfx.volume', 0.55))); + const sC = makeSfxScene(); + check('sfx: cannon_fire → the mass-cannon voice', + combatSfx(sC, 'cannon_fire') === 'cannon_fire' && + sC.played.length === 1 && sC.played[0].key === 'sfx_mass_cannon'); + const sCh = makeSfxScene(); + check('sfx: cannon_hit → the mass-cannon hit voice', + combatSfx(sCh, 'cannon_hit') === 'cannon_hit' && + sCh.played.length === 1 && sCh.played[0].key === 'sfx_mass_cannon_hit'); + const s2 = makeSfxScene(); - combatSfx(s2, 'fire', { vol: 0.45 }); // enemy lances play quieter + combatSfx(s2, 'fire', { vol: 0.45 }); // the vol knob scales the volume check('sfx: vol scales the volume', Math.abs(s2.played[0].volume - Number(config.get('sfx.volume', 0.55)) * 0.45) < 1e-9); diff --git a/js/combat/CombatScene.js b/js/combat/CombatScene.js index 4d4e5c9..6bbe0c4 100644 --- a/js/combat/CombatScene.js +++ b/js/combat/CombatScene.js @@ -348,7 +348,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.muzzle(x, y, angle, wpn.color ?? 0x00e5ff, Number(wpn.muzzle) || 46); - combatSfx(this, 'fire'); + combatSfx(this, 'cannon_fire'); // the mass-cannon voice (the enemy lances keep the blaster ping) } updateThrustGlow(ship, heading, thrusting, dt) { @@ -413,7 +413,7 @@ export class CombatScene extends Phaser.Scene { const my = e.y + Math.sin(toPlayer) * nose; this.spawnBullet(mx, my, angle, wpn, 'enemy'); this.fx.muzzle(mx, my, angle, wpn.color ?? 0xff2d6f, Number(wpn.muzzle) || 50); - combatSfx(this, 'fire', { vol: 0.45 }); // the same blaster voice, quieter + combatSfx(this, 'fire'); // the laser voice, at full volume (the player's cannon has its own voice) } killEnemy(e) { @@ -533,7 +533,7 @@ export class CombatScene extends Phaser.Scene { // and the layer's cue (a cyan shield arc / orange shards). this.fx.enemyHit(e, b.x, b.y, b.angle0, kind, b.damage); this.fx.damage(b.x, b.y, b.damage, kind); - combatSfx(this, 'impact'); + combatSfx(this, 'cannon_hit'); // the mass-cannon hit voice (the shield/hull cues ride on) combatSfxEnemyHit(this, preShields, b.damage); if (killed) this.killEnemy(e); if (b.consume()) this.bullets.splice(i, 1); diff --git a/js/combat/CombatSfx.js b/js/combat/CombatSfx.js index e5fe4eb..484eefb 100644 --- a/js/combat/CombatSfx.js +++ b/js/combat/CombatSfx.js @@ -7,28 +7,39 @@ import { playSfxOn, playSfxOneOfOn } from '../utils/Sfx.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): + * Every event gets a DISTINCT voice from the data/sfx.json set — the two + * cannon voices ride the new assets/fx/combat-mass-cannon*.mp3 files, the + * rest the existing set: * - * 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) - * levelup solar_flare_01 the combat level landing (the charge-rise - * voice — js/combat/XpManager.js's 'levelup' - * event, subscribed by CombatSystem) + * cannon_fire mass_cannon the player's cannon going off (the new + * combat-mass-cannon.mp3) + * cannon_hit mass_cannon_hit the player's tracer striking a hostile + * (the new combat-mass-cannon-hit.mp3) + * fire laser the ENEMY's laser going off (the new + * combat-laser.mp3 — the player's cannon + * plays cannon_fire; the laser plays at + * the same volume) + * impact ui_click the light tick of a tracer striking (the + * player being hit, wall ticks — a hostile + * taking the player's shot plays cannon_hit) + * 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) + * levelup solar_flare_01 the combat level landing (the charge-rise + * voice — js/combat/XpManager.js's 'levelup' + * event, subscribed by CombatSystem) * * 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'], + cannon_fire: ['mass_cannon'], + cannon_hit: ['mass_cannon_hit'], + fire: ['laser'], impact: ['ui_click'], shield: ['solar_buzz'], hull: ['mining_split'], @@ -63,7 +74,8 @@ function remember(entry) { * * @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) + * @param {object} [o] `vol` = 0..1 volume multiplier (default 1 — the + * enemy laser and the player's cannon both play at full) * @returns {string|null} the event name (null for an unknown event) */ export function combatSfx(scene, event, o = {}) { diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index 45512b4..678ba23 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -619,7 +619,7 @@ export class CombatSystem { const y = player.y + Math.sin(a) * nose; this.spawnBullet(x, y, a, wpn, 'player'); this.fx.muzzle(x, y, a, wpn.color ?? '#00e5ff', Number(wpn.muzzle) || 46); - combatSfx(this.scene, 'fire'); + combatSfx(this.scene, 'cannon_fire'); // the mass-cannon voice (the enemy lances keep the blaster ping) } /** A hostile's lance at the player (aimed at the ship, spread-jittered). */ @@ -632,7 +632,7 @@ export class CombatSystem { const my = e.y + Math.sin(a) * nose; this.spawnBullet(mx, my, a, wpn, 'enemy'); this.fx.muzzle(mx, my, a, wpn.color ?? '#ff2d6f', Number(wpn.muzzle) || 50); - combatSfx(this.scene, 'fire', { vol: 0.45 }); // the same blaster voice, quieter + combatSfx(this.scene, 'fire'); // the laser voice, at full volume (the player's cannon has its own voice) } spawnBullet(x, y, angle, wpn, owner) { @@ -695,7 +695,7 @@ export class CombatSystem { // and the layer's cue (a cyan shield arc / orange shards). this.fx.enemyHit(e, b.x, b.y, b.angle0, kind, b.damage); this.fx.damage(b.x, b.y, b.damage, kind); - combatSfx(this.scene, 'impact'); + combatSfx(this.scene, 'cannon_hit'); // the mass-cannon hit voice (the shield/hull cues ride on) combatSfxEnemyHit(this.scene, preShields, b.damage); if (killed) this.killEnemy(e, this._now()); if (b.consume()) this.bullets.splice(i, 1); diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 3efcd9a..1130249 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -339,6 +339,9 @@ export class GameScene extends Phaser.Scene { this.load.audio('sfx_construct', config.get('sfx.construct', 'assets/fx/type-construct.mp3')); this.load.audio('sfx_deconstruct', config.get('sfx.deconstruct', 'assets/fx/type-deconstruct.mp3')); this.load.audio('sfx_discovery', config.get('sfx.discovery', 'assets/fx/discovery.mp3')); + this.load.audio('sfx_laser', config.get('sfx.laser', 'assets/fx/combat-laser.mp3')); + this.load.audio('sfx_mass_cannon', config.get('sfx.mass_cannon', 'assets/fx/combat-mass-cannon.mp3')); + this.load.audio('sfx_mass_cannon_hit', config.get('sfx.mass_cannon_hit', 'assets/fx/combat-mass-cannon-hit.mp3')); this.load.audio('sfx_mining', config.get('sfx.mining', 'assets/fx/system-scan.mp3')); this.load.audio('sfx_mining_loop', config.get('sfx.mining_loop', 'assets/fx/mining-01.mp3')); this.load.audio('sfx_mining_split', config.get('sfx.mining_split', 'assets/fx/mining-02.mp3'));