Files
OpenFrontIO/src/client/sound/SoundManager.ts
T
Evan 1ebac8e854 Move brand images to proprietary/ and support multi-dir asset pipeline (#3662)
## Description:

* Move proprietary brand images (logos, favicon) from resources/images/
to proprietary/images/ to separate open-source assets from proprietary
ones
* Extend the asset pipeline (PublicAssetManifest, vite.config.ts) to
support multiple source directories (resources/ + proprietary/), so
buildAssetUrl resolves assets from either location transparently
* In dev, serve proprietary/ as a fallback middleware (registered after
Vite's publicDir handler) so resources/ takes precedence when files
exist in both. The idea is we could have placeholder assets placeholders
that can be used by forks, and only the production build uses
proprietary assets.

## Please complete the following:

- [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

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
2026-04-16 08:42:11 -07:00

180 lines
5.7 KiB
TypeScript

import { Howl } from "howler";
import { assetUrl } from "../../core/AssetUrls";
import { EventBus } from "../../core/EventBus";
import { UserSettings } from "../../core/game/UserSettings";
import {
PlaySoundEffectEvent,
SetBackgroundMusicVolumeEvent,
SetSoundEffectsVolumeEvent,
SoundEffect,
soundEffectUrls,
} from "./Sounds";
export const MAX_CONCURRENT_SOUNDS = 8;
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(eventBus: EventBus, userSettings: UserSettings) {
this.eventBus = eventBus;
this.safely("initialize background music", () => {
this.backgroundMusic = [
new Howl({
src: [assetUrl("sounds/music/of4.mp3")],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
new Howl({
src: [assetUrl("sounds/music/openfront.mp3")],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
new Howl({
src: [assetUrl("sounds/music/war.mp3")],
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 {
this.safely("play background music", () => {
if (
this.backgroundMusic.length > 0 &&
!this.backgroundMusic[this.currentTrack].playing()
) {
this.backgroundMusic[this.currentTrack].play();
}
});
}
public stopBackgroundMusic(): void {
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.safely("set background music volume", () => {
this.backgroundMusic.forEach((track) => {
track.volume(this.backgroundMusicVolume);
});
});
}
private playNext(): void {
this.currentTrack = (this.currentTrack + 1) % this.backgroundMusic.length;
this.playBackgroundMusic();
}
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 {
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.safely("set sound effects volume", () => {
this.soundEffects.forEach((sound) => {
sound.volume(this.soundEffectsVolume);
});
});
}
public stopSoundEffect(name: SoundEffect): void {
this.safely(`stop sound ${name}`, () => {
const howl = this.soundEffects.get(name);
if (howl) {
howl.stop();
this.activeSounds = this.activeSounds.filter((s) => s.howl !== howl);
}
});
}
}