diff --git a/resources/sounds/effects/alliance-broken.mp3 b/resources/sounds/effects/alliance-broken.mp3 new file mode 100644 index 000000000..0bf64e6e6 Binary files /dev/null and b/resources/sounds/effects/alliance-broken.mp3 differ diff --git a/resources/sounds/effects/alliance-suggested.mp3 b/resources/sounds/effects/alliance-suggested.mp3 new file mode 100644 index 000000000..dd172eb91 Binary files /dev/null and b/resources/sounds/effects/alliance-suggested.mp3 differ diff --git a/resources/sounds/effects/atom-hit.mp3 b/resources/sounds/effects/atom-hit.mp3 new file mode 100644 index 000000000..895969d79 Binary files /dev/null and b/resources/sounds/effects/atom-hit.mp3 differ diff --git a/resources/sounds/effects/atom-launch.mp3 b/resources/sounds/effects/atom-launch.mp3 new file mode 100644 index 000000000..f33c149e8 Binary files /dev/null and b/resources/sounds/effects/atom-launch.mp3 differ diff --git a/resources/sounds/effects/build-city.mp3 b/resources/sounds/effects/build-city.mp3 new file mode 100644 index 000000000..85d9efadb Binary files /dev/null and b/resources/sounds/effects/build-city.mp3 differ diff --git a/resources/sounds/effects/build-defense-post.mp3 b/resources/sounds/effects/build-defense-post.mp3 new file mode 100644 index 000000000..3774ca979 Binary files /dev/null and b/resources/sounds/effects/build-defense-post.mp3 differ diff --git a/resources/sounds/effects/build-port.mp3 b/resources/sounds/effects/build-port.mp3 new file mode 100644 index 000000000..5b3419c64 Binary files /dev/null and b/resources/sounds/effects/build-port.mp3 differ diff --git a/resources/sounds/effects/build-warship.mp3 b/resources/sounds/effects/build-warship.mp3 new file mode 100644 index 000000000..e96cf23d5 Binary files /dev/null and b/resources/sounds/effects/build-warship.mp3 differ diff --git a/resources/sounds/effects/click.mp3 b/resources/sounds/effects/click.mp3 new file mode 100644 index 000000000..92cf4b718 Binary files /dev/null and b/resources/sounds/effects/click.mp3 differ diff --git a/resources/sounds/effects/hydrogen-hit.mp3 b/resources/sounds/effects/hydrogen-hit.mp3 new file mode 100644 index 000000000..d56bedf06 Binary files /dev/null and b/resources/sounds/effects/hydrogen-hit.mp3 differ diff --git a/resources/sounds/effects/hydrogen-launch.mp3 b/resources/sounds/effects/hydrogen-launch.mp3 new file mode 100644 index 000000000..ebff2748a Binary files /dev/null and b/resources/sounds/effects/hydrogen-launch.mp3 differ diff --git a/resources/sounds/effects/message.mp3 b/resources/sounds/effects/message.mp3 new file mode 100644 index 000000000..0c9f66a01 Binary files /dev/null and b/resources/sounds/effects/message.mp3 differ diff --git a/resources/sounds/effects/mirv-launch.mp3 b/resources/sounds/effects/mirv-launch.mp3 new file mode 100644 index 000000000..5764c95e9 Binary files /dev/null and b/resources/sounds/effects/mirv-launch.mp3 differ diff --git a/resources/sounds/effects/sam-built.mp3 b/resources/sounds/effects/sam-built.mp3 new file mode 100644 index 000000000..b4008be21 Binary files /dev/null and b/resources/sounds/effects/sam-built.mp3 differ diff --git a/resources/sounds/effects/sam-hit.mp3 b/resources/sounds/effects/sam-hit.mp3 new file mode 100644 index 000000000..e992c68f3 Binary files /dev/null and b/resources/sounds/effects/sam-hit.mp3 differ diff --git a/resources/sounds/effects/sam-shoot.mp3 b/resources/sounds/effects/sam-shoot.mp3 new file mode 100644 index 000000000..4deae706e Binary files /dev/null and b/resources/sounds/effects/sam-shoot.mp3 differ diff --git a/resources/sounds/effects/silo-built.mp3 b/resources/sounds/effects/silo-built.mp3 new file mode 100644 index 000000000..8906a9b51 Binary files /dev/null and b/resources/sounds/effects/silo-built.mp3 differ diff --git a/resources/sounds/effects/warship-lost.mp3 b/resources/sounds/effects/warship-lost.mp3 new file mode 100644 index 000000000..414fec220 Binary files /dev/null and b/resources/sounds/effects/warship-lost.mp3 differ diff --git a/resources/sounds/effects/warship-shot.mp3 b/resources/sounds/effects/warship-shot.mp3 new file mode 100644 index 000000000..e7f90e8cf Binary files /dev/null and b/resources/sounds/effects/warship-shot.mp3 differ diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index aefbac8ed..1d8db72d8 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -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; diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index 0524c992a..1192a3ce7 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -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"), diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index d6281be8d..9187605cb 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -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 = 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() { diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 9f8b6c654..8e65357e6 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -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 && diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index 2e3c9f532..5f747c6ba 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -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(); } diff --git a/src/client/sound/SoundManager.ts b/src/client/sound/SoundManager.ts index 6e6e0e912..9edf026d1 100644 --- a/src/client/sound/SoundManager.ts +++ b/src/client/sound/SoundManager.ts @@ -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 = 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(); diff --git a/src/client/sound/Sounds.ts b/src/client/sound/Sounds.ts new file mode 100644 index 000000000..f80e06102 --- /dev/null +++ b/src/client/sound/Sounds.ts @@ -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 = 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) {} +} diff --git a/tests/client/sound/SoundManager.test.ts b/tests/client/sound/SoundManager.test.ts new file mode 100644 index 000000000..246ccfc37 --- /dev/null +++ b/tests/client/sound/SoundManager.test.ts @@ -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 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(); + 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(); + }); +});