mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 17:36:44 +00:00
18da7134c8
## 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
304 lines
9.9 KiB
TypeScript
304 lines
9.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Mock howler before importing SoundManager
|
|
const howlCtor = vi.fn();
|
|
const howlInstances: any[] = [];
|
|
let nextPlayId = 1;
|
|
vi.mock("howler", () => {
|
|
class MockHowl {
|
|
play = vi.fn(() => nextPlayId++);
|
|
stop = vi.fn((id?: number) => {
|
|
if (id !== undefined) {
|
|
this._fireEvent("stop", id);
|
|
}
|
|
});
|
|
volume = vi.fn();
|
|
playing = vi.fn().mockReturnValue(false);
|
|
unload = vi.fn();
|
|
once = vi.fn((event: string, callback: () => void, id?: number) => {
|
|
if (id !== undefined) {
|
|
if (!this._listeners.has(event)) {
|
|
this._listeners.set(event, new Map());
|
|
}
|
|
this._listeners.get(event)!.set(id, callback);
|
|
}
|
|
});
|
|
_listeners: Map<string, Map<number, () => void>> = new Map();
|
|
_fireEvent(event: string, id: number) {
|
|
const cb = this._listeners.get(event)?.get(id);
|
|
if (cb) {
|
|
cb();
|
|
this._listeners.get(event)?.delete(id);
|
|
}
|
|
}
|
|
constructor(_opts: any) {
|
|
howlCtor(_opts);
|
|
howlInstances.push(this);
|
|
}
|
|
}
|
|
return { Howl: MockHowl };
|
|
});
|
|
|
|
// Mock music imports
|
|
vi.mock("../../../../proprietary/sounds/music/of4.mp3", () => ({
|
|
default: "of4.mp3",
|
|
}));
|
|
vi.mock("../../../../proprietary/sounds/music/openfront.mp3", () => ({
|
|
default: "openfront.mp3",
|
|
}));
|
|
vi.mock("../../../../proprietary/sounds/music/war.mp3", () => ({
|
|
default: "war.mp3",
|
|
}));
|
|
|
|
// Mock the Sounds module so tests don't depend on actual asset paths
|
|
vi.mock("../../../src/client/sound/Sounds", async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import("../../../src/client/sound/Sounds")>();
|
|
return {
|
|
...actual,
|
|
soundEffectUrls: new Map([
|
|
["click", "mock/click.mp3"],
|
|
["atom-hit", "mock/atom-hit.mp3"],
|
|
["atom-launch", "mock/atom-launch.mp3"],
|
|
["hydrogen-hit", "mock/hydrogen-hit.mp3"],
|
|
["hydrogen-launch", "mock/hydrogen-launch.mp3"],
|
|
["mirv-launch", "mock/mirv-launch.mp3"],
|
|
["ka-ching", "mock/ka-ching.mp3"],
|
|
["message", "mock/message.mp3"],
|
|
["build-city", "mock/build-city.mp3"],
|
|
]),
|
|
};
|
|
});
|
|
|
|
import {
|
|
MAX_CONCURRENT_SOUNDS,
|
|
SoundManager,
|
|
} from "../../../src/client/sound/SoundManager";
|
|
import {
|
|
PlaySoundEffectEvent,
|
|
SetBackgroundMusicVolumeEvent,
|
|
SetSoundEffectsVolumeEvent,
|
|
} from "../../../src/client/sound/Sounds";
|
|
import { EventBus } from "../../../src/core/EventBus";
|
|
import { UserSettings } from "../../../src/core/game/UserSettings";
|
|
|
|
function createUserSettings(musicVolume = 0, sfxVolume = 1): UserSettings {
|
|
const settings = new UserSettings();
|
|
settings.setBackgroundMusicVolume(musicVolume);
|
|
settings.setSoundEffectsVolume(sfxVolume);
|
|
return settings;
|
|
}
|
|
|
|
describe("SoundManager", () => {
|
|
let eventBus: EventBus;
|
|
let userSettings: UserSettings;
|
|
let soundManager: SoundManager;
|
|
|
|
beforeEach(() => {
|
|
howlCtor.mockClear();
|
|
howlInstances.length = 0;
|
|
nextPlayId = 1;
|
|
eventBus = new EventBus();
|
|
userSettings = createUserSettings();
|
|
soundManager = new SoundManager(eventBus, userSettings);
|
|
});
|
|
|
|
it("lazy-loads a sound effect once and reuses it", () => {
|
|
eventBus.emit(new PlaySoundEffectEvent("click"));
|
|
eventBus.emit(new PlaySoundEffectEvent("click"));
|
|
// 3 background music Howls + 1 Click Howl = 4
|
|
expect(howlCtor).toHaveBeenCalledTimes(4);
|
|
});
|
|
|
|
it("plays a sound effect when PlaySoundEffectEvent is emitted", () => {
|
|
eventBus.emit(new PlaySoundEffectEvent("atom-hit"));
|
|
const effectHowl = howlInstances[howlInstances.length - 1];
|
|
expect(effectHowl.play).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("applies bootstrap volume from UserSettings to background music", () => {
|
|
const settings = createUserSettings(0.5, 1);
|
|
const bus = new EventBus();
|
|
howlCtor.mockClear();
|
|
howlInstances.length = 0;
|
|
new SoundManager(bus, settings);
|
|
const bgHowls = howlInstances.slice(0, 3);
|
|
bgHowls.forEach((h) => {
|
|
expect(h.volume).toHaveBeenCalledWith(0.5);
|
|
});
|
|
});
|
|
|
|
it("applies current sfx volume to lazily-loaded sounds", () => {
|
|
const settings = createUserSettings(0, 0.3);
|
|
const bus = new EventBus();
|
|
howlCtor.mockClear();
|
|
howlInstances.length = 0;
|
|
new SoundManager(bus, settings);
|
|
bus.emit(new PlaySoundEffectEvent("click"));
|
|
expect(howlCtor).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ volume: 0.3 }),
|
|
);
|
|
});
|
|
|
|
it("responds to SetBackgroundMusicVolumeEvent", () => {
|
|
eventBus.emit(new SetBackgroundMusicVolumeEvent(0.7));
|
|
const bgHowls = howlInstances.slice(0, 3);
|
|
bgHowls.forEach((h) => {
|
|
expect(h.volume).toHaveBeenCalledWith(0.7);
|
|
});
|
|
});
|
|
|
|
it("responds to SetSoundEffectsVolumeEvent", () => {
|
|
eventBus.emit(new PlaySoundEffectEvent("click"));
|
|
const clickHowl = howlInstances[howlInstances.length - 1];
|
|
clickHowl.volume.mockClear();
|
|
eventBus.emit(new SetSoundEffectsVolumeEvent(0.4));
|
|
expect(clickHowl.volume).toHaveBeenCalledWith(0.4);
|
|
});
|
|
|
|
it("clamps volume values between 0 and 1", () => {
|
|
eventBus.emit(new SetBackgroundMusicVolumeEvent(2));
|
|
const bgHowls = howlInstances.slice(0, 3);
|
|
bgHowls.forEach((h) => {
|
|
expect(h.volume).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
bgHowls.forEach((h) => h.volume.mockClear());
|
|
eventBus.emit(new SetBackgroundMusicVolumeEvent(-0.5));
|
|
bgHowls.forEach((h) => {
|
|
expect(h.volume).toHaveBeenCalledWith(0);
|
|
});
|
|
});
|
|
|
|
it("dispose() unsubscribes from EventBus so events no longer play sounds", () => {
|
|
eventBus.emit(new PlaySoundEffectEvent("click"));
|
|
const clickHowl = howlInstances[howlInstances.length - 1];
|
|
expect(clickHowl.play).toHaveBeenCalledTimes(1);
|
|
|
|
soundManager.dispose();
|
|
|
|
eventBus.emit(new PlaySoundEffectEvent("click"));
|
|
expect(clickHowl.play).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("dispose() stops and unloads all loaded sound effects", () => {
|
|
eventBus.emit(new PlaySoundEffectEvent("click"));
|
|
const clickHowl = howlInstances[howlInstances.length - 1];
|
|
|
|
soundManager.dispose();
|
|
|
|
expect(clickHowl.stop).toHaveBeenCalled();
|
|
expect(clickHowl.unload).toHaveBeenCalled();
|
|
});
|
|
|
|
it("dispose() stops and unloads background music", () => {
|
|
const bgHowls = howlInstances.slice(0, 3);
|
|
|
|
soundManager.dispose();
|
|
|
|
bgHowls.forEach((h) => {
|
|
expect(h.stop).toHaveBeenCalled();
|
|
expect(h.unload).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("does not throw when playSoundEffect is called directly", () => {
|
|
expect(() => soundManager.playSoundEffect("click")).not.toThrow();
|
|
});
|
|
|
|
it("does not throw when playBackgroundMusic and stopBackgroundMusic are called", () => {
|
|
expect(() => soundManager.playBackgroundMusic()).not.toThrow();
|
|
expect(() => soundManager.stopBackgroundMusic()).not.toThrow();
|
|
});
|
|
|
|
it("swallows errors from Howler and does not propagate", () => {
|
|
howlInstances.forEach((h) => {
|
|
h.play.mockImplementation(() => {
|
|
throw new Error("audio backend failure");
|
|
});
|
|
h.stop.mockImplementation(() => {
|
|
throw new Error("audio backend failure");
|
|
});
|
|
h.volume.mockImplementation(() => {
|
|
throw new Error("audio backend failure");
|
|
});
|
|
});
|
|
eventBus.emit(new PlaySoundEffectEvent("click"));
|
|
const clickHowl = howlInstances[howlInstances.length - 1];
|
|
clickHowl.play.mockImplementation(() => {
|
|
throw new Error("audio backend failure");
|
|
});
|
|
clickHowl.stop.mockImplementation(() => {
|
|
throw new Error("audio backend failure");
|
|
});
|
|
clickHowl.volume.mockImplementation(() => {
|
|
throw new Error("audio backend failure");
|
|
});
|
|
|
|
expect(() => soundManager.playBackgroundMusic()).not.toThrow();
|
|
expect(() => soundManager.stopBackgroundMusic()).not.toThrow();
|
|
expect(() => soundManager.setBackgroundMusicVolume(0.5)).not.toThrow();
|
|
expect(() => soundManager.setSoundEffectsVolume(0.5)).not.toThrow();
|
|
expect(() => soundManager.playSoundEffect("click")).not.toThrow();
|
|
expect(() => soundManager.stopSoundEffect("click")).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("Sound channel management", () => {
|
|
let eventBus: EventBus;
|
|
|
|
beforeEach(() => {
|
|
howlCtor.mockClear();
|
|
howlInstances.length = 0;
|
|
nextPlayId = 1;
|
|
eventBus = new EventBus();
|
|
new SoundManager(eventBus, createUserSettings());
|
|
});
|
|
|
|
it("new sound always plays even when at channel cap", () => {
|
|
for (let i = 0; i < MAX_CONCURRENT_SOUNDS; i++) {
|
|
eventBus.emit(new PlaySoundEffectEvent("click"));
|
|
}
|
|
|
|
eventBus.emit(new PlaySoundEffectEvent("atom-hit"));
|
|
const atomHowl = howlInstances[howlInstances.length - 1];
|
|
expect(atomHowl.play).toHaveBeenCalled();
|
|
});
|
|
|
|
it("stops the oldest sound when at channel cap", () => {
|
|
for (let i = 0; i < MAX_CONCURRENT_SOUNDS; i++) {
|
|
eventBus.emit(new PlaySoundEffectEvent("click"));
|
|
}
|
|
const clickHowl = howlInstances[howlInstances.length - 1];
|
|
|
|
// The first play had id=1. Playing one more should stop id=1.
|
|
eventBus.emit(new PlaySoundEffectEvent("atom-hit"));
|
|
expect(clickHowl.stop).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("frees a channel when a sound ends naturally", () => {
|
|
for (let i = 0; i < MAX_CONCURRENT_SOUNDS; i++) {
|
|
eventBus.emit(new PlaySoundEffectEvent("click"));
|
|
}
|
|
const clickHowl = howlInstances[howlInstances.length - 1];
|
|
|
|
// Simulate first sound ending naturally
|
|
clickHowl._fireEvent("end", 1);
|
|
|
|
// Next sound should play without stopping anything
|
|
clickHowl.stop.mockClear();
|
|
eventBus.emit(new PlaySoundEffectEvent("click"));
|
|
expect(clickHowl.stop).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows up to MAX_CONCURRENT_SOUNDS without stopping any", () => {
|
|
for (let i = 0; i < MAX_CONCURRENT_SOUNDS; i++) {
|
|
eventBus.emit(new PlaySoundEffectEvent("click"));
|
|
}
|
|
const clickHowl = howlInstances[howlInstances.length - 1];
|
|
expect(clickHowl.play).toHaveBeenCalledTimes(8);
|
|
// No stop calls with specific IDs (only general stop might be called)
|
|
expect(clickHowl.stop).not.toHaveBeenCalled();
|
|
});
|
|
});
|