mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-20 19:20:27 +00:00
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:
@@ -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"),
|
||||
|
||||
@@ -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,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 &&
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user