Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
76d7174
Add FX sound effects for combat events
camclark Mar 10, 2026
9b4bb9a
Decouple sound playback from visual FX toggle and fix Prettier format…
camclark Mar 10, 2026
3d0c1c7
Wire up all approved sound effects from sound pack
camclark Mar 10, 2026
03685f4
Fix AllianceBroken sound playing for disconnected players
camclark Mar 10, 2026
ee947fb
Move AllianceBroken sound into addEvent branches
camclark Mar 10, 2026
1eeeed6
Merge branch 'main' into feature/fx-sound-effects
camclark Mar 11, 2026
8c785f9
Merge upstream/main into feature/fx-sound-effects
camclark Apr 3, 2026
c578fa8
Fix Prettier formatting in SoundManager.ts
camclark Apr 3, 2026
e452347
Refactor SoundManager from singleton to DI, lazy-load sound effects
camclark Apr 3, 2026
84438f9
Add fixed channel limit (4) with priority-based preemption for sound …
camclark Apr 4, 2026
11521e8
Merge remote-tracking branch 'upstream/main' into feature/fx-sound-ef…
camclark Apr 4, 2026
ff8af6d
Fix lobby leave path to stop background music, run prettier
camclark Apr 4, 2026
85efb84
Fix double-removal in channel preemption when stop fires synchronously
camclark Apr 4, 2026
14dee38
Make loadSoundEffect and unloadSoundEffect private
camclark Apr 4, 2026
bb3e259
Address remaining CodeRabbit nitpicks
camclark Apr 4, 2026
fafbbfb
Fix incorrect priority comments in channel tests
camclark Apr 4, 2026
33724dc
Remove dead code, fix remaining comment inaccuracies, add volume prop…
camclark Apr 4, 2026
24c3abd
Remove sound channel cap and priority preemption
camclark Apr 5, 2026
60b03bb
Switch sound dispatch from DI to EventBus
camclark Apr 5, 2026
d110664
Remove ISoundManager interface, rename file to SoundEvents.ts
camclark Apr 5, 2026
f6bcb49
Make SoundManager resilient to Howler errors
camclark Apr 5, 2026
916935d
Merge branch 'main' into feature/fx-sound-effects
camclark Apr 5, 2026
486a049
Dispose SoundManager on runner stop and guard AllianceSuggested sound
camclark Apr 5, 2026
5769074
Rename SoundEvents to Sounds, enum to type union, move sound config
camclark Apr 6, 2026
f45e264
Add channel limit of 8 with stop-oldest strategy
camclark Apr 6, 2026
25a66ca
Remove unused soundManager variable in channel tests
camclark Apr 6, 2026
522a3ce
Merge remote-tracking branch 'upstream/main' into feature/fx-sound-ef…
camclark Apr 6, 2026
b339d3d
Export MAX_CONCURRENT_SOUNDS and use it in tests
camclark Apr 6, 2026
9ffb7a2
Tighten channel-freed test assertion
camclark Apr 6, 2026
5100532
Per-item error handling in dispose, stop before remove in preemption
camclark Apr 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added resources/sounds/effects/alliance-broken.mp3
Binary file not shown.
Binary file added resources/sounds/effects/alliance-suggested.mp3
Binary file not shown.
Binary file added resources/sounds/effects/atom-hit.mp3
Binary file not shown.
Binary file added resources/sounds/effects/atom-launch.mp3
Binary file not shown.
Binary file added resources/sounds/effects/build-city.mp3
Binary file not shown.
Binary file added resources/sounds/effects/build-defense-post.mp3
Binary file not shown.
Binary file added resources/sounds/effects/build-port.mp3
Binary file not shown.
Binary file added resources/sounds/effects/build-warship.mp3
Binary file not shown.
Binary file added resources/sounds/effects/click.mp3
Binary file not shown.
Binary file added resources/sounds/effects/hydrogen-hit.mp3
Binary file not shown.
Binary file added resources/sounds/effects/hydrogen-launch.mp3
Binary file not shown.
Binary file added resources/sounds/effects/message.mp3
Binary file not shown.
Binary file added resources/sounds/effects/mirv-launch.mp3
Binary file not shown.
Binary file added resources/sounds/effects/sam-built.mp3
Binary file not shown.
Binary file added resources/sounds/effects/sam-hit.mp3
Binary file not shown.
Binary file added resources/sounds/effects/sam-shoot.mp3
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@evanpelle - this and silo-built have sound files ready but are marked "Waiting for Approval" in the spreadsheet. Happy for me to wire them up? If so I'll add them in a follow-up commit.

Binary file not shown.
Binary file added resources/sounds/effects/silo-built.mp3
Binary file not shown.
Binary file added resources/sounds/effects/warship-lost.mp3
Binary file not shown.
Binary file added resources/sounds/effects/warship-shot.mp3
Binary file not shown.
50 changes: 31 additions & 19 deletions src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -294,6 +305,7 @@ export class ClientGameRunner {
private transport: Transport,
private worker: WorkerClient,
private gameView: GameView,
private soundManager: SoundManager,
) {
this.lastMessageTime = Date.now();
}
Expand Down Expand Up @@ -346,7 +358,7 @@ export class ClientGameRunner {
}

public start() {
SoundManager.playBackgroundMusic();
this.soundManager.playBackgroundMusic();
Comment thread
camclark marked this conversation as resolved.
console.log("starting client game");

this.isActive = true;
Expand Down Expand Up @@ -524,7 +536,7 @@ export class ClientGameRunner {
}

public stop() {
SoundManager.stopBackgroundMusic();
this.soundManager.dispose();
if (!this.isActive) return;

this.isActive = false;
Expand Down
7 changes: 7 additions & 0 deletions src/client/graphics/layers/EventsDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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) {
Expand All @@ -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(),
Expand Down Expand Up @@ -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,
);
Expand All @@ -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"),
Expand Down
146 changes: 105 additions & 41 deletions src/client/graphics/layers/FxLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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))
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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) {
Expand All @@ -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(
Expand All @@ -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());
Comment thread
camclark marked this conversation as resolved.
} 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;
}
}

Expand All @@ -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);
}
}
Comment thread
camclark marked this conversation as resolved.

async init() {
Expand Down
2 changes: 2 additions & 0 deletions src/client/graphics/layers/RadialMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -506,6 +507,7 @@ export class RadialMenu implements Layer {
this.navigationInProgress
)
return;
this.eventBus.emit(new PlaySoundEffectEvent("click"));

if (
this.currentLevel > 0 &&
Expand Down
Loading
Loading