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: {