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-06 21:01:23 -07:00
committed by GitHub
parent 341f344ce5
commit 18da7134c8
27 changed files with 639 additions and 132 deletions
+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) {}
}