feat: Add sound effects for battle actions and menu interactions

Integrate audio feedback across the game by adding new SFX assets and loading them in `BootScene`. Key changes include:
- Added 12 new sound effect files (attacks, abilities, UI) and a main menu music track.
- Implemented SFX playback in `BattleScene` for actions like attacks, counters, mortar/strike/siege/pierce fires, buffs (rally), shields (protect), damage, destructions, and legendary card plays.
- Stopped main menu music during battles to avoid audio overlap and resumed it upon returning to `MainMenuScene`.
- Added hover and select sounds for UI buttons in `MainMenuScene`.
- Fixed deck composition in `DeckManager` by removing one 'imp_trooper_1' from the starter deck.
This commit is contained in:
Brian Fertig 2026-03-14 08:47:26 -06:00
parent 3c92d58055
commit 850a32c33f
18 changed files with 72 additions and 25 deletions

BIN
assets/audio/fx/attack.mp3 Normal file

Binary file not shown.

BIN
assets/audio/fx/counter.mp3 Normal file

Binary file not shown.

BIN
assets/audio/fx/damage.mp3 Normal file

Binary file not shown.

BIN
assets/audio/fx/destroy.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/audio/fx/mortar.mp3 Normal file

Binary file not shown.

BIN
assets/audio/fx/pierce.mp3 Normal file

Binary file not shown.

BIN
assets/audio/fx/protect.mp3 Normal file

Binary file not shown.

BIN
assets/audio/fx/rally.mp3 Normal file

Binary file not shown.

BIN
assets/audio/fx/siege.mp3 Normal file

Binary file not shown.

BIN
assets/audio/fx/strike.mp3 Normal file

Binary file not shown.

Binary file not shown.

View File

