diff --git a/src/client/graphics/layers/OptionsMenu.ts b/src/client/graphics/layers/OptionsMenu.ts
index ed984904e..4d7978202 100644
--- a/src/client/graphics/layers/OptionsMenu.ts
+++ b/src/client/graphics/layers/OptionsMenu.ts
@@ -6,6 +6,7 @@ import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler";
+import { soundManager } from "../../SoundManager";
import { PauseGameEvent } from "../../Transport";
import { translateText } from "../../Utils";
import { Layer } from "./Layer";
@@ -75,9 +76,7 @@ export class OptionsMenu extends LitElement implements Layer {
private onExitButtonClick() {
const isAlive = this.game.myPlayer()?.isAlive();
if (isAlive) {
- const isConfirmed = confirm(
- translateText("help_modal.exit_confirmation"),
- );
+ const isConfirmed = confirm("Are you sure you want to exit the game?");
if (!isConfirmed) return;
}
// redirect to the home page
@@ -137,6 +136,21 @@ export class OptionsMenu extends LitElement implements Layer {
this.requestUpdate();
}
+ private onMuteButtonClick() {
+ if (soundManager.isMuted()) {
+ soundManager.unmute();
+ } else {
+ soundManager.mute();
+ }
+ this.requestUpdate();
+ }
+
+ private onVolumeChange(e: Event) {
+ const volume = parseFloat((e.target as HTMLInputElement).value);
+ soundManager.setMasterVolume(volume);
+ this.requestUpdate();
+ }
+
init() {
console.log("init called from OptionsMenu");
this.showPauseButton =
@@ -260,6 +274,21 @@ export class OptionsMenu extends LitElement implements Layer {
? "Focus locked"
: "Hover focus"),
})} -->
+
+ ${button({
+ onClick: this.onMuteButtonClick,
+ title: "Mute",
+ children: soundManager.isMuted() ? "🔇" : "🔊",
+ })}
+
+
`;
diff --git a/src/client/soundmanager.ts b/src/client/soundmanager.ts
new file mode 100644
index 000000000..38de1fcb7
--- /dev/null
+++ b/src/client/soundmanager.ts
@@ -0,0 +1,191 @@
+import { UserSettings } from "../core/game/UserSettings";
+
+class SoundManager {
+ private static instance: SoundManager;
+ private audioContext: AudioContext;
+ private soundBuffers: Map = new Map();
+ private musicSource: AudioBufferSourceNode | null = null;
+ private userSettings: UserSettings;
+ private masterVolume: GainNode;
+ private musicVolume: GainNode;
+ private soundEffectsVolume: GainNode;
+ private muted: boolean;
+ private activeSources: Map = new Map();
+
+ private constructor() {
+ this.audioContext = new (window.AudioContext ||
+ (window as any).webkitAudioContext)();
+ this.userSettings = new UserSettings();
+ this.masterVolume = this.audioContext.createGain();
+ this.musicVolume = this.audioContext.createGain();
+ this.soundEffectsVolume = this.audioContext.createGain();
+ this.musicVolume.connect(this.masterVolume);
+ this.soundEffectsVolume.connect(this.masterVolume);
+ this.masterVolume.connect(this.audioContext.destination);
+ this.muted = this.userSettings.getMuted();
+ this.setMasterVolume(this.userSettings.getVolume());
+
+ if (this.muted) {
+ this.mute();
+ }
+
+ if (this.userSettings.getMuteMusic()) {
+ this.muteMusic();
+ }
+
+ if (this.userSettings.getMuteSoundEffects()) {
+ this.muteSoundEffects();
+ }
+ }
+
+ public static getInstance(): SoundManager {
+ if (!SoundManager.instance) {
+ SoundManager.instance = new SoundManager();
+ }
+ return SoundManager.instance;
+ }
+
+ public async loadSounds(
+ sounds: { name: string; path: string }[],
+ ): Promise {
+ const promises = sounds.map((sound) =>
+ this.loadSound(sound.name, sound.path),
+ );
+ await Promise.all(promises);
+ }
+
+ private async loadSound(name: string, path: string): Promise {
+ try {
+ const response = await fetch(path);
+ const arrayBuffer = await response.arrayBuffer();
+ const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
+ this.soundBuffers.set(name, audioBuffer);
+ } catch (error) {
+ console.error(`Failed to load sound: ${path}`, error);
+ }
+ }
+
+ public playSound(name: string, loop: boolean = false): void {
+ const soundBuffer = this.soundBuffers.get(name);
+ if (soundBuffer) {
+ const source = this.audioContext.createBufferSource();
+ source.buffer = soundBuffer;
+ source.loop = loop;
+ source.connect(this.soundEffectsVolume);
+ source.start(0);
+
+ if (!this.activeSources.has(name)) {
+ this.activeSources.set(name, []);
+ }
+ this.activeSources.get(name)?.push(source);
+
+ source.onended = () => {
+ const sources = this.activeSources.get(name);
+ if (sources) {
+ const index = sources.indexOf(source);
+ if (index > -1) {
+ sources.splice(index, 1);
+ }
+ }
+ };
+ }
+ }
+
+ public stopSound(name: string): void {
+ const sources = this.activeSources.get(name);
+ if (sources) {
+ sources.forEach((source) => source.stop());
+ this.activeSources.set(name, []);
+ }
+ }
+
+ public playMusic(name: string): void {
+ if (this.musicSource) {
+ this.musicSource.stop();
+ }
+ const musicBuffer = this.soundBuffers.get(name);
+ if (musicBuffer) {
+ this.musicSource = this.audioContext.createBufferSource();
+ this.musicSource.buffer = musicBuffer;
+ this.musicSource.loop = true;
+ this.musicSource.connect(this.musicVolume);
+ this.musicSource.start(0);
+ }
+ }
+
+ public setMasterVolume(volume: number): void {
+ this.masterVolume.gain.setValueAtTime(
+ volume,
+ this.audioContext.currentTime,
+ );
+ this.userSettings.setVolume(volume);
+ }
+
+ public getMasterVolume(): number {
+ return this.masterVolume.gain.value;
+ }
+
+ public mute(): void {
+ this.masterVolume.gain.setValueAtTime(0, this.audioContext.currentTime);
+ this.muted = true;
+ this.userSettings.setMuted(true);
+ }
+
+ public unmute(): void {
+ this.masterVolume.gain.setValueAtTime(
+ this.userSettings.getVolume(),
+ this.audioContext.currentTime,
+ );
+ this.muted = false;
+ this.userSettings.setMuted(false);
+ }
+
+ public isMuted(): boolean {
+ return this.muted;
+ }
+
+ public muteMusic(): void {
+ this.musicVolume.gain.setValueAtTime(0, this.audioContext.currentTime);
+ this.userSettings.setMuteMusic(true);
+ }
+
+ public unmuteMusic(): void {
+ this.musicVolume.gain.setValueAtTime(1, this.audioContext.currentTime);
+ this.userSettings.setMuteMusic(false);
+ }
+
+ public muteSoundEffects(): void {
+ this.soundEffectsVolume.gain.setValueAtTime(0, this.audioContext.currentTime);
+ this.userSettings.setMuteSoundEffects(true);
+ }
+
+ public unmuteSoundEffects(): void {
+ this.soundEffectsVolume.gain.setValueAtTime(1, this.audioContext.currentTime);
+ this.userSettings.setMuteSoundEffects(false);
+ }
+
+ public playSpatialSound(name: string, x: number, y: number, z: number): void {
+ const soundBuffer = this.soundBuffers.get(name);
+ if (soundBuffer) {
+ const source = this.audioContext.createBufferSource();
+ source.buffer = soundBuffer;
+
+ const panner = this.audioContext.createPanner();
+ panner.panningModel = "HRTF";
+ panner.distanceModel = "inverse";
+ panner.refDistance = 1;
+ panner.maxDistance = 10000;
+ panner.rolloffFactor = 1;
+ panner.coneInnerAngle = 360;
+ panner.coneOuterAngle = 0;
+ panner.coneOuterGain = 0;
+ panner.setPosition(x, y, z);
+
+ source.connect(panner);
+ panner.connect(this.masterVolume);
+ source.start(0);
+ }
+ }
+}
+
+export const soundManager = SoundManager.getInstance();
\ No newline at end of file
diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts
index f9a60d448..3c62bc1d0 100644
--- a/src/core/game/UserSettings.ts
+++ b/src/core/game/UserSettings.ts
@@ -87,6 +87,42 @@ export class UserSettings {
}
}
+ getVolume(): number {
+ const value = localStorage.getItem("volume");
+ if (value) {
+ return parseFloat(value);
+ }
+ return 1;
+ }
+
+ setVolume(volume: number): void {
+ localStorage.setItem("volume", volume.toString());
+ }
+
+ getMuted(): boolean {
+ return this.get("muted", false);
+ }
+
+ setMuted(muted: boolean): void {
+ this.set("muted", muted);
+ }
+
+ getMuteMusic(): boolean {
+ return this.get("muteMusic", false);
+ }
+
+ setMuteMusic(mute: boolean): void {
+ this.set("muteMusic", mute);
+ }
+
+ getMuteSoundEffects(): boolean {
+ return this.get("muteSoundEffects", false);
+ }
+
+ setMuteSoundEffects(mute: boolean): void {
+ this.set("muteSoundEffects", mute);
+ }
+
getSelectedPattern(): string | undefined {
return localStorage.getItem(PATTERN_KEY) ?? undefined;
}
diff --git a/src/global.d.ts b/src/global.d.ts
index 5c3aadc48..d922bfc09 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -40,3 +40,7 @@ declare module "*.xml" {
const value: string;
export default value;
}
+declare module "*.mp3" {
+ const value: string;
+ export default value;
+}
diff --git a/webpack.config.js b/webpack.config.js
index 0820e8cd0..73841c21d 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -93,6 +93,13 @@ export default async (env, argv) => {
filename: "fonts/[name].[contenthash][ext]", // Added content hash and fixed path
},
},
+ {
+ test: /\.mp3$/,
+ type: "asset/resource",
+ generator: {
+ filename: "sound/[name].[contenthash][ext]",
+ },
+ },
],
},
resolve: {