Create SoundController, also make SoundEffect more type-safe

This commit is contained in:
VariableVince
2026-05-23 22:11:35 +02:00
parent fb05e1fd39
commit 5210a51aa0
8 changed files with 179 additions and 145 deletions
+6 -85
View File
@@ -1,6 +1,6 @@
import { Config } from "src/core/configuration/Config";
import { translateText } from "../client/Utils";
import { EventBus } from "../core/EventBus";
import { EventBus, GameEvent } from "../core/EventBus";
import {
ClientID,
GameID,
@@ -21,12 +21,10 @@ import {
import { TileRef } from "../core/game/GameMap";
import { GameMapLoader } from "../core/game/GameMapLoader";
import {
ConquestUpdate,
ErrorUpdate,
GameUpdateType,
GameUpdateViewData,
HashUpdate,
UnitUpdate,
WinUpdate,
} from "../core/game/GameUpdates";
import { GameView, PlayerView } from "../core/game/GameView";
@@ -71,7 +69,6 @@ import { createRenderer, GameRenderer } from "./hud/GameRenderer";
import { GameView as WebGLGameView } from "./render/gl";
import { ALL_UNIT_TYPES, UnitState } from "./render/types";
import { SoundManager } from "./sound/SoundManager";
import { PlaySoundEffectEvent } from "./sound/Sounds";
export interface LobbyConfig {
cosmetics: PlayerCosmeticRefs;
@@ -522,6 +519,10 @@ async function createClientGame(
}
}
export class SoundUpdateEvent implements GameEvent {
constructor(public gu: GameUpdateViewData) {}
}
export class ClientGameRunner {
private myPlayer: PlayerView | null = null;
private isActive = false;
@@ -658,87 +659,7 @@ export class ClientGameRunner {
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
});
// Sound FX
if ((gu.pendingTurns ?? 0) <= 1 && this.gameView.ticks() > 0) {
const myPlayer = this.gameView.myPlayer();
if (myPlayer) {
gu.updates[GameUpdateType.ConquestEvent]?.forEach(
(cu: ConquestUpdate) => {
if (cu.conquerorId === myPlayer.id()) {
this.eventBus.emit(new PlaySoundEffectEvent("ka-ching"));
}
},
);
gu.updates[GameUpdateType.Unit]?.forEach((u: UnitUpdate) => {
const existingUnit = this.gameView.unit(u.id);
const isMine = u.ownerID === myPlayer.smallID();
if (!existingUnit) {
switch (u.unitType) {
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 (isMine) {
this.eventBus.emit(
new PlaySoundEffectEvent("build-warship"),
);
}
break;
case UnitType.City:
if (isMine) {
this.eventBus.emit(new PlaySoundEffectEvent("build-city"));
}
break;
case UnitType.Port:
if (isMine) {
this.eventBus.emit(new PlaySoundEffectEvent("build-port"));
}
break;
case UnitType.DefensePost:
if (isMine) {
this.eventBus.emit(
new PlaySoundEffectEvent("build-defense-post"),
);
}
break;
case UnitType.SAMLauncher:
if (isMine) {
this.eventBus.emit(new PlaySoundEffectEvent("sam-built"));
}
break;
case UnitType.MissileSilo:
if (isMine) {
this.eventBus.emit(new PlaySoundEffectEvent("silo-built"));
}
break;
}
} else if (
existingUnit.isActive() &&
!u.isActive &&
u.reachedTarget
) {
if (u.unitType === UnitType.HydrogenBomb) {
this.eventBus.emit(new PlaySoundEffectEvent("hydrogen-hit"));
} else if (
u.unitType === UnitType.AtomBomb ||
u.unitType === UnitType.MIRV
) {
this.eventBus.emit(new PlaySoundEffectEvent("atom-hit"));
}
}
});
}
}
this.eventBus.emit(new SoundUpdateEvent(gu));
this.gameView.update(gu);
this.webglBuilder?.update(this.gameView);
+101
View File
@@ -0,0 +1,101 @@
import { EventBus } from "../../core/EventBus";
import { UnitType } from "../../core/game/Game";
import {
ConquestUpdate,
GameUpdateType,
UnitUpdate,
} from "../../core/game/GameUpdates";
import { GameView } from "../../core/game/GameView";
import { SoundUpdateEvent } from "../ClientGameRunner";
import { Controller } from "../Controller";
import { PlaySoundEffectEvent, SoundEffect } from "../sound/Sounds";
export class SoundController implements Controller {
constructor(
private eventBus: EventBus,
private view: GameView,
) {}
init() {
this.eventBus.on(SoundUpdateEvent, (e) => this.handleGameUpdate(e));
}
private handleGameUpdate(e: SoundUpdateEvent) {
const gu = e.gu;
if ((gu.pendingTurns ?? 0) > 1 || this.view.ticks() <= 0) return;
const myPlayer = this.view.myPlayer();
if (!myPlayer) return;
// 1. Process Conquests
gu.updates[GameUpdateType.ConquestEvent]?.forEach((cu: ConquestUpdate) => {
if (cu.conquerorId === myPlayer.id()) {
this.eventBus.emit(new PlaySoundEffectEvent(SoundEffect.KaChing));
}
});
// 2. Process Units
gu.updates[GameUpdateType.Unit]?.forEach((u: UnitUpdate) => {
const existingUnit = this.view.unit(u.id);
const isMine = u.ownerID === myPlayer.smallID();
if (!existingUnit) {
this.handleNewUnitSounds(u.unitType, isMine);
} else if (existingUnit.isActive() && !u.isActive && u.reachedTarget) {
this.handleImpactSounds(u.unitType);
}
});
}
private handleNewUnitSounds(unitType: UnitType, isMine: boolean) {
switch (unitType) {
case UnitType.AtomBomb:
this.eventBus.emit(new PlaySoundEffectEvent(SoundEffect.AtomLaunch));
break;
case UnitType.HydrogenBomb:
this.eventBus.emit(
new PlaySoundEffectEvent(SoundEffect.HydrogenLaunch),
);
break;
case UnitType.MIRV:
this.eventBus.emit(new PlaySoundEffectEvent(SoundEffect.MirvLaunch));
break;
case UnitType.Warship:
if (isMine)
this.eventBus.emit(
new PlaySoundEffectEvent(SoundEffect.BuildWarship),
);
break;
case UnitType.City:
if (isMine)
this.eventBus.emit(new PlaySoundEffectEvent(SoundEffect.BuildCity));
break;
case UnitType.Port:
if (isMine)
this.eventBus.emit(new PlaySoundEffectEvent(SoundEffect.BuildPort));
break;
case UnitType.DefensePost:
if (isMine)
this.eventBus.emit(
new PlaySoundEffectEvent(SoundEffect.BuildDefensePost),
);
break;
case UnitType.SAMLauncher:
if (isMine)
this.eventBus.emit(new PlaySoundEffectEvent(SoundEffect.SamBuilt));
break;
case UnitType.MissileSilo:
if (isMine)
this.eventBus.emit(new PlaySoundEffectEvent(SoundEffect.SiloBuilt));
break;
}
}
private handleImpactSounds(unitType: UnitType) {
if (unitType === UnitType.HydrogenBomb) {
this.eventBus.emit(new PlaySoundEffectEvent(SoundEffect.HydrogenHit));
} else if (unitType === UnitType.AtomBomb || unitType === UnitType.MIRV) {
this.eventBus.emit(new PlaySoundEffectEvent(SoundEffect.AtomHit));
}
}
}
+2
View File
@@ -7,6 +7,7 @@ import { TransformHandler } from "../TransformHandler";
import { UIState } from "../UIState";
import { BuildPreviewController } from "../controllers/BuildPreviewController";
import { HoverHighlightController } from "../controllers/HoverHighlightController";
import { SoundController } from "../controllers/SoundController";
import { StructureHighlightController } from "../controllers/StructureHighlightController";
import { ViewModeController } from "../controllers/ViewModeController";
import { WarshipSelectionController } from "../controllers/WarshipSelectionController";
@@ -283,6 +284,7 @@ export function createRenderer(
new HoverHighlightController(game, eventBus, transformHandler, view),
new StructureHighlightController(eventBus, view),
new ViewModeController(eventBus, view),
new SoundController(eventBus, game),
new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings),
eventsDisplay,
actionableEvents,
+4 -2
View File
@@ -10,7 +10,7 @@ import {
} from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { PlaySoundEffectEvent, SoundEffect } from "../../sound/Sounds";
import { GoToPlayerEvent } from "../../TransformHandler";
import {
SendAllianceExtensionIntentEvent,
@@ -203,7 +203,9 @@ export class ActionableEvents extends LitElement implements Controller {
) as PlayerView;
if (!requestor.isAlliedWith(recipient)) {
this.eventBus.emit(new PlaySoundEffectEvent("alliance-suggested"));
this.eventBus.emit(
new PlaySoundEffectEvent(SoundEffect.AllianceSuggested),
);
}
this.addEvent({
description: translateText("events_display.request_alliance", {
+4 -4
View File
@@ -23,7 +23,7 @@ import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { onlyImages } from "../../../core/Util";
import { GoToPlayerEvent, GoToUnitEvent } from "../../TransformHandler";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { PlaySoundEffectEvent, SoundEffect } from "../../sound/Sounds";
import { UIState } from "../../UIState";
import {
getMessageTypeClasses,
@@ -297,7 +297,7 @@ export class EventsDisplay extends LitElement implements Controller {
type: MessageType.CHAT,
unsafeDescription: false,
});
this.eventBus.emit(new PlaySoundEffectEvent("message"));
this.eventBus.emit(new PlaySoundEffectEvent(SoundEffect.Message));
}
onAllianceRequestReplyEvent(update: AllianceRequestReplyUpdate) {
@@ -335,7 +335,7 @@ export class EventsDisplay extends LitElement implements Controller {
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"));
this.eventBus.emit(new PlaySoundEffectEvent(SoundEffect.AllianceBroken));
const malusPercent = Math.round(
(1 - this.game.config().traitorDefenseDebuff()) * 100,
);
@@ -362,7 +362,7 @@ export class EventsDisplay extends LitElement implements Controller {
focusID: update.betrayedID,
});
} else if (betrayed === myPlayer) {
this.eventBus.emit(new PlaySoundEffectEvent("alliance-broken"));
this.eventBus.emit(new PlaySoundEffectEvent(SoundEffect.AllianceBroken));
this.addEvent({
description: translateText("events_display.betrayed_you", {
name: traitor.displayName(),
+2 -2
View File
@@ -3,7 +3,7 @@ import { assetUrl } from "../../../core/AssetUrls";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { Controller } from "../../Controller";
import { CloseViewEvent } from "../../InputHandler";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { PlaySoundEffectEvent, SoundEffect } from "../../sound/Sounds";
import { getSvgAspectRatio, translateText } from "../../Utils";
import {
CenterButtonElement,
@@ -507,7 +507,7 @@ export class RadialMenu implements Controller {
this.navigationInProgress
)
return;
this.eventBus.emit(new PlaySoundEffectEvent("click"));
this.eventBus.emit(new PlaySoundEffectEvent(SoundEffect.Click));
if (
this.currentLevel > 0 &&
+40 -33
View File
@@ -1,41 +1,48 @@
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"
| "silo-built"
| "message"
| "click";
export enum SoundEffect {
KaChing = "ka-ching",
AtomHit = "atom-hit",
AtomLaunch = "atom-launch",
HydrogenHit = "hydrogen-hit",
HydrogenLaunch = "hydrogen-launch",
MirvLaunch = "mirv-launch",
AllianceSuggested = "alliance-suggested",
AllianceBroken = "alliance-broken",
BuildPort = "build-port",
BuildCity = "build-city",
BuildDefensePost = "build-defense-post",
BuildWarship = "build-warship",
SamBuilt = "sam-built",
SiloBuilt = "silo-built",
Message = "message",
Click = "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")],
["silo-built", assetUrl("sounds/effects/silo-built.mp3")],
["message", assetUrl("sounds/effects/message.mp3")],
["click", assetUrl("sounds/effects/click.mp3")],
[SoundEffect.KaChing, assetUrl("sounds/effects/ka-ching.mp3")],
[SoundEffect.AtomHit, assetUrl("sounds/effects/atom-hit.mp3")],
[SoundEffect.AtomLaunch, assetUrl("sounds/effects/atom-launch.mp3")],
[SoundEffect.HydrogenHit, assetUrl("sounds/effects/hydrogen-hit.mp3")],
[SoundEffect.HydrogenLaunch, assetUrl("sounds/effects/hydrogen-launch.mp3")],
[SoundEffect.MirvLaunch, assetUrl("sounds/effects/mirv-launch.mp3")],
[
SoundEffect.AllianceSuggested,
assetUrl("sounds/effects/alliance-suggested.mp3"),
],
[SoundEffect.AllianceBroken, assetUrl("sounds/effects/alliance-broken.mp3")],
[SoundEffect.BuildPort, assetUrl("sounds/effects/build-port.mp3")],
[SoundEffect.BuildCity, assetUrl("sounds/effects/build-city.mp3")],
[
SoundEffect.BuildDefensePost,
assetUrl("sounds/effects/build-defense-post.mp3"),
],
[SoundEffect.BuildWarship, assetUrl("sounds/effects/build-warship.mp3")],
[SoundEffect.SamBuilt, assetUrl("sounds/effects/sam-built.mp3")],
[SoundEffect.SiloBuilt, assetUrl("sounds/effects/silo-built.mp3")],
[SoundEffect.Message, assetUrl("sounds/effects/message.mp3")],
[SoundEffect.Click, assetUrl("sounds/effects/click.mp3")],
]);
export class PlaySoundEffectEvent implements GameEvent {
+20 -19
View File
@@ -67,6 +67,7 @@ import {
PlaySoundEffectEvent,
SetBackgroundMusicVolumeEvent,
SetSoundEffectsVolumeEvent,
SoundEffect,
} from "../../../src/client/sound/Sounds";
import { EventBus } from "../../../src/core/EventBus";
import { UserSettings } from "../../../src/core/game/UserSettings";
@@ -93,14 +94,14 @@ describe("SoundManager", () => {
});
it("lazy-loads a sound effect once and reuses it", () => {
eventBus.emit(new PlaySoundEffectEvent("click"));
eventBus.emit(new PlaySoundEffectEvent("click"));
eventBus.emit(new PlaySoundEffectEvent(SoundEffect.Click));
eventBus.emit(new PlaySoundEffectEvent(SoundEffect.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"));
eventBus.emit(new PlaySoundEffectEvent(SoundEffect.AtomHit));
const effectHowl = howlInstances[howlInstances.length - 1];
expect(effectHowl.play).toHaveBeenCalledTimes(1);
});
@@ -123,7 +124,7 @@ describe("SoundManager", () => {
howlCtor.mockClear();
howlInstances.length = 0;
new SoundManager(bus, settings);
bus.emit(new PlaySoundEffectEvent("click"));
bus.emit(new PlaySoundEffectEvent(SoundEffect.Click));
expect(howlCtor).toHaveBeenLastCalledWith(
expect.objectContaining({ volume: 0.3 }),
);
@@ -138,7 +139,7 @@ describe("SoundManager", () => {
});
it("responds to SetSoundEffectsVolumeEvent", () => {
eventBus.emit(new PlaySoundEffectEvent("click"));
eventBus.emit(new PlaySoundEffectEvent(SoundEffect.Click));
const clickHowl = howlInstances[howlInstances.length - 1];
clickHowl.volume.mockClear();
eventBus.emit(new SetSoundEffectsVolumeEvent(0.4));
@@ -160,18 +161,18 @@ describe("SoundManager", () => {
});
it("dispose() unsubscribes from EventBus so events no longer play sounds", () => {
eventBus.emit(new PlaySoundEffectEvent("click"));
eventBus.emit(new PlaySoundEffectEvent(SoundEffect.Click));
const clickHowl = howlInstances[howlInstances.length - 1];
expect(clickHowl.play).toHaveBeenCalledTimes(1);
soundManager.dispose();
eventBus.emit(new PlaySoundEffectEvent("click"));
eventBus.emit(new PlaySoundEffectEvent(SoundEffect.Click));
expect(clickHowl.play).toHaveBeenCalledTimes(1);
});
it("dispose() stops and unloads all loaded sound effects", () => {
eventBus.emit(new PlaySoundEffectEvent("click"));
eventBus.emit(new PlaySoundEffectEvent(SoundEffect.Click));
const clickHowl = howlInstances[howlInstances.length - 1];
soundManager.dispose();
@@ -192,7 +193,7 @@ describe("SoundManager", () => {
});
it("does not throw when playSoundEffect is called directly", () => {
expect(() => soundManager.playSoundEffect("click")).not.toThrow();
expect(() => soundManager.playSoundEffect(SoundEffect.Click)).not.toThrow();
});
it("does not throw when playBackgroundMusic and stopBackgroundMusic are called", () => {
@@ -212,7 +213,7 @@ describe("SoundManager", () => {
throw new Error("audio backend failure");
});
});
eventBus.emit(new PlaySoundEffectEvent("click"));
eventBus.emit(new PlaySoundEffectEvent(SoundEffect.Click));
const clickHowl = howlInstances[howlInstances.length - 1];
clickHowl.play.mockImplementation(() => {
throw new Error("audio backend failure");
@@ -228,8 +229,8 @@ describe("SoundManager", () => {
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();
expect(() => soundManager.playSoundEffect(SoundEffect.Click)).not.toThrow();
expect(() => soundManager.stopSoundEffect(SoundEffect.Click)).not.toThrow();
});
});
@@ -246,28 +247,28 @@ describe("Sound channel management", () => {
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(SoundEffect.Click));
}
eventBus.emit(new PlaySoundEffectEvent("atom-hit"));
eventBus.emit(new PlaySoundEffectEvent(SoundEffect.AtomHit));
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"));
eventBus.emit(new PlaySoundEffectEvent(SoundEffect.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"));
eventBus.emit(new PlaySoundEffectEvent(SoundEffect.AtomHit));
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"));
eventBus.emit(new PlaySoundEffectEvent(SoundEffect.Click));
}
const clickHowl = howlInstances[howlInstances.length - 1];
@@ -276,13 +277,13 @@ describe("Sound channel management", () => {
// Next sound should play without stopping anything
clickHowl.stop.mockClear();
eventBus.emit(new PlaySoundEffectEvent("click"));
eventBus.emit(new PlaySoundEffectEvent(SoundEffect.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"));
eventBus.emit(new PlaySoundEffectEvent(SoundEffect.Click));
}
const clickHowl = howlInstances[howlInstances.length - 1];
expect(clickHowl.play).toHaveBeenCalledTimes(8);