@ -27,7 +27,7 @@ export class DeckManager {
name: 'Starter Deck',
commander: 'imp_cmd_1',
cards: [
'imp_trooper_1', 'imp_trooper_1', 'imp_trooper_1', 'imp_trooper_1',
'imp_trooper_1', 'imp_trooper_1', 'imp_trooper_1',
'imp_gunner_1', 'imp_gunner_1', 'imp_gunner_1',
'imp_guardian_1', 'imp_guardian_1', 'imp_guardian_1',
]

View File

@ -17,6 +17,9 @@ export class BattleScene extends Phaser.Scene {
const cardManager = this.registry.get('cardManager');
const save = this.registry.get('save');
// Stop main menu music for the duration of the battle
this.registry.get('music_main_menu')?.stop();
// Layout constants
// Lanes: 4 lanes centred at x = 430, 740, 1050, 1360 (spacing 310)
// Commander column: x = 150
@ -312,8 +315,8 @@ export class BattleScene extends Phaser.Scene {
}
const events = this.engine.processNextAttack();
const preAttackEvent = events.find(e => e.type === 'preAttack');
const attackEvent = events.find(e => e.type === 'attack');
const preAttackEvent = events.find(e => e.type === 'preAttack');
const attackEvent = events.find(e => e.type === 'attack');
const postAttackEvent = events.find(e => e.type === 'postAttack');
if (!attackEvent) {
@ -329,8 +332,8 @@ export class BattleScene extends Phaser.Scene {
this.commanderObjects.get(state.player.commander.instanceId)?.refresh();
this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh();
const berserkEvent = events.find(e => e.type === 'berserk');
const counterEvent = events.find(e => e.type === 'counter');
const berserkEvent = events.find(e => e.type === 'berserk');
const counterEvent = events.find(e => e.type === 'counter');
// Fire pre-attack hook, then proceed to attack animation.
// Counter fires at impact (cards still enlarged at center) via onImpact callback.
@ -374,6 +377,7 @@ export class BattleScene extends Phaser.Scene {
.setDisplaySize(630, 630)
.setDepth(23);
explosionSprite.play('explosion_anim');
this.sound.play('sfx_destroy', { volume: 0.85 });
explosionSprite.once('animationcomplete', () => {
if (explosionSprite.scene) explosionSprite.destroy();
if (!obj.scene) { if (onComplete) onComplete(); return; }
@ -394,6 +398,9 @@ export class BattleScene extends Phaser.Scene {
// Animate a card appearing on the field (scale from 0 with overshoot)
_animateDeploy(cardObj, onComplete) {
if (!cardObj) { onComplete(); return; }
if (cardObj.cardData?.rarity === 'legendary') {
this.sound.play('sfx_legendary_play', { volume: 0.9 });
}
cardObj.setScale(0).setAlpha(0);
this.tweens.add({
targets: cardObj,
@ -430,6 +437,7 @@ export class BattleScene extends Phaser.Scene {
const targetObj = _lookup(counterEvent.target.instanceId); // the original attacker
if (!sourceObj?.scene || !targetObj?.scene) return;
this.sound.play('sfx_counter', { volume: 0.8 });
this.statusText.setText(`${counterEvent.source.name} counters ${counterEvent.target.name}!`);
@ -460,8 +468,9 @@ export class BattleScene extends Phaser.Scene {
if (counterEvent.damage > 0) {
const target = counterEvent.target;
const hpBefore = target.currentHP + counterEvent.damage;
const hpAfter = Math.max(0, target.currentHP);
const hpAfter = Math.max(0, target.currentHP);
if (targetObj.hpText) targetObj.hpText.setText(`${hpBefore}`);
if (target.currentHP > 0) this.sound.play('sfx_damage', { volume: 0.8 });
targetObj.animateHPLoss(counterEvent.damage, null, hpBefore, hpAfter);
}
}
@ -521,6 +530,7 @@ export class BattleScene extends Phaser.Scene {
const targetObj = _lookup(mortarFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
this.sound.play('sfx_mortar', { volume: 0.8 });
// Immediately correct the target's HP display to the pre-mortar value.
// The early commander refresh (and any prior render) may have already set it to
@ -545,7 +555,7 @@ export class BattleScene extends Phaser.Scene {
// Brief pause so the rotated mortar is visible on the card before launching
this.time.delayedCall(450, () => {
const startX = sourceObj.x, startY = sourceObj.y;
const endX = targetObj.x, endY = targetObj.y;
const endX = targetObj.x, endY = targetObj.y;
const ARC_HEIGHT = -180;
const progress = { t: 0 };
@ -597,6 +607,7 @@ export class BattleScene extends Phaser.Scene {
};
if (mortarFire.damage > 0) {
if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 });
targetObj.animateHPLoss(mortarFire.damage, afterImpact, mortarFire.hpBefore, hpAfter);
} else {
afterImpact();
@ -617,6 +628,7 @@ export class BattleScene extends Phaser.Scene {
const targetObj = _lookup(strikeFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
this.sound.play('sfx_strike', { volume: 0.8 });
// Immediately correct the target's HP display to the pre-strike value.
// Commanders are refreshed to the final HP before _onPreAttackStep runs;
@ -641,7 +653,7 @@ export class BattleScene extends Phaser.Scene {
// Brief pause so the aimed missile is visible before launch
this.time.delayedCall(300, () => {
const startX = sourceObj.x, startY = sourceObj.y;
const endX = targetObj.x, endY = targetObj.y;
const endX = targetObj.x, endY = targetObj.y;
const ARC_HEIGHT = -70; // missiles fly flatter than mortar shells
const progress = { t: 0 };
@ -690,6 +702,7 @@ export class BattleScene extends Phaser.Scene {
};
if (strikeFire.damage > 0) {
if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 });
targetObj.animateHPLoss(strikeFire.damage, afterImpact, strikeFire.hpBefore, hpAfter);
} else {
afterImpact();
@ -708,6 +721,7 @@ export class BattleScene extends Phaser.Scene {
const targetObj = _lookup(pierceFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene || pierceFire.amount <= 0) { onComplete(); return; }
this.sound.play('sfx_pierce', { volume: 0.8 });
this.statusText.setText(`${attacker.name} pierces ${pierceFire.target.name}'s armor!`);
@ -892,6 +906,7 @@ export class BattleScene extends Phaser.Scene {
'attacks'
).setDisplaySize(480, 480).setDepth(22);
attackSprite.play('attack_anim');
this.sound.play('sfx_attack', { volume: 0.7 });
attackSprite.once('animationcomplete', () => {
if (attackSprite.scene) attackSprite.destroy();
this._onAttackReconcile(event, berserkEvent);
@ -985,8 +1000,8 @@ export class BattleScene extends Phaser.Scene {
// Called once per preBattle step. Handles buff animations, siege fires, then protect fires.
_onPreBattleStep(event, onComplete) {
const hasBuffs = event.buffs?.length > 0;
const hasSiege = event.siegeFires?.length > 0;
const hasBuffs = event.buffs?.length > 0;
const hasSiege = event.siegeFires?.length > 0;
const hasProtect = event.protectFires?.length > 0;
if (hasBuffs || hasSiege || hasProtect) {
@ -1035,6 +1050,7 @@ export class BattleScene extends Phaser.Scene {
const targetObj = _lookup(buff.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
this.sound.play('sfx_rally', { volume: 0.8 });
this.statusText.setText(`${buff.source.name} rallies ${buff.target.name}! +${buff.amount} ATK`);
// Base scale for 160px display from 460px source frame
@ -1070,8 +1086,8 @@ export class BattleScene extends Phaser.Scene {
// Arc flight — tween a progress value and compute position + scale each frame
const startX = sourceObj.x;
const startY = sourceObj.y;
const endX = targetObj.x;
const endY = targetObj.y;
const endX = targetObj.x;
const endY = targetObj.y;
const ARC_HEIGHT = -200; // px upward at the midpoint
const progress = { t: 0 };
@ -1145,10 +1161,12 @@ export class BattleScene extends Phaser.Scene {
if (!launcherSprite.scene) { onComplete(); return; }
const startX = sourceObj.x, startY = sourceObj.y;
const endX = targetObj.x, endY = targetObj.y;
const endX = targetObj.x, endY = targetObj.y;
const ARC_HEIGHT = -160;
const progress = { t: 0 };
this.sound.play('sfx_siege', { volume: 0.8 });
// Siege missile sprite (14) — aimed at commander, points up by default
const missileSprite = this.add.sprite(startX, startY, 'attacks', 14)
.setScale(BASE_SCALE)
@ -1198,6 +1216,7 @@ export class BattleScene extends Phaser.Scene {
};
if (siegeFire.damage > 0) {
if (hpAfter > 0) this.sound.play('sfx_damage', { volume: 0.8 });
targetObj.animateHPLoss(siegeFire.damage, afterImpact, siegeFire.hpBefore, hpAfter);
} else {
afterImpact();
@ -1215,21 +1234,22 @@ export class BattleScene extends Phaser.Scene {
const sourceObj = _lookup(protectFire.source.instanceId);
if (!sourceObj?.scene) { onComplete(); return; }
this.sound.play('sfx_protect', { volume: 0.8 });
this.statusText.setText(`${protectFire.source.name} raises shields!`);
const BASE_SCALE = 160 / 460;
const NEIGHBOR_SCALE = BASE_SCALE * (2 / 3);
const RISE_DURATION = 600;
const FLY_DURATION = 500;
const SETTLE_DELAY = 650; // ms before ARM gain animations start
const FLY_DURATION = 500;
const SETTLE_DELAY = 650; // ms before ARM gain animations start
// Resolve the target CardObjects and separate self from neighbors
const self = protectFire.targets.find(t => t.laneOffset === 0);
const left = protectFire.targets.find(t => t.laneOffset === -1);
const right = protectFire.targets.find(t => t.laneOffset === 1);
const self = protectFire.targets.find(t => t.laneOffset === 0);
const left = protectFire.targets.find(t => t.laneOffset === -1);
const right = protectFire.targets.find(t => t.laneOffset === 1);
const selfObj = self ? _lookup(self.card.instanceId) : null;
const leftObj = left ? _lookup(left.card.instanceId) : null;
const selfObj = self ? _lookup(self.card.instanceId) : null;
const leftObj = left ? _lookup(left.card.instanceId) : null;
const rightObj = right ? _lookup(right.card.instanceId) : null;
const sprites = [];
@ -1251,7 +1271,7 @@ export class BattleScene extends Phaser.Scene {
// 2/3-size shields for each neighbor — fly from source to neighbor card
for (const { neighbor, neighborObj } of [
{ neighbor: left, neighborObj: leftObj },
{ neighbor: left, neighborObj: leftObj },
{ neighbor: right, neighborObj: rightObj }
]) {
if (!neighbor || !neighborObj?.scene) continue;
@ -1315,7 +1335,7 @@ export class BattleScene extends Phaser.Scene {
if (event.debuffs?.length > 0) {
for (const debuff of event.debuffs) {
const obj = this.cardObjects.get(debuff.target.instanceId)
?? this.commanderObjects?.get(debuff.target.instanceId);
?? this.commanderObjects?.get(debuff.target.instanceId);
obj?.refresh();
}
}
@ -1330,6 +1350,7 @@ export class BattleScene extends Phaser.Scene {
// HP loss animation on defender; on completion, trigger death if HP reached zero
if (defenderObj?.scene && event.damage > 0) {
if (event.defender.currentHP > 0) this.sound.play('sfx_damage', { volume: 0.8 });
defenderObj.animateHPLoss(event.damage, () => {
if (event.defender.currentHP <= 0 && !defenderObj.isCommander) {
this._playCardDeath(defenderObj, event.defender);

View File

@ -16,6 +16,22 @@ export class BootScene extends Phaser.Scene {
this.load.json('missions', 'data/missions.json');
this.load.json('skills', 'data/skills.json');
this.load.audio('sfx_attack', 'assets/audio/fx/attack.mp3');
this.load.audio('sfx_damage', 'assets/audio/fx/damage.mp3');
this.load.audio('sfx_mortar', 'assets/audio/fx/mortar.mp3');
this.load.audio('sfx_strike', 'assets/audio/fx/strike.mp3');
this.load.audio('sfx_pierce', 'assets/audio/fx/pierce.mp3');
this.load.audio('sfx_protect', 'assets/audio/fx/protect.mp3');
this.load.audio('sfx_legendary_play', 'assets/audio/fx/legendary-play.mp3');
this.load.audio('sfx_destroy', 'assets/audio/fx/destroy.mp3');
this.load.audio('sfx_rally', 'assets/audio/fx/rally.mp3');
this.load.audio('sfx_siege', 'assets/audio/fx/siege.mp3');
this.load.audio('sfx_counter', 'assets/audio/fx/counter.mp3');
this.load.audio('sfx_menu_select', 'assets/audio/fx/menu_select.mp3');
this.load.audio('sfx_menu_hover', 'assets/audio/fx/menu_hover.mp3');
this.load.audio('music_main_menu', 'assets/audio/music/main_menu.mp3');
// Once cards.json finishes, queue card art images and resume loading
this.load.once('filecomplete-json-cards', (_key, _type, data) => {
data.forEach(card => {
@ -67,6 +83,11 @@ export class BootScene extends Phaser.Scene {
this.registry.set('missions', missionsData);
this.registry.set('save', save);
// Start main menu music looping; store reference so other scenes can stop/resume it
const menuMusic = this.sound.add('music_main_menu', { loop: true, volume: 0.5 });
menuMusic.play();
this.registry.set('music_main_menu', menuMusic);
this.scene.start('MainMenuScene');
}
}

View File

@ -5,6 +5,10 @@ export class MainMenuScene extends Phaser.Scene {
const save = this.registry.get('save');
const { width, height } = this.scale;
// Resume main menu music if it was stopped by a battle
const menuMusic = this.registry.get('music_main_menu');
if (menuMusic && !menuMusic.isPlaying) menuMusic.play();
// Background
this.add.rectangle(width / 2, height / 2, width, height, 0x0d1b2a);
@ -65,9 +69,9 @@ export class MainMenuScene extends Phaser.Scene {
fontSize: '28px', color: '#ffffff'
}).setOrigin(0.5);
bg.on('pointerover', () => bg.setFillStyle(0x2a5a8c));
bg.on('pointerover', () => { this.sound.play('sfx_menu_hover', { volume: 0.5 }); bg.setFillStyle(0x2a5a8c); });
bg.on('pointerout', () => bg.setFillStyle(0x1a3a5c));
bg.on('pointerdown', callback);
bg.on('pointerdown', () => { this.sound.play('sfx_menu_select', { volume: 0.7 }); callback(); });
return { bg, text };
}
@ -83,9 +87,10 @@ export class MainMenuScene extends Phaser.Scene {
fontSize: '20px', color: '#88cc88'
}).setOrigin(0.5);
bg.on('pointerover', () => bg.setFillStyle(0x2a3f2a));
bg.on('pointerover', () => { this.sound.play('sfx_menu_hover', { volume: 0.5 }); bg.setFillStyle(0x2a3f2a); });
bg.on('pointerout', () => bg.setFillStyle(0x1a2a1a));
bg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
if (this.scale.isFullscreen) {
this.scale.stopFullscreen();
} else {