diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 11b252b59..c834ea10f 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -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); diff --git a/src/client/controllers/SoundController.ts b/src/client/controllers/SoundController.ts new file mode 100644 index 000000000..6c1fbaa03 --- /dev/null +++ b/src/client/controllers/SoundController.ts @@ -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)); + } + } +} diff --git a/src/client/hud/GameRenderer.ts b/src/client/hud/GameRenderer.ts index aa5bf7e87..0c700fc55 100644 --- a/src/client/hud/GameRenderer.ts +++ b/src/client/hud/GameRenderer.ts @@ -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, diff --git a/src/client/hud/layers/ActionableEvents.ts b/src/client/hud/layers/ActionableEvents.ts index c5831722d..59b96e363 100644 --- a/src/client/hud/layers/ActionableEvents.ts +++ b/src/client/hud/layers/ActionableEvents.ts @@ -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", { diff --git a/src/client/hud/layers/EventsDisplay.ts b/src/client/hud/layers/EventsDisplay.ts index caadf642a..6a6ef60dc 100644 --- a/src/client/hud/layers/EventsDisplay.ts +++ b/src/client/hud/layers/EventsDisplay.ts @@ -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(), diff --git a/src/client/hud/layers/RadialMenu.ts b/src/client/hud/layers/RadialMenu.ts index 740a0b90e..3005e568f 100644 --- a/src/client/hud/layers/RadialMenu.ts +++ b/src/client/hud/layers/RadialMenu.ts @@ -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 && diff --git a/src/client/sound/Sounds.ts b/src/client/sound/Sounds.ts index 7368d4592..372c0ce94 100644 --- a/src/client/sound/Sounds.ts +++ b/src/client/sound/Sounds.ts @@ -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 = 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 { diff --git a/tests/client/sound/SoundManager.test.ts b/tests/client/sound/SoundManager.test.ts index a8827ebf1..e9b0d49fd 100644 --- a/tests/client/sound/SoundManager.test.ts +++ b/tests/client/sound/SoundManager.test.ts @@ -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);