Implement FX sound effects (#3394)

## Description:
Adds sound effects for approved events from the [sound asset
pack](https://drive.google.com/drive/folders/1KpGYJkmLxipy8XmTeyHf40XDC4P--Ck8?usp=sharing).
15 new sound effects triggered from `FxLayer`, `EventsDisplay`, and
`RadialMenu`. Sounds play even when visual FX are off, so disabling
explosions doesn't kill audio. Unapproved sounds are included as assets
but not wired up yet.

### SoundManager architecture

Reworked `SoundManager` per [maintainer
feedback](https://github.com/openfrontio/OpenFrontIO/issues/1893#issuecomment-4184649434)
and [follow-up
review](https://github.com/openfrontio/OpenFrontIO/pull/3394):

- No more singleton. `SoundManager` is instantiated in
`createClientGame()` with `EventBus` and `UserSettings`
- Layers emit events (`PlaySoundEffectEvent`,
`SetBackgroundMusicVolumeEvent`, `SetSoundEffectsVolumeEvent`) via
EventBus instead of holding a `SoundManager` reference
- `SoundManager` subscribes to these events in its constructor
- `SoundEffect` is a type union (not an enum), per project convention
- All sound configuration (type, URL mapping, events) lives in
`Sounds.ts`
- Sound effects are lazy-loaded on first play
- Channel limit of 8 concurrent sounds. New sounds always play; when at
the limit, the oldest active sound gets stopped
- `SoundManager` bootstraps volume from `UserSettings` in its
constructor
- All Howler calls are wrapped in try/catch with error logging, so sound
failures never crash the game
- `dispose()` method unsubscribes from EventBus and unloads all Howl
instances on game shutdown
- Sound code stays entirely in `src/client/`, nothing in `core/` touches
it

## Sound approval status (per
[spreadsheet](https://drive.google.com/drive/folders/1KpGYJkmLxipy8XmTeyHf40XDC4P--Ck8?usp=sharing))

### Approved, wired up in this PR

| Event | Sound file | Trigger location |
|-------|-----------|-----------------|
| Message sent/received | `message.mp3` | EventsDisplay |
| Menu open/select | `click.mp3` | RadialMenu |
| Atom bomb launch | `atom-launch.mp3` | FxLayer (unit created) |
| Atom bomb / MIRV hit | `atom-hit.mp3` | FxLayer (reached target) |
| Hydrogen launch | `hydrogen-launch.mp3` | FxLayer (unit created) |
| Hydrogen hit | `hydrogen-hit.mp3` | FxLayer (reached target) |
| MIRV launch | `mirv-launch.mp3` | FxLayer (unit created) |
| Alliance suggested | `alliance-suggested.mp3` | EventsDisplay |
| Alliance broken | `alliance-broken.mp3` | EventsDisplay |
| Port built | `build-port.mp3` | FxLayer (construction complete) |
| City built | `build-city.mp3` | FxLayer (construction complete) |
| Defense post built | `build-defense-post.mp3` | FxLayer (construction
complete) |
| Warship built | `build-warship.mp3` | FxLayer (unit created) |
| SAM built | `sam-built.mp3` | FxLayer (construction complete) |

### Waiting for approval, sound files included but NOT wired up

| Event | Sound file | Notes |
|-------|-----------|-------|
| Missile Silo built | `silo-built.mp3` | Waiting for Approval |
| SAM shoot | `sam-shoot.mp3` | Waiting for Approval |
| SAM hit | - | Waiting for Approval, no sound file assigned |
| Warship sunk | - | Waiting for Approval, no sound file assigned |
| Warship shoot | - | Waiting for Approval, no sound file assigned |

### Not done, no sound files exist yet

| Event | Notes |
|-------|-------|
| Looted player | "Not sure if needed" |
| Invaded | - |
| Ship invasion incoming | - |
| Ship sent | - |
| Menu theme song | - |
| Ambience | "Not sure if needed" |

## Test plan
- [x] Start a private game and launch atom/hydrogen/MIRV nukes, verify
launch and detonation sounds
- [x] Build structures (city, port, defense post, SAM), verify build
completion sounds
- [x] Build a warship, verify warship built sound
- [x] Receive an alliance request, verify alliance suggested sound
- [x] Break an alliance, verify alliance broken sound
- [ ] Receive a chat message, verify message sound
- [x] Open the radial menu and click items, verify click sound
- [x] Disable visual FX in settings, verify sounds still play
- [x] Adjust SFX volume slider, verify it affects all new sounds
- [x] Verify no audio issues with rapid/overlapping events
- [x] Verify SoundManager responds to EventBus events and unsubscribes
cleanly on dispose
- [x] Verify SoundManager swallows Howler errors without crashing the
game
- [x] Verify channel limit of 8, oldest sound stopped when at cap

## Checklist
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

Resolves #1893

## Please put your Discord username so you can be contacted if a bug or
regression is found:
cool_clarky
This commit is contained in:
Cameron Clark
2026-04-07 14:01:23 +10:00
committed by GitHub
parent 341f344ce5
commit 18da7134c8
27 changed files with 639 additions and 132 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+31 -19
View File
@@ -50,7 +50,7 @@ import {
import { createCanvas } from "./Utils";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
import { GoToPlayerEvent } from "./graphics/layers/Leaderboard";
import SoundManager from "./sound/SoundManager";
import { SoundManager } from "./sound/SoundManager";
export interface LobbyConfig {
serverConfig: ServerConfig;
@@ -202,8 +202,12 @@ export function joinLobby(
return false;
}
console.log("leaving game");
currentGameRunner = null;
transport.leaveGame();
if (currentGameRunner) {
currentGameRunner.stop();
currentGameRunner = null;
} else {
transport.leaveGame();
}
return true;
},
prestart: prestartPromise,
@@ -253,22 +257,29 @@ async function createClientGame(
);
const canvas = createCanvas();
const gameRenderer = createRenderer(canvas, gameView, eventBus);
const soundManager = new SoundManager(eventBus, userSettings);
try {
const gameRenderer = createRenderer(canvas, gameView, eventBus);
console.log(
`creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`,
);
console.log(
`creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`,
);
return new ClientGameRunner(
lobbyConfig,
clientID,
eventBus,
gameRenderer,
new InputHandler(gameRenderer.uiState, canvas, eventBus),
transport,
worker,
gameView,
);
return new ClientGameRunner(
lobbyConfig,
clientID,
eventBus,
gameRenderer,
new InputHandler(gameRenderer.uiState, canvas, eventBus),
transport,
worker,
gameView,
soundManager,
);
} catch (err) {
soundManager.dispose();
throw err;
}
}
export class ClientGameRunner {
@@ -294,6 +305,7 @@ export class ClientGameRunner {
private transport: Transport,
private worker: WorkerClient,
private gameView: GameView,
private soundManager: SoundManager,
) {
this.lastMessageTime = Date.now();
}
@@ -346,7 +358,7 @@ export class ClientGameRunner {
}
public start() {
SoundManager.playBackgroundMusic();
this.soundManager.playBackgroundMusic();
console.log("starting client game");
this.isActive = true;
@@ -524,7 +536,7 @@ export class ClientGameRunner {
}
public stop() {
SoundManager.stopBackgroundMusic();
this.soundManager.dispose();
if (!this.isActive) return;
this.isActive = false;
@@ -36,6 +36,7 @@ import { onlyImages } from "../../../core/Util";
import { renderNumber } from "../../Utils";
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { getMessageTypeClasses, translateText } from "../../Utils";
import { UIState } from "../UIState";
const allianceIcon = assetUrl("images/AllianceIconWhite.svg");
@@ -444,6 +445,7 @@ export class EventsDisplay extends LitElement implements Layer {
type: MessageType.CHAT,
unsafeDescription: false,
});
this.eventBus.emit(new PlaySoundEffectEvent("message"));
}
onAllianceRequestEvent(update: AllianceRequestUpdate) {
@@ -459,6 +461,9 @@ export class EventsDisplay extends LitElement implements Layer {
update.recipientID,
) as PlayerView;
if (!requestor.isAlliedWith(recipient)) {
this.eventBus.emit(new PlaySoundEffectEvent("alliance-suggested"));
}
this.addEvent({
description: translateText("events_display.request_alliance", {
name: requestor.displayName(),
@@ -554,6 +559,7 @@ export class EventsDisplay extends LitElement implements Layer {
if (betrayed.isDisconnected()) return; // Do not send the message if betraying a disconnected player
if (!betrayed.isTraitor() && traitor === myPlayer) {
this.eventBus.emit(new PlaySoundEffectEvent("alliance-broken"));
const malusPercent = Math.round(
(1 - this.game.config().traitorDefenseDebuff()) * 100,
);
@@ -580,6 +586,7 @@ export class EventsDisplay extends LitElement implements Layer {
focusID: update.betrayedID,
});
} else if (betrayed === myPlayer) {
this.eventBus.emit(new PlaySoundEffectEvent("alliance-broken"));
const buttons = [
{
text: translateText("events_display.focus"),
+105 -41
View File
@@ -4,7 +4,7 @@ import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { ConquestUpdate, GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import SoundManager, { SoundEffect } from "../../sound/SoundManager";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { conquestFxFactory } from "../fx/ConquestFx";
import { Fx, FxType } from "../fx/Fx";
@@ -26,6 +26,7 @@ export class FxLayer implements Layer {
private allFx: Fx[] = [];
private hasBufferedFrame = false;
private constructionState: Map<number, boolean> = new Map();
constructor(
private game: GameView,
@@ -39,10 +40,11 @@ export class FxLayer implements Layer {
return true;
}
private fxEnabled(): boolean {
return this.game.config().userSettings()?.fxLayer() ?? true;
}
tick() {
if (!this.game.config().userSettings()?.fxLayer()) {
return;
}
this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
@@ -59,6 +61,11 @@ export class FxLayer implements Layer {
}
onUnitEvent(unit: UnitView) {
// Detect unit creation (launches, warship built)
if (unit.isActive() && unit.createdAt() === this.game.ticks()) {
this.onUnitCreated(unit);
}
switch (unit.type()) {
case UnitType.AtomBomb: {
this.onNukeEvent(unit, 70);
@@ -91,9 +98,28 @@ export class FxLayer implements Layer {
}
}
onUnitCreated(unit: UnitView) {
switch (unit.type()) {
case UnitType.AtomBomb:
this.eventBus.emit(new PlaySoundEffectEvent("atom-launch"));
break;
case UnitType.HydrogenBomb:
this.eventBus.emit(new PlaySoundEffectEvent("hydrogen-launch"));
break;
case UnitType.MIRV:
this.eventBus.emit(new PlaySoundEffectEvent("mirv-launch"));
break;
case UnitType.Warship:
if (unit.owner() === this.game.myPlayer()) {
this.eventBus.emit(new PlaySoundEffectEvent("build-warship"));
}
break;
}
}
onShellEvent(unit: UnitView) {
if (!unit.isActive()) {
if (unit.reachedTarget()) {
if (unit.reachedTarget() && this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
@@ -109,7 +135,7 @@ export class FxLayer implements Layer {
onTrainEvent(unit: UnitView) {
if (!unit.isActive()) {
if (!unit.reachedTarget()) {
if (!unit.reachedTarget() && this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
@@ -124,6 +150,7 @@ export class FxLayer implements Layer {
}
onRailroadEvent(tile: TileRef) {
if (!this.fxEnabled()) return;
// No need for pseudorandom, this is fx
const chanceFx = Math.floor(Math.random() * 3);
if (chanceFx === 0) {
@@ -146,15 +173,17 @@ export class FxLayer implements Layer {
return;
}
SoundManager.playSoundEffect(SoundEffect.KaChing);
this.eventBus.emit(new PlaySoundEffectEvent("ka-ching"));
this.allFx.push(
conquestFxFactory(this.animatedSpriteLoader, conquest, this.game),
);
if (this.fxEnabled()) {
this.allFx.push(
conquestFxFactory(this.animatedSpriteLoader, conquest, this.game),
);
}
}
onWarshipEvent(unit: UnitView) {
if (!unit.isActive()) {
if (!unit.isActive() && this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const shipExplosion = new UnitExplosionFx(
@@ -179,15 +208,43 @@ export class FxLayer implements Layer {
onStructureEvent(unit: UnitView) {
if (!unit.isActive()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.BuildingExplosion,
);
this.allFx.push(explosion);
if (this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.BuildingExplosion,
);
this.allFx.push(explosion);
}
this.constructionState.delete(unit.id());
} else {
const wasUnderConstruction = this.constructionState.get(unit.id());
this.constructionState.set(unit.id(), unit.isUnderConstruction());
if (wasUnderConstruction && !unit.isUnderConstruction()) {
if (unit.owner() === this.game.myPlayer()) {
this.onStructureBuilt(unit);
}
}
}
}
onStructureBuilt(unit: UnitView) {
switch (unit.type()) {
case UnitType.City:
this.eventBus.emit(new PlaySoundEffectEvent("build-city"));
break;
case UnitType.Port:
this.eventBus.emit(new PlaySoundEffectEvent("build-port"));
break;
case UnitType.DefensePost:
this.eventBus.emit(new PlaySoundEffectEvent("build-defense-post"));
break;
case UnitType.SAMLauncher:
this.eventBus.emit(new PlaySoundEffectEvent("sam-built"));
break;
}
}
@@ -203,30 +260,37 @@ export class FxLayer implements Layer {
}
handleNukeExplosion(unit: UnitView, radius: number) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const nukeFx = nukeFxFactory(
this.animatedSpriteLoader,
x,
y,
radius,
this.game,
);
this.allFx = this.allFx.concat(nukeFx);
if (this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const nukeFx = nukeFxFactory(
this.animatedSpriteLoader,
x,
y,
radius,
this.game,
);
this.allFx = this.allFx.concat(nukeFx);
}
const sound =
unit.type() === UnitType.HydrogenBomb ? "hydrogen-hit" : "atom-hit";
this.eventBus.emit(new PlaySoundEffectEvent(sound));
}
handleSAMInterception(unit: UnitView) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.SAMExplosion,
);
this.allFx.push(explosion);
const shockwave = new ShockwaveFx(x, y, 800, 40);
this.allFx.push(shockwave);
if (this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.SAMExplosion,
);
this.allFx.push(explosion);
const shockwave = new ShockwaveFx(x, y, 800, 40);
this.allFx.push(shockwave);
}
}
async init() {
+2
View File
@@ -2,6 +2,7 @@ import * as d3 from "d3";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { CloseViewEvent } from "../../InputHandler";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { getSvgAspectRatio, translateText } from "../../Utils";
import { Layer } from "./Layer";
import {
@@ -506,6 +507,7 @@ export class RadialMenu implements Layer {
this.navigationInProgress
)
return;
this.eventBus.emit(new PlaySoundEffectEvent("click"));
if (
this.currentLevel > 0 &&
+6 -7
View File
@@ -7,7 +7,10 @@ import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler";
import { translateText } from "../../Utils";
import SoundManager from "../../sound/SoundManager";
import {
SetBackgroundMusicVolumeEvent,
SetSoundEffectsVolumeEvent,
} from "../../sound/Sounds";
import { Layer } from "./Layer";
const structureIcon = assetUrl("images/CityIconWhite.svg");
const cursorPriceIcon = assetUrl("images/CursorPriceIconWhite.svg");
@@ -52,10 +55,6 @@ export class SettingsModal extends LitElement implements Layer {
wasPausedWhenOpened = false;
init() {
SoundManager.setBackgroundMusicVolume(
this.userSettings.backgroundMusicVolume(),
);
SoundManager.setSoundEffectsVolume(this.userSettings.soundEffectsVolume());
this.eventBus.on(ShowSettingsModalEvent, (event) => {
this.isVisible = event.isVisible;
this.shouldPause = event.shouldPause;
@@ -183,14 +182,14 @@ export class SettingsModal extends LitElement implements Layer {
private onVolumeChange(event: Event) {
const volume = parseFloat((event.target as HTMLInputElement).value) / 100;
this.userSettings.setBackgroundMusicVolume(volume);
SoundManager.setBackgroundMusicVolume(volume);
this.eventBus.emit(new SetBackgroundMusicVolumeEvent(volume));
this.requestUpdate();
}
private onSoundEffectsVolumeChange(event: Event) {
const volume = parseFloat((event.target as HTMLInputElement).value) / 100;
this.userSettings.setSoundEffectsVolume(volume);
SoundManager.setSoundEffectsVolume(volume);
this.eventBus.emit(new SetSoundEffectsVolumeEvent(volume));
this.requestUpdate();
}
+136 -65
View File
@@ -2,63 +2,119 @@ import { Howl } from "howler";
import of4 from "../../../proprietary/sounds/music/of4.mp3";
import openfront from "../../../proprietary/sounds/music/openfront.mp3";
import war from "../../../proprietary/sounds/music/war.mp3";
import { assetUrl } from "../../core/AssetUrls";
const kaChingSound = assetUrl("sounds/effects/ka-ching.mp3");
import { EventBus } from "../../core/EventBus";
import { UserSettings } from "../../core/game/UserSettings";
import {
PlaySoundEffectEvent,
SetBackgroundMusicVolumeEvent,
SetSoundEffectsVolumeEvent,
SoundEffect,
soundEffectUrls,
} from "./Sounds";
export enum SoundEffect {
KaChing = "ka-ching",
}
export const MAX_CONCURRENT_SOUNDS = 8;
class SoundManager {
export class SoundManager {
private backgroundMusic: Howl[] = [];
private currentTrack: number = 0;
private soundEffects: Map<SoundEffect, Howl> = new Map();
private soundEffectsVolume: number = 1;
private backgroundMusicVolume: number = 0;
private activeSounds: { howl: Howl; id: number }[] = [];
private eventBus: EventBus;
private onPlaySoundEffect: (e: PlaySoundEffectEvent) => void;
private onSetBackgroundMusicVolume: (
e: SetBackgroundMusicVolumeEvent,
) => void;
private onSetSoundEffectsVolume: (e: SetSoundEffectsVolumeEvent) => void;
constructor() {
this.backgroundMusic = [
new Howl({
src: [of4],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
new Howl({
src: [openfront],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
new Howl({
src: [war],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
];
this.loadSoundEffect(SoundEffect.KaChing, kaChingSound);
constructor(eventBus: EventBus, userSettings: UserSettings) {
this.eventBus = eventBus;
this.safely("initialize background music", () => {
this.backgroundMusic = [
new Howl({
src: [of4],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
new Howl({
src: [openfront],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
new Howl({
src: [war],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
];
});
this.setBackgroundMusicVolume(userSettings.backgroundMusicVolume());
this.setSoundEffectsVolume(userSettings.soundEffectsVolume());
this.onPlaySoundEffect = (e) => this.playSoundEffect(e.effect);
this.onSetBackgroundMusicVolume = (e) =>
this.setBackgroundMusicVolume(e.volume);
this.onSetSoundEffectsVolume = (e) => this.setSoundEffectsVolume(e.volume);
eventBus.on(PlaySoundEffectEvent, this.onPlaySoundEffect);
eventBus.on(SetBackgroundMusicVolumeEvent, this.onSetBackgroundMusicVolume);
eventBus.on(SetSoundEffectsVolumeEvent, this.onSetSoundEffectsVolume);
}
public dispose(): void {
this.eventBus.off(PlaySoundEffectEvent, this.onPlaySoundEffect);
this.eventBus.off(
SetBackgroundMusicVolumeEvent,
this.onSetBackgroundMusicVolume,
);
this.eventBus.off(SetSoundEffectsVolumeEvent, this.onSetSoundEffectsVolume);
this.backgroundMusic.forEach((track) => {
this.safely("stop background track", () => track.stop());
this.safely("unload background track", () => track.unload());
});
this.soundEffects.forEach((sound) => {
this.safely("stop sound effect", () => sound.stop());
this.safely("unload sound effect", () => sound.unload());
});
this.soundEffects.clear();
this.activeSounds = [];
}
private safely(action: string, fn: () => void): void {
try {
fn();
} catch (err) {
console.error(`SoundManager: failed to ${action}`, err);
}
}
public playBackgroundMusic(): void {
if (
this.backgroundMusic.length > 0 &&
!this.backgroundMusic[this.currentTrack].playing()
) {
this.backgroundMusic[this.currentTrack].play();
}
this.safely("play background music", () => {
if (
this.backgroundMusic.length > 0 &&
!this.backgroundMusic[this.currentTrack].playing()
) {
this.backgroundMusic[this.currentTrack].play();
}
});
}
public stopBackgroundMusic(): void {
if (this.backgroundMusic.length > 0) {
this.backgroundMusic[this.currentTrack].stop();
}
this.safely("stop background music", () => {
if (this.backgroundMusic.length > 0) {
this.backgroundMusic[this.currentTrack].stop();
}
});
}
public setBackgroundMusicVolume(volume: number): void {
this.backgroundMusicVolume = Math.max(0, Math.min(1, volume));
this.backgroundMusic.forEach((track) => {
track.volume(this.backgroundMusicVolume);
this.safely("set background music volume", () => {
this.backgroundMusic.forEach((track) => {
track.volume(this.backgroundMusicVolume);
});
});
}
@@ -67,44 +123,59 @@ class SoundManager {
this.playBackgroundMusic();
}
public loadSoundEffect(name: SoundEffect, src: string): void {
if (!this.soundEffects.has(name)) {
const sound = new Howl({
src: [src],
volume: this.soundEffectsVolume,
});
private getOrLoadSoundEffect(name: SoundEffect): Howl | null {
let sound = this.soundEffects.get(name);
if (sound) return sound;
const src = soundEffectUrls.get(name);
if (!src) return null;
try {
sound = new Howl({ src: [src], volume: this.soundEffectsVolume });
this.soundEffects.set(name, sound);
return sound;
} catch (err) {
console.error(`SoundManager: failed to load sound ${name}`, err);
return null;
}
}
private removeActiveSoundById(id: number): void {
this.activeSounds = this.activeSounds.filter((s) => s.id !== id);
}
public playSoundEffect(name: SoundEffect): void {
const sound = this.soundEffects.get(name);
if (sound) {
sound.play();
}
this.safely(`play sound ${name}`, () => {
const howl = this.getOrLoadSoundEffect(name);
if (!howl) return;
if (this.activeSounds.length >= MAX_CONCURRENT_SOUNDS) {
const oldest = this.activeSounds[0];
oldest.howl.stop(oldest.id);
this.removeActiveSoundById(oldest.id);
}
const id = howl.play();
this.activeSounds.push({ howl, id });
howl.once("end", () => this.removeActiveSoundById(id), id);
howl.once("stop", () => this.removeActiveSoundById(id), id);
});
}
public setSoundEffectsVolume(volume: number): void {
this.soundEffectsVolume = Math.max(0, Math.min(1, volume));
this.soundEffects.forEach((sound) => {
sound.volume(this.soundEffectsVolume);
this.safely("set sound effects volume", () => {
this.soundEffects.forEach((sound) => {
sound.volume(this.soundEffectsVolume);
});
});
}
public stopSoundEffect(name: SoundEffect): void {
const sound = this.soundEffects.get(name);
if (sound) {
sound.stop();
}
}
public unloadSoundEffect(name: SoundEffect): void {
const sound = this.soundEffects.get(name);
if (sound) {
sound.unload();
this.soundEffects.delete(name);
}
this.safely(`stop sound ${name}`, () => {
const howl = this.soundEffects.get(name);
if (howl) {
howl.stop();
this.activeSounds = this.activeSounds.filter((s) => s.howl !== howl);
}
});
}
}
export default new SoundManager();
+49
View File
@@ -0,0 +1,49 @@
import { assetUrl } from "../../core/AssetUrls";
import { GameEvent } from "../../core/EventBus";
export type SoundEffect =
| "ka-ching"
| "atom-hit"
| "atom-launch"
| "hydrogen-hit"
| "hydrogen-launch"
| "mirv-launch"
| "alliance-suggested"
| "alliance-broken"
| "build-port"
| "build-city"
| "build-defense-post"
| "build-warship"
| "sam-built"
| "message"
| "click";
export const soundEffectUrls: ReadonlyMap<SoundEffect, string> = new Map([
["ka-ching", assetUrl("sounds/effects/ka-ching.mp3")],
["atom-hit", assetUrl("sounds/effects/atom-hit.mp3")],
["atom-launch", assetUrl("sounds/effects/atom-launch.mp3")],
["hydrogen-hit", assetUrl("sounds/effects/hydrogen-hit.mp3")],
["hydrogen-launch", assetUrl("sounds/effects/hydrogen-launch.mp3")],
["mirv-launch", assetUrl("sounds/effects/mirv-launch.mp3")],
["alliance-suggested", assetUrl("sounds/effects/alliance-suggested.mp3")],
["alliance-broken", assetUrl("sounds/effects/alliance-broken.mp3")],
["build-port", assetUrl("sounds/effects/build-port.mp3")],
["build-city", assetUrl("sounds/effects/build-city.mp3")],
["build-defense-post", assetUrl("sounds/effects/build-defense-post.mp3")],
["build-warship", assetUrl("sounds/effects/build-warship.mp3")],
["sam-built", assetUrl("sounds/effects/sam-built.mp3")],
["message", assetUrl("sounds/effects/message.mp3")],
["click", assetUrl("sounds/effects/click.mp3")],
]);
export class PlaySoundEffectEvent implements GameEvent {
constructor(public readonly effect: SoundEffect) {}
}
export class SetSoundEffectsVolumeEvent implements GameEvent {
constructor(public readonly volume: number) {}
}
export class SetBackgroundMusicVolumeEvent implements GameEvent {
constructor(public readonly volume: number) {}
}
+303
View File
@@ -0,0 +1,303 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock howler before importing SoundManager
const howlCtor = vi.fn();
const howlInstances: any[] = [];
let nextPlayId = 1;
vi.mock("howler", () => {
class MockHowl {
play = vi.fn(() => nextPlayId++);
stop = vi.fn((id?: number) => {
if (id !== undefined) {
this._fireEvent("stop", id);
}
});
volume = vi.fn();
playing = vi.fn().mockReturnValue(false);
unload = vi.fn();
once = vi.fn((event: string, callback: () => void, id?: number) => {
if (id !== undefined) {
if (!this._listeners.has(event)) {
this._listeners.set(event, new Map());
}
this._listeners.get(event)!.set(id, callback);
}
});
_listeners: Map<string, Map<number, () => void>> = new Map();
_fireEvent(event: string, id: number) {
const cb = this._listeners.get(event)?.get(id);
if (cb) {
cb();
this._listeners.get(event)?.delete(id);
}
}
constructor(_opts: any) {
howlCtor(_opts);
howlInstances.push(this);
}
}
return { Howl: MockHowl };
});
// Mock music imports
vi.mock("../../../../proprietary/sounds/music/of4.mp3", () => ({
default: "of4.mp3",
}));
vi.mock("../../../../proprietary/sounds/music/openfront.mp3", () => ({
default: "openfront.mp3",
}));
vi.mock("../../../../proprietary/sounds/music/war.mp3", () => ({
default: "war.mp3",
}));
// Mock the Sounds module so tests don't depend on actual asset paths
vi.mock("../../../src/client/sound/Sounds", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../src/client/sound/Sounds")>();
return {
...actual,
soundEffectUrls: new Map([
["click", "mock/click.mp3"],
["atom-hit", "mock/atom-hit.mp3"],
["atom-launch", "mock/atom-launch.mp3"],
["hydrogen-hit", "mock/hydrogen-hit.mp3"],
["hydrogen-launch", "mock/hydrogen-launch.mp3"],
["mirv-launch", "mock/mirv-launch.mp3"],
["ka-ching", "mock/ka-ching.mp3"],
["message", "mock/message.mp3"],
["build-city", "mock/build-city.mp3"],
]),
};
});
import {
MAX_CONCURRENT_SOUNDS,
SoundManager,
} from "../../../src/client/sound/SoundManager";
import {
PlaySoundEffectEvent,
SetBackgroundMusicVolumeEvent,
SetSoundEffectsVolumeEvent,
} from "../../../src/client/sound/Sounds";
import { EventBus } from "../../../src/core/EventBus";
import { UserSettings } from "../../../src/core/game/UserSettings";
function createUserSettings(musicVolume = 0, sfxVolume = 1): UserSettings {
const settings = new UserSettings();
settings.setBackgroundMusicVolume(musicVolume);
settings.setSoundEffectsVolume(sfxVolume);
return settings;
}
describe("SoundManager", () => {
let eventBus: EventBus;
let userSettings: UserSettings;
let soundManager: SoundManager;
beforeEach(() => {
howlCtor.mockClear();
howlInstances.length = 0;
nextPlayId = 1;
eventBus = new EventBus();
userSettings = createUserSettings();
soundManager = new SoundManager(eventBus, userSettings);
});
it("lazy-loads a sound effect once and reuses it", () => {
eventBus.emit(new PlaySoundEffectEvent("click"));
eventBus.emit(new PlaySoundEffectEvent("click"));
// 3 background music Howls + 1 Click Howl = 4
expect(howlCtor).toHaveBeenCalledTimes(4);
});
it("plays a sound effect when PlaySoundEffectEvent is emitted", () => {
eventBus.emit(new PlaySoundEffectEvent("atom-hit"));
const effectHowl = howlInstances[howlInstances.length - 1];
expect(effectHowl.play).toHaveBeenCalledTimes(1);
});
it("applies bootstrap volume from UserSettings to background music", () => {
const settings = createUserSettings(0.5, 1);
const bus = new EventBus();
howlCtor.mockClear();
howlInstances.length = 0;
new SoundManager(bus, settings);
const bgHowls = howlInstances.slice(0, 3);
bgHowls.forEach((h) => {
expect(h.volume).toHaveBeenCalledWith(0.5);
});
});
it("applies current sfx volume to lazily-loaded sounds", () => {
const settings = createUserSettings(0, 0.3);
const bus = new EventBus();
howlCtor.mockClear();
howlInstances.length = 0;
new SoundManager(bus, settings);
bus.emit(new PlaySoundEffectEvent("click"));
expect(howlCtor).toHaveBeenLastCalledWith(
expect.objectContaining({ volume: 0.3 }),
);
});
it("responds to SetBackgroundMusicVolumeEvent", () => {
eventBus.emit(new SetBackgroundMusicVolumeEvent(0.7));
const bgHowls = howlInstances.slice(0, 3);
bgHowls.forEach((h) => {
expect(h.volume).toHaveBeenCalledWith(0.7);
});
});
it("responds to SetSoundEffectsVolumeEvent", () => {
eventBus.emit(new PlaySoundEffectEvent("click"));
const clickHowl = howlInstances[howlInstances.length - 1];
clickHowl.volume.mockClear();
eventBus.emit(new SetSoundEffectsVolumeEvent(0.4));
expect(clickHowl.volume).toHaveBeenCalledWith(0.4);
});
it("clamps volume values between 0 and 1", () => {
eventBus.emit(new SetBackgroundMusicVolumeEvent(2));
const bgHowls = howlInstances.slice(0, 3);
bgHowls.forEach((h) => {
expect(h.volume).toHaveBeenCalledWith(1);
});
bgHowls.forEach((h) => h.volume.mockClear());
eventBus.emit(new SetBackgroundMusicVolumeEvent(-0.5));
bgHowls.forEach((h) => {
expect(h.volume).toHaveBeenCalledWith(0);
});
});
it("dispose() unsubscribes from EventBus so events no longer play sounds", () => {
eventBus.emit(new PlaySoundEffectEvent("click"));
const clickHowl = howlInstances[howlInstances.length - 1];
expect(clickHowl.play).toHaveBeenCalledTimes(1);
soundManager.dispose();
eventBus.emit(new PlaySoundEffectEvent("click"));
expect(clickHowl.play).toHaveBeenCalledTimes(1);
});
it("dispose() stops and unloads all loaded sound effects", () => {
eventBus.emit(new PlaySoundEffectEvent("click"));
const clickHowl = howlInstances[howlInstances.length - 1];
soundManager.dispose();
expect(clickHowl.stop).toHaveBeenCalled();
expect(clickHowl.unload).toHaveBeenCalled();
});
it("dispose() stops and unloads background music", () => {
const bgHowls = howlInstances.slice(0, 3);
soundManager.dispose();
bgHowls.forEach((h) => {
expect(h.stop).toHaveBeenCalled();
expect(h.unload).toHaveBeenCalled();
});
});
it("does not throw when playSoundEffect is called directly", () => {
expect(() => soundManager.playSoundEffect("click")).not.toThrow();
});
it("does not throw when playBackgroundMusic and stopBackgroundMusic are called", () => {
expect(() => soundManager.playBackgroundMusic()).not.toThrow();
expect(() => soundManager.stopBackgroundMusic()).not.toThrow();
});
it("swallows errors from Howler and does not propagate", () => {
howlInstances.forEach((h) => {
h.play.mockImplementation(() => {
throw new Error("audio backend failure");
});
h.stop.mockImplementation(() => {
throw new Error("audio backend failure");
});
h.volume.mockImplementation(() => {
throw new Error("audio backend failure");
});
});
eventBus.emit(new PlaySoundEffectEvent("click"));
const clickHowl = howlInstances[howlInstances.length - 1];
clickHowl.play.mockImplementation(() => {
throw new Error("audio backend failure");
});
clickHowl.stop.mockImplementation(() => {
throw new Error("audio backend failure");
});
clickHowl.volume.mockImplementation(() => {
throw new Error("audio backend failure");
});
expect(() => soundManager.playBackgroundMusic()).not.toThrow();
expect(() => soundManager.stopBackgroundMusic()).not.toThrow();
expect(() => soundManager.setBackgroundMusicVolume(0.5)).not.toThrow();
expect(() => soundManager.setSoundEffectsVolume(0.5)).not.toThrow();
expect(() => soundManager.playSoundEffect("click")).not.toThrow();
expect(() => soundManager.stopSoundEffect("click")).not.toThrow();
});
});
describe("Sound channel management", () => {
let eventBus: EventBus;
beforeEach(() => {
howlCtor.mockClear();
howlInstances.length = 0;
nextPlayId = 1;
eventBus = new EventBus();
new SoundManager(eventBus, createUserSettings());
});
it("new sound always plays even when at channel cap", () => {
for (let i = 0; i < MAX_CONCURRENT_SOUNDS; i++) {
eventBus.emit(new PlaySoundEffectEvent("click"));
}
eventBus.emit(new PlaySoundEffectEvent("atom-hit"));
const atomHowl = howlInstances[howlInstances.length - 1];
expect(atomHowl.play).toHaveBeenCalled();
});
it("stops the oldest sound when at channel cap", () => {
for (let i = 0; i < MAX_CONCURRENT_SOUNDS; i++) {
eventBus.emit(new PlaySoundEffectEvent("click"));
}
const clickHowl = howlInstances[howlInstances.length - 1];
// The first play had id=1. Playing one more should stop id=1.
eventBus.emit(new PlaySoundEffectEvent("atom-hit"));
expect(clickHowl.stop).toHaveBeenCalledWith(1);
});
it("frees a channel when a sound ends naturally", () => {
for (let i = 0; i < MAX_CONCURRENT_SOUNDS; i++) {
eventBus.emit(new PlaySoundEffectEvent("click"));
}
const clickHowl = howlInstances[howlInstances.length - 1];
// Simulate first sound ending naturally
clickHowl._fireEvent("end", 1);
// Next sound should play without stopping anything
clickHowl.stop.mockClear();
eventBus.emit(new PlaySoundEffectEvent("click"));
expect(clickHowl.stop).not.toHaveBeenCalled();
});
it("allows up to MAX_CONCURRENT_SOUNDS without stopping any", () => {
for (let i = 0; i < MAX_CONCURRENT_SOUNDS; i++) {
eventBus.emit(new PlaySoundEffectEvent("click"));
}
const clickHowl = howlInstances[howlInstances.length - 1];
expect(clickHowl.play).toHaveBeenCalledTimes(8);
// No stop calls with specific IDs (only general stop might be called)
expect(clickHowl.stop).not.toHaveBeenCalled();
});
});