define([ 'howler', 'js/misc/physics', 'js/system/events', 'js/config', 'js/system/globals' ], function ( howler, physics, events, config, globals ) { const globalVolume = 0.3; let soundVolume = config.soundVolume; let musicVolume = config.musicVolume; const globalScopes = ['ui']; const minDistance = 10; const fadeDuration = 1800; window.Howler.volume(globalVolume); return { sounds: [], muted: false, currentMusic: null, init: function () { events.on('onToggleAudio', this.onToggleAudio.bind(this)); events.on('onPlaySound', this.playSound.bind(this)); events.on('onPlaySoundAtPosition', this.onPlaySoundAtPosition.bind(this)); events.on('onManipulateVolume', this.onManipulateVolume.bind(this)); const { clientConfig: { sounds: loadSounds } } = globals; Object.entries(loadSounds).forEach(([ scope, soundList ]) => { soundList.forEach(({ name: soundName, file }) => { this.addSound({ name: soundName, file, scope: 'ui', autoLoad: true }); }); }); this.onToggleAudio(config.playAudio); }, //Fired when a character rezones // 'scope' is the new zone name unload: function (newScope) { const { sounds } = this; for (let i = 0; i < sounds.length; i++) { const { scope, sound } = sounds[i]; if (!globalScopes.includes(scope) && scope !== newScope) { if (sound) sound.unload(); sounds.splice(i, 1); i--; } } }, onPlaySoundAtPosition: function ({ position: { x, y }, file, volume }) { const { player: { x: playerX, y: playerY } } = window; const dx = Math.abs(x - playerX); const dy = Math.abs(y - playerY); const distance = Math.max(dx, dy); const useVolume = volume * (1 - (Math.pow(distance, 2) / Math.pow(minDistance, 2))); //eslint-disable-next-line no-undef, no-unused-vars const sound = new Howl({ src: [file], volume: useVolume, loop: false, autoplay: true, html5: false }); }, playSound: function (soundName) { const soundEntry = this.sounds.find(s => s.name === soundName); if (!soundEntry) return; const { sound } = soundEntry; sound.volume(soundVolume / 100); sound.play(); }, playSoundHelper: function (soundEntry, volume) { const { sound } = soundEntry; if (!sound) { const { file, loop } = soundEntry; soundEntry.sound = this.loadSound(file, loop, true, volume); return; } soundEntry.volume = volume; volume *= (soundVolume / 100); if (sound.playing()) { if (sound.volume() === volume) return; sound.volume(volume); } else { sound.volume(volume); sound.play(); } }, playMusicHelper: function (soundEntry) { const { sound } = soundEntry; if (!sound) { const { file, loop } = soundEntry; soundEntry.volume = musicVolume; soundEntry.sound = this.loadSound(file, loop, true, musicVolume / 100); return; } if (!sound.playing()) { soundEntry.volume = 0; sound.volume(0); sound.play(); } if (this.currentMusic === soundEntry && sound.volume() === musicVolume / 100) return; this.currentMusic = soundEntry; sound.fade(sound.volume(), (musicVolume / 100), fadeDuration); }, stopSoundHelper: function (soundEntry) { const { sound, music } = soundEntry; if (!sound || !sound.playing()) return; if (music) sound.fade(sound.volume(), 0, fadeDuration); else { sound.stop(); sound.volume(0); } }, updateSounds: function (playerX, playerY) { this.sounds.forEach(s => { const { x, y, area, music, scope } = s; if (music || scope === 'ui') return; let distance = 0; if (!area) { let dx = Math.abs(x - playerX); let dy = Math.abs(y - playerY); distance = Math.max(dx, dy); } else if (!physics.isInPolygon(playerX, playerY, area)) distance = physics.distanceToPolygon([playerX, playerY], area); if (distance > minDistance) { this.stopSoundHelper(s); return; } //Exponential fall-off const volume = s.maxVolume * (1 - (Math.pow(distance, 2) / Math.pow(minDistance, 2))); this.playSoundHelper(s, volume); }); }, updateMusic: function (playerX, playerY) { const sounds = this.sounds; const areaMusic = sounds.filter(s => s.music && s.area); //All music that should be playing because we're in the correct polygon const playMusic = areaMusic.filter(s => physics.isInPolygon(playerX, playerY, s.area)); //All music that should stop playing because we're in the incorrect polygon const stopMusic = areaMusic.filter(s => s.sound && s.sound.playing() && !playMusic.some(m => m === s)); //Stop or start defaultMusic, depending on whether anything else was found const defaultMusic = sounds.filter(a => a.defaultMusic); if (defaultMusic) { if (!playMusic.length) defaultMusic.forEach(m => this.playMusicHelper(m)); else defaultMusic.forEach(m => this.stopSoundHelper(m)); } //If there's a music entry in both 'play' and 'stop' that shares a fileName, we'll just ignore it. This happens when you // move to a building interior, for example. Unfortunately, we can't have different volume settings for these kinds of entries. // The one that starts playing first will get priority const filesPlaying = [...playMusic.map(p => p.file), ...stopMusic.map(p => p.file)]; playMusic.spliceWhere(p => filesPlaying.filter(f => f === p.file).length > 1); stopMusic.spliceWhere(p => filesPlaying.filter(f => f === p.file).length > 1); stopMusic.forEach(m => this.stopSoundHelper(m)); playMusic.forEach(m => this.playMusicHelper(m)); }, update: function (playerX, playerY) { this.updateSounds(playerX, playerY); this.updateMusic(playerX, playerY); }, addSound: function ( { name: soundName, scope, file, volume = 1, x, y, w, h, area, music, defaultMusic, autoLoad, loop } ) { if (this.sounds.some(s => s.file === file)) { if (window.player?.x !== undefined) this.update(window.player.x, window.player.y); return; } if (!area && w) { area = [ [x, y], [x + w, y], [x + w, y + h], [x, y + h] ]; } let sound = null; if (autoLoad) sound = this.loadSound(file, loop); if (music) volume = 0; const soundEntry = { name: soundName, sound, scope, file, loop, x, y, volume, maxVolume: volume, area, music, defaultMusic }; this.sounds.push(soundEntry); if (window.player?.x !== undefined) this.update(window.player.x, window.player.y); return soundEntry; }, loadSound: function (file, loop = false, autoplay = false, volume = 1) { //eslint-disable-next-line no-undef const sound = new Howl({ src: [file], volume, loop, autoplay, html5: loop }); return sound; }, onToggleAudio: function (isAudioOn) { this.muted = !isAudioOn; this.sounds.forEach(s => { if (!s.sound) return; s.sound.mute(this.muted); }); if (!window.player) return; const { player: { x, y } } = window; this.update(x, y); }, onManipulateVolume: function ({ soundType, delta }) { if (soundType === 'sound') soundVolume = Math.max(0, Math.min(100, soundVolume + delta)); else if (soundType === 'music') musicVolume = Math.max(0, Math.min(100, musicVolume + delta)); const volume = soundType === 'sound' ? soundVolume : musicVolume; events.emit('onVolumeChange', { soundType, volume }); const { player: { x, y } } = window; this.update(x, y); }, destroySoundEntry: function (soundEntry) { this.sounds.spliceWhere(s => s === soundEntry); } }; });