feat(advance-wars): add contextual battle and movement sound effects

- Assign battle sounds to each unit type (gunfire, tank, cannon, missile, etc.)
  and play the appropriate sound on attack/counterattack in battle animations
- Add per-unit-type movement sounds (march for infantry/mech, engine-fast,
  engine-heavy) that loop during move animations and stop cleanly on completion
- Play jump sound during capture animation hops and activation sound on capture
- Play explosion sound when units are defeated in combat or crash
- Lighten unit tint relative to buildings so units remain visually distinct
  when standing on captured buildings
- Add new audio assets: battle sounds, march, 8bit-jump, and update battle-tank
This commit is contained in:
Brian Fertig 2026-07-19 17:44:18 -06:00
parent 427aacae6f
commit c0ad354458
10 changed files with 80 additions and 8 deletions

BIN
assets/fx/8bit-jump.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/fx/march.mp3 Normal file

Binary file not shown.

View File

@ -317,6 +317,7 @@
"units": [ "units": [
{ {
"id": "infantry", "id": "infantry",
"battleSound": "gunfire-modern",
"name": "Infantry", "name": "Infantry",
"abbr": "IN", "abbr": "IN",
"cost": 1000, "cost": 1000,
@ -353,6 +354,7 @@
}, },
{ {
"id": "recon", "id": "recon",
"battleSound": "gunfire-modern",
"name": "Recon", "name": "Recon",
"abbr": "RC", "abbr": "RC",
"cost": 4000, "cost": 4000,
@ -370,6 +372,7 @@
}, },
{ {
"id": "tank", "id": "tank",
"battleSound": "tank",
"name": "Tank", "name": "Tank",
"abbr": "TK", "abbr": "TK",
"cost": 7000, "cost": 7000,
@ -387,6 +390,7 @@
}, },
{ {
"id": "mdtank", "id": "mdtank",
"battleSound": "cannon",
"name": "Md Tank", "name": "Md Tank",
"abbr": "MD", "abbr": "MD",
"cost": 16000, "cost": 16000,
@ -404,6 +408,7 @@
}, },
{ {
"id": "apc", "id": "apc",
"battleSound": "gunfire-modern",
"name": "APC", "name": "APC",
"abbr": "AP", "abbr": "AP",
"cost": 5000, "cost": 5000,
@ -426,6 +431,7 @@
}, },
{ {
"id": "artillery", "id": "artillery",
"battleSound": "cannon",
"name": "Artillery", "name": "Artillery",
"abbr": "AR", "abbr": "AR",
"cost": 6000, "cost": 6000,
@ -444,6 +450,7 @@
}, },
{ {
"id": "rockets", "id": "rockets",
"battleSound": "missle",
"name": "Rockets", "name": "Rockets",
"abbr": "RK", "abbr": "RK",
"cost": 15000, "cost": 15000,
@ -462,6 +469,7 @@
}, },
{ {
"id": "antiair", "id": "antiair",
"battleSound": "gunfire-modern",
"name": "Anti-Air", "name": "Anti-Air",
"abbr": "AA", "abbr": "AA",
"cost": 8000, "cost": 8000,
@ -479,6 +487,7 @@
}, },
{ {
"id": "missiles", "id": "missiles",
"battleSound": "missle",
"name": "Missiles", "name": "Missiles",
"abbr": "MS", "abbr": "MS",
"cost": 12000, "cost": 12000,
@ -497,6 +506,7 @@
}, },
{ {
"id": "fighter", "id": "fighter",
"battleSound": "gunfire-modern",
"name": "Fighter", "name": "Fighter",
"abbr": "FT", "abbr": "FT",
"cost": 20000, "cost": 20000,
@ -515,6 +525,7 @@
}, },
{ {
"id": "bomber", "id": "bomber",
"battleSound": "cannon",
"name": "Bomber", "name": "Bomber",
"abbr": "BM", "abbr": "BM",
"cost": 22000, "cost": 22000,
@ -533,6 +544,7 @@
}, },
{ {
"id": "bcopter", "id": "bcopter",
"battleSound": "missle",
"name": "B-Copter", "name": "B-Copter",
"abbr": "BC", "abbr": "BC",
"cost": 9000, "cost": 9000,
@ -551,6 +563,7 @@
}, },
{ {
"id": "tcopter", "id": "tcopter",
"battleSound": "helicopter",
"name": "T-Copter", "name": "T-Copter",
"abbr": "TC", "abbr": "TC",
"cost": 5000, "cost": 5000,
@ -573,6 +586,7 @@
}, },
{ {
"id": "battleship", "id": "battleship",
"battleSound": "cannon",
"name": "Battleship", "name": "Battleship",
"abbr": "BS", "abbr": "BS",
"cost": 28000, "cost": 28000,
@ -592,6 +606,7 @@
}, },
{ {
"id": "cruiser", "id": "cruiser",
"battleSound": "tank",
"name": "Cruiser", "name": "Cruiser",
"abbr": "CR", "abbr": "CR",
"cost": 18000, "cost": 18000,
@ -617,6 +632,7 @@
}, },
{ {
"id": "lander", "id": "lander",
"battleSound": "missle",
"name": "Lander", "name": "Lander",
"abbr": "LD", "abbr": "LD",
"cost": 12000, "cost": 12000,
@ -636,6 +652,7 @@
}, },
{ {
"id": "sub", "id": "sub",
"battleSound": "submarine",
"name": "Sub", "name": "Sub",
"abbr": "SB", "abbr": "SB",
"cost": 20000, "cost": 20000,

View File

@ -208,6 +208,12 @@ export const MANIFEST = {
{ type: 'json', key: 'advancewars-campaign', path: 'data/advancewars-campaign.json' }, { type: 'json', key: 'advancewars-campaign', path: 'data/advancewars-campaign.json' },
(scene) => sheetsFrom(scene, 'advancewars-artwork', (scene) => sheetsFrom(scene, 'advancewars-artwork',
['terrainSheet', 'buildingSheet', 'unitSheet', 'uiSheet', 'battleBgSheet']), ['terrainSheet', 'buildingSheet', 'unitSheet', 'uiSheet', 'battleBgSheet']),
// Battle sounds keyed by each unit's `battleSound` in advancewars-rules.json
// (played on attack by playBattleAnim in AdvanceWarsBattleAnim.js).
...['gunfire-modern', 'tank', 'cannon', 'helicopter', 'missle', 'submarine']
.map((n) => ({ type: 'audio', key: `sfx-battle-${n}`, path: `assets/fx/battle-${n}.mp3` })),
// Foot-unit march (infantry/mech), played on move in AdvanceWarsMapView.js.
{ type: 'audio', key: 'sfx-march', path: 'assets/fx/march.mp3' },
(scene) => musicFrom(scene, 'nintendo-music'), (scene) => musicFrom(scene, 'nintendo-music'),
], ],
coloradodefense: [ coloradodefense: [

View File

@ -4,8 +4,9 @@
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import * as Logic from './AdvanceWarsLogic.js'; import * as Logic from './AdvanceWarsLogic.js';
import { armyColorInt } from './AdvanceWarsMapView.js'; import { unitColorInt } from './AdvanceWarsMapView.js';
import { FONT } from './AdvanceWarsUI.js'; import { FONT } from './AdvanceWarsUI.js';
import { playSound } from '../../ui/Sounds.js';
const W = 760; const W = 760;
const H = 380; const H = 380;
@ -220,7 +221,7 @@ export function playBattleAnim(scene, rules, state, texKey, battles, onDone) {
const img = scene.add.image(cx + side * W / 4, cy + H / 2 - 78, texKey, spec.frame) const img = scene.add.image(cx + side * W / 4, cy + H / 2 - 78, texKey, spec.frame)
.setScale(3.4).setOrigin(0.5, 1).setDepth(51) .setScale(3.4).setOrigin(0.5, 1).setDepth(51)
.setFlipX(side > 0) .setFlipX(side > 0)
.setTint(armyColorInt(rules, info.army)); .setTint(unitColorInt(rules, info.army));
objs.push(img); objs.push(img);
const label = scene.add.text(cx + side * W / 4, cy - H / 2 + 26, const label = scene.add.text(cx + side * W / 4, cy - H / 2 + 26,
spec.name, { fontFamily: FONT, fontSize: '26px', color: '#ffffff' }) spec.name, { fontFamily: FONT, fontSize: '26px', color: '#ffffff' })
@ -277,6 +278,10 @@ export function playBattleAnim(scene, rules, state, texKey, battles, onDone) {
const victim = fromLeft ? right : left; const victim = fromLeft ? right : left;
timers.push(scene.time.delayedCall(delay, () => { timers.push(scene.time.delayedCall(delay, () => {
if (finished) return; if (finished) return;
// keyed by the shooter for THIS volley, not the initial attacker —
// a counterattack is fired by the defender's own unit type.
const battleSound = rules.unitById[b.attacker.type]?.battleSound;
if (battleSound) playSound(scene, `sfx-battle-${battleSound}`);
scene.tweens.add({ scene.tweens.add({
targets: shooter.img, x: shooter.img.x + (fromLeft ? 40 : -40), targets: shooter.img, x: shooter.img.x + (fromLeft ? 40 : -40),
duration: 150, yoyo: true, duration: 150, yoyo: true,

View File

@ -6,9 +6,10 @@
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import * as Logic from './AdvanceWarsLogic.js'; import * as Logic from './AdvanceWarsLogic.js';
import { armyColorInt } from './AdvanceWarsMapView.js'; import { armyColorInt, unitColorInt } from './AdvanceWarsMapView.js';
import { FONT } from './AdvanceWarsUI.js'; import { FONT } from './AdvanceWarsUI.js';
import { ensureBattleBgSheet, groundBgFrame } from './AdvanceWarsBattleAnim.js'; import { ensureBattleBgSheet, groundBgFrame } from './AdvanceWarsBattleAnim.js';
import { playSound, SFX } from '../../ui/Sounds.js';
const W = 380; const W = 380;
const H = 500; const H = 500;
@ -69,7 +70,7 @@ export function playCaptureAnim(scene, rules, view, capturing, captured, onDone)
const unitImg = scene.add.image(cx, groundY - building.displayHeight, view.tex.units, spec.frame) const unitImg = scene.add.image(cx, groundY - building.displayHeight, view.tex.units, spec.frame)
.setOrigin(0.5, 1).setScale(UNIT_SCALE).setDepth(52) .setOrigin(0.5, 1).setScale(UNIT_SCALE).setDepth(52)
.setFlipX(unit.army !== 0) .setFlipX(unit.army !== 0)
.setTint(armyColorInt(rules, unit.army)); .setTint(unitColorInt(rules, unit.army));
objs.push(unitImg); objs.push(unitImg);
// large capture-points counter, floating to the unit's left, always // large capture-points counter, floating to the unit's left, always
@ -105,6 +106,7 @@ export function playCaptureAnim(scene, rules, view, capturing, captured, onDone)
function phase3raise() { function phase3raise() {
if (finished) return; if (finished) return;
building.setTint(armyColorInt(rules, captured.army)); building.setTint(armyColorInt(rules, captured.army));
playSound(scene, SFX.EIGHTBIT_MOVE);
scene.tweens.add({ scene.tweens.add({
targets: building, scaleY: BUILDING_SCALE, duration: 500, ease: 'Sine.easeOut', targets: building, scaleY: BUILDING_SCALE, duration: 500, ease: 'Sine.easeOut',
onUpdate: () => { unitImg.y = restY(); trackUnit(); pointsText.setText(String(currentPoints())); }, onUpdate: () => { unitImg.y = restY(); trackUnit(); pointsText.setText(String(currentPoints())); },
@ -127,9 +129,14 @@ export function playCaptureAnim(scene, rules, view, capturing, captured, onDone)
}); });
} }
// 1.5s: two hops in place atop the (pre-squish) building height. // 1.5s: two hops in place atop the (pre-squish) building height. A jump
// sound fires as each hop leaves the ground — once on start, once again
// when the repeat kicks off the second hop.
const jumpSound = () => playSound(scene, SFX.EIGHTBIT_JUMP);
scene.tweens.add({ scene.tweens.add({
targets: unitImg, y: restY() - HOP, duration: 375, yoyo: true, repeat: 1, ease: 'Sine.easeOut', targets: unitImg, y: restY() - HOP, duration: 375, yoyo: true, repeat: 1, ease: 'Sine.easeOut',
onStart: jumpSound,
onRepeat: jumpSound,
onUpdate: trackUnit, onUpdate: trackUnit,
onComplete: phase2squish, onComplete: phase2squish,
}); });

View File

@ -11,6 +11,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import * as Logic from './AdvanceWarsLogic.js'; import * as Logic from './AdvanceWarsLogic.js';
import { playSound, SFX } from '../../ui/Sounds.js';
const CELL = 48; const CELL = 48;
const TALL = 64; // building/unit cells: 48×48 footprint + 16px headroom const TALL = 64; // building/unit cells: 48×48 footprint + 16px headroom
@ -27,6 +28,15 @@ const UNIT_DEPTH_BIAS = 0.5;
// building rather than floating disconnected from it. // building rather than floating disconnected from it.
const PROP_NUMBER_OVERLAP = 5; const PROP_NUMBER_OVERLAP = 5;
// Per-unit-type movement sound, looped for however long the move animation
// takes and stopped the instant it ends (see animateMove).
const MOVE_SOUND_BY_TYPE = {
infantry: 'sfx-march', mech: 'sfx-march',
tank: 'sfx-engine-heavy', mdtank: 'sfx-engine-heavy', artillery: 'sfx-engine-heavy',
rockets: 'sfx-engine-heavy', missiles: 'sfx-engine-heavy', bomber: 'sfx-engine-heavy',
recon: 'sfx-engine-fast', apc: 'sfx-engine-fast', antiair: 'sfx-engine-fast', fighter: 'sfx-engine-fast',
};
// Painted drop-in sheets and the layout their procedural stand-ins mirror. // Painted drop-in sheets and the layout their procedural stand-ins mirror.
export const SHEETS = { export const SHEETS = {
terrain: { key: 'advancewars-terrain', cellH: CELL, cols: 8, frames: 8 }, terrain: { key: 'advancewars-terrain', cellH: CELL, cols: 8, frames: 8 },
@ -44,6 +54,12 @@ export function armyColorInt(rules, army) {
return parseInt(hex.slice(1), 16); return parseInt(hex.slice(1), 16);
} }
// Units render a little lighter than buildings of the same army, so the two
// stay visually distinguishable when a unit is standing on a building.
export function unitColorInt(rules, army) {
return lighten(armyColorInt(rules, army), 0.48);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Procedural stand-in sheets // Procedural stand-in sheets
@ -461,7 +477,7 @@ export class AdvanceWarsMapView {
v.c.setPosition(this.px(u.x), this.oy + (u.y + 1) * this.tile).setDepth(this.depths.actors + u.y + UNIT_DEPTH_BIAS); v.c.setPosition(this.px(u.x), this.oy + (u.y + 1) * this.tile).setDepth(this.depths.actors + u.y + UNIT_DEPTH_BIAS);
v.sprite.setFrame(rules.unitById[u.type].frame + this.animStep); v.sprite.setFrame(rules.unitById[u.type].frame + this.animStep);
v.sprite.setFlipX(u.army !== 0); v.sprite.setFlipX(u.army !== 0);
const base = armyColorInt(rules, u.army); const base = unitColorInt(rules, u.army);
v.sprite.setTint(u.moved && u.army === this.state.turn ? darken(base, 0.5) : base); v.sprite.setTint(u.moved && u.army === this.state.turn ? darken(base, 0.5) : base);
v.sprite.setAlpha(u.dived ? 0.5 : 1); v.sprite.setAlpha(u.dived ? 0.5 : 1);
const hpd = Logic.hpDisplay(u); const hpd = Logic.hpDisplay(u);
@ -577,10 +593,12 @@ export class AdvanceWarsMapView {
this.setUnitVisibility(spotted); this.setUnitVisibility(spotted);
} }
// quick explosion puff at a tile // quick explosion puff at a tile — plays whenever a unit is defeated
// (destroyed in combat or crashed), regardless of the battleAnims toggle.
boom(x, y, onDone) { boom(x, y, onDone) {
const img = this.scene.add.image(this.px(x), this.py(y), this.tex.ui, UI_FRAMES.explosion1) const img = this.scene.add.image(this.px(x), this.py(y), this.tex.ui, UI_FRAMES.explosion1)
.setScale(this.spriteScaleFor()).setDepth(this.depths.cursor); .setScale(this.spriteScaleFor()).setDepth(this.depths.cursor);
playSound(this.scene, SFX.BATTLESHIP_HIT);
this.scene.time.delayedCall(200, () => img.setFrame(UI_FRAMES.explosion2)); this.scene.time.delayedCall(200, () => img.setFrame(UI_FRAMES.explosion2));
this.scene.time.delayedCall(450, () => { img.destroy(); onDone?.(); }); this.scene.time.delayedCall(450, () => { img.destroy(); onDone?.(); });
} }
@ -589,10 +607,27 @@ export class AdvanceWarsMapView {
animateMove(unitId, path, onDone) { animateMove(unitId, path, onDone) {
const v = this.unitViews.get(unitId); const v = this.unitViews.get(unitId);
if (!v || !path?.length) { onDone?.(); return; } if (!v || !path?.length) { onDone?.(); return; }
const unit = Logic.unitById(this.state, unitId);
// loops for however long the move takes (covers paths longer than one
// clip) and is stopped the instant the path finishes — never allowed to
// ring on past the move or get cut short mid-loop.
let moveSound = null;
const moveSoundKey = unit && MOVE_SOUND_BY_TYPE[unit.type];
if (moveSoundKey) {
try {
moveSound = this.scene.sound.add(moveSoundKey, { loop: true });
moveSound.play();
} catch (_) { moveSound = null; }
}
const points = path.map((s) => ({ x: this.px(s.x), y: this.oy + (s.y + 1) * this.tile, row: s.y })); const points = path.map((s) => ({ x: this.px(s.x), y: this.oy + (s.y + 1) * this.tile, row: s.y }));
let i = 0; let i = 0;
const step = () => { const step = () => {
if (i >= points.length) { onDone?.(); return; } if (i >= points.length) {
moveSound?.stop();
moveSound?.destroy();
onDone?.();
return;
}
const p = points[i++]; const p = points[i++];
v.c.setDepth(this.depths.actors + p.row + UNIT_DEPTH_BIAS); v.c.setDepth(this.depths.actors + p.row + UNIT_DEPTH_BIAS);
this.scene.tweens.add({ this.scene.tweens.add({

View File

@ -137,6 +137,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-8bit-activate', 'assets/fx/8bit-activate.mp3'); this.load.audio('sfx-8bit-activate', 'assets/fx/8bit-activate.mp3');
this.load.audio('sfx-8bit-action', 'assets/fx/8bit-action.mp3'); this.load.audio('sfx-8bit-action', 'assets/fx/8bit-action.mp3');
this.load.audio('sfx-8bit-move', 'assets/fx/8bit-move.mp3'); this.load.audio('sfx-8bit-move', 'assets/fx/8bit-move.mp3');
this.load.audio('sfx-8bit-jump', 'assets/fx/8bit-jump.mp3');
this.load.audio('sfx-8bit-explode', 'assets/fx/8bit-explode.mp3'); this.load.audio('sfx-8bit-explode', 'assets/fx/8bit-explode.mp3');
this.load.audio('sfx-8bit-explode2', 'assets/fx/8bit-explode2.mp3'); this.load.audio('sfx-8bit-explode2', 'assets/fx/8bit-explode2.mp3');
this.load.audio('sfx-8bit-count', 'assets/fx/8bit-count.mp3'); this.load.audio('sfx-8bit-count', 'assets/fx/8bit-count.mp3');

View File

@ -44,6 +44,7 @@ export const SFX = {
EIGHTBIT_ACTIVATE: 'sfx-8bit-activate', EIGHTBIT_ACTIVATE: 'sfx-8bit-activate',
EIGHTBIT_ACTION: 'sfx-8bit-action', EIGHTBIT_ACTION: 'sfx-8bit-action',
EIGHTBIT_MOVE: 'sfx-8bit-move', EIGHTBIT_MOVE: 'sfx-8bit-move',
EIGHTBIT_JUMP: 'sfx-8bit-jump',
EIGHTBIT_CARD: 'sfx-card-deal-8bit', EIGHTBIT_CARD: 'sfx-card-deal-8bit',
EIGHTBIT_EXPLODE: 'sfx-8bit-explode', EIGHTBIT_EXPLODE: 'sfx-8bit-explode',
EIGHTBIT_EXPLODE_2: 'sfx-8bit-explode2', EIGHTBIT_EXPLODE_2: 'sfx-8bit-explode2',