UNPKG

phaser

Version:

A fast, free and fun HTML5 Game Framework for Desktop and Mobile web browsers from the team at Phaser Studio Inc.

573 lines (486 loc) 16.9 kB
/** * @author Richard Davey <rich@phaser.io> * @author Pavle Goloskokovic <pgoloskokovic@gmail.com> (http://prunegames.com) * @copyright 2013-2025 Phaser Studio Inc. * @license {@link https://opensource.org/licenses/MIT|MIT License} */ var Base64ToArrayBuffer = require('../../utils/base64/Base64ToArrayBuffer'); var BaseSoundManager = require('../BaseSoundManager'); var Class = require('../../utils/Class'); var Events = require('../events'); var GameEvents = require('../../core/events'); var WebAudioSound = require('./WebAudioSound'); var GetFastValue = require('../../utils/object/GetFastValue'); /** * @classdesc * Web Audio API implementation of the Sound Manager. * * Not all browsers can play all audio formats. * * There is a good guide to what's supported: [Cross-browser audio basics: Audio codec support](https://developer.mozilla.org/en-US/Apps/Fundamentals/Audio_and_video_delivery/Cross-browser_audio_basics#Audio_Codec_Support). * * @class WebAudioSoundManager * @extends Phaser.Sound.BaseSoundManager * @memberof Phaser.Sound * @constructor * @since 3.0.0 * * @param {Phaser.Game} game - Reference to the current game instance. */ var WebAudioSoundManager = new Class({ Extends: BaseSoundManager, initialize: function WebAudioSoundManager (game) { /** * The AudioContext being used for playback. * * @name Phaser.Sound.WebAudioSoundManager#context * @type {AudioContext} * @since 3.0.0 */ this.context = this.createAudioContext(game); /** * Gain node responsible for controlling global muting. * * @name Phaser.Sound.WebAudioSoundManager#masterMuteNode * @type {GainNode} * @since 3.0.0 */ this.masterMuteNode = this.context.createGain(); /** * Gain node responsible for controlling global volume. * * @name Phaser.Sound.WebAudioSoundManager#masterVolumeNode * @type {GainNode} * @since 3.0.0 */ this.masterVolumeNode = this.context.createGain(); this.masterMuteNode.connect(this.masterVolumeNode); this.masterVolumeNode.connect(this.context.destination); /** * Destination node for connecting individual sounds to. * * @name Phaser.Sound.WebAudioSoundManager#destination * @type {AudioNode} * @since 3.0.0 */ this.destination = this.masterMuteNode; this.locked = this.context.state === 'suspended'; BaseSoundManager.call(this, game); if (this.locked) { if (game.isBooted) { this.unlock(); } else { game.events.once(GameEvents.BOOT, this.unlock, this); } } game.events.on(GameEvents.VISIBLE, this.onGameVisible, this); }, /** * Internal handler for Phaser.Core.Events#VISIBLE. * * Needed to handle resuming audio on iOS17/iOS18+ if you hide the browser, press * the home button, etc. See https://github.com/phaserjs/phaser/issues/6829 * * @method Phaser.Sound.WebAudioSoundManager#onGameVisible * @private * @since 3.88.0 */ onGameVisible: function () { var context = this.context; // setTimeout to avoid weird audio artifacts (thanks Apple) window.setTimeout(function () { if (context) { context.suspend(); context.resume(); } }, 100); }, /** * Method responsible for instantiating and returning AudioContext instance. * If an instance of an AudioContext class was provided through the game config, * that instance will be returned instead. This can come in handy if you are reloading * a Phaser game on a page that never properly refreshes (such as in an SPA project) * and you want to reuse already instantiated AudioContext. * * @method Phaser.Sound.WebAudioSoundManager#createAudioContext * @since 3.0.0 * * @param {Phaser.Game} game - Reference to the current game instance. * * @return {AudioContext} The AudioContext instance to be used for playback. */ createAudioContext: function (game) { var audioConfig = game.config.audio; if (audioConfig.context) { audioConfig.context.resume(); return audioConfig.context; } if (window.hasOwnProperty('AudioContext')) { return new AudioContext(); } else if (window.hasOwnProperty('webkitAudioContext')) { return new window.webkitAudioContext(); } }, /** * This method takes a new AudioContext reference and then sets * this Sound Manager to use that context for all playback. * * As part of this call it also disconnects the master mute and volume * nodes and then re-creates them on the new given context. * * @method Phaser.Sound.WebAudioSoundManager#setAudioContext * @since 3.21.0 * * @param {AudioContext} context - Reference to an already created AudioContext instance. * * @return {this} The WebAudioSoundManager instance. */ setAudioContext: function (context) { if (this.context) { this.context.close(); } if (this.masterMuteNode) { this.masterMuteNode.disconnect(); } if (this.masterVolumeNode) { this.masterVolumeNode.disconnect(); } this.context = context; this.masterMuteNode = context.createGain(); this.masterVolumeNode = context.createGain(); this.masterMuteNode.connect(this.masterVolumeNode); this.masterVolumeNode.connect(context.destination); this.destination = this.masterMuteNode; return this; }, /** * Adds a new sound into the sound manager. * * @method Phaser.Sound.WebAudioSoundManager#add * @since 3.0.0 * * @param {string} key - Asset key for the sound. * @param {Phaser.Types.Sound.SoundConfig} [config] - An optional config object containing default sound settings. * * @return {Phaser.Sound.WebAudioSound} The new sound instance. */ add: function (key, config) { var sound = new WebAudioSound(this, key, config); this.sounds.push(sound); return sound; }, /** * Decode audio data into a format ready for playback via Web Audio. * * The audio data can be a base64 encoded string, an audio media-type data uri, or an ArrayBuffer instance. * * The `audioKey` is the key that will be used to save the decoded audio to the audio cache. * * Instead of passing a single entry you can instead pass an array of `Phaser.Types.Sound.DecodeAudioConfig` * objects as the first and only argument. * * Decoding is an async process, so be sure to listen for the events to know when decoding has completed. * * Once the audio has decoded it can be added to the Sound Manager or played via its key. * * @method Phaser.Sound.WebAudioSoundManager#decodeAudio * @fires Phaser.Sound.Events#DECODED * @fires Phaser.Sound.Events#DECODED_ALL * @since 3.18.0 * * @param {(Phaser.Types.Sound.DecodeAudioConfig[]|string)} [audioKey] - The string-based key to be used to reference the decoded audio in the audio cache, or an array of audio config objects. * @param {(ArrayBuffer|string)} [audioData] - The audio data, either a base64 encoded string, an audio media-type data uri, or an ArrayBuffer instance. */ decodeAudio: function (audioKey, audioData) { var audioFiles; if (!Array.isArray(audioKey)) { audioFiles = [ { key: audioKey, data: audioData } ]; } else { audioFiles = audioKey; } var cache = this.game.cache.audio; var remaining = audioFiles.length; for (var i = 0; i < audioFiles.length; i++) { var entry = audioFiles[i]; var key = entry.key; var data = entry.data; if (typeof data === 'string') { data = Base64ToArrayBuffer(data); } var success = function (key, audioBuffer) { cache.add(key, audioBuffer); this.emit(Events.DECODED, key); remaining--; if (remaining === 0) { this.emit(Events.DECODED_ALL); } }.bind(this, key); var failure = function (key, error) { // eslint-disable-next-line no-console console.error('Error decoding audio: ' + key + ' - ', error ? error.message : ''); remaining--; if (remaining === 0) { this.emit(Events.DECODED_ALL); } }.bind(this, key); this.context.decodeAudioData(data, success, failure); } }, /** * Sets the X and Y position of the Spatial Audio listener on this Web Audios context. * * If you call this method with no parameters it will default to the center-point of * the game canvas. Depending on the type of game you're making, you may need to call * this method constantly to reset the listener position as the camera scrolls. * * Calling this method does nothing on HTML5Audio. * * @method Phaser.Sound.WebAudioSoundManager#setListenerPosition * @since 3.60.0 * * @param {number} [x] - The x position of the Spatial Audio listener. * @param {number} [y] - The y position of the Spatial Audio listener. */ setListenerPosition: function (x, y) { if (x === undefined) { x = this.game.scale.width / 2; } if (y === undefined) { y = this.game.scale.height / 2; } this.listenerPosition.set(x, y); return this; }, /** * Unlocks Web Audio API on the initial input event. * * Read more about how this issue is handled here in [this article](https://medium.com/@pgoloskokovic/unlocking-web-audio-the-smarter-way-8858218c0e09). * * @method Phaser.Sound.WebAudioSoundManager#unlock * @since 3.0.0 */ unlock: function () { var _this = this; var body = document.body; var unlockHandler = function unlockHandler () { if (_this.context && body) { var bodyRemove = body.removeEventListener.bind(body); _this.context.resume().then(function () { bodyRemove('touchstart', unlockHandler); bodyRemove('touchend', unlockHandler); bodyRemove('mousedown', unlockHandler); bodyRemove('mouseup', unlockHandler); bodyRemove('keydown', unlockHandler); _this.unlocked = true; }, function () { bodyRemove('touchstart', unlockHandler); bodyRemove('touchend', unlockHandler); bodyRemove('mousedown', unlockHandler); bodyRemove('mouseup', unlockHandler); bodyRemove('keydown', unlockHandler); }); } }; if (body) { body.addEventListener('touchstart', unlockHandler, false); body.addEventListener('touchend', unlockHandler, false); body.addEventListener('mousedown', unlockHandler, false); body.addEventListener('mouseup', unlockHandler, false); body.addEventListener('keydown', unlockHandler, false); } }, /** * Method used internally for pausing sound manager if * Phaser.Sound.WebAudioSoundManager#pauseOnBlur is set to true. * * @method Phaser.Sound.WebAudioSoundManager#onBlur * @protected * @since 3.0.0 */ onBlur: function () { if (!this.locked) { this.context.suspend(); } }, /** * Method used internally for resuming sound manager if * Phaser.Sound.WebAudioSoundManager#pauseOnBlur is set to true. * * @method Phaser.Sound.WebAudioSoundManager#onFocus * @protected * @since 3.0.0 */ onFocus: function () { var context = this.context; if (context && !this.locked && (context.state === 'suspended' || context.state === 'interrupted')) { context.resume(); } }, /** * Update method called on every game step. * * Removes destroyed sounds and updates every active sound in the game. * * @method Phaser.Sound.WebAudioSoundManager#update * @protected * @fires Phaser.Sound.Events#UNLOCKED * @since 3.0.0 * * @param {number} time - The current timestamp as generated by the Request Animation Frame or SetTimeout. * @param {number} delta - The delta time elapsed since the last frame. */ update: function (time, delta) { var listener = this.context.listener; if (listener && listener.positionX !== undefined) { var x = GetFastValue(this.listenerPosition, 'x', null); var y = GetFastValue(this.listenerPosition, 'y', null); if (x && x !== this._spatialx) { this._spatialx = listener.positionX.value = x; } if (y && y !== this._spatialy) { this._spatialy = listener.positionY.value = y; } } BaseSoundManager.prototype.update.call(this, time, delta); // Resume interrupted audio on iOS only if the game has focus if (!this.gameLostFocus) { this.onFocus(); } }, /** * Calls Phaser.Sound.BaseSoundManager#destroy method * and cleans up all Web Audio API related stuff. * * @method Phaser.Sound.WebAudioSoundManager#destroy * @since 3.0.0 */ destroy: function () { this.destination = null; this.masterVolumeNode.disconnect(); this.masterVolumeNode = null; this.masterMuteNode.disconnect(); this.masterMuteNode = null; if (this.game.config.audio.context) { this.context.suspend(); } else { var _this = this; this.context.close().then(function () { _this.context = null; }); } this.game.events.off(GameEvents.VISIBLE, this.onGameVisible, this); BaseSoundManager.prototype.destroy.call(this); }, /** * Sets the muted state of all this Sound Manager. * * @method Phaser.Sound.WebAudioSoundManager#setMute * @fires Phaser.Sound.Events#GLOBAL_MUTE * @since 3.3.0 * * @param {boolean} value - `true` to mute all sounds, `false` to unmute them. * * @return {Phaser.Sound.WebAudioSoundManager} This Sound Manager. */ setMute: function (value) { this.mute = value; return this; }, /** * @name Phaser.Sound.WebAudioSoundManager#mute * @type {boolean} * @fires Phaser.Sound.Events#GLOBAL_MUTE * @since 3.0.0 */ mute: { get: function () { return (this.masterMuteNode.gain.value === 0); }, set: function (value) { this.masterMuteNode.gain.setValueAtTime(value ? 0 : 1, 0); this.emit(Events.GLOBAL_MUTE, this, value); } }, /** * Sets the volume of this Sound Manager. * * @method Phaser.Sound.WebAudioSoundManager#setVolume * @fires Phaser.Sound.Events#GLOBAL_VOLUME * @since 3.3.0 * * @param {number} value - The global volume of this Sound Manager. * * @return {Phaser.Sound.WebAudioSoundManager} This Sound Manager. */ setVolume: function (value) { this.volume = value; return this; }, /** * @name Phaser.Sound.WebAudioSoundManager#volume * @type {number} * @fires Phaser.Sound.Events#GLOBAL_VOLUME * @since 3.0.0 */ volume: { get: function () { return this.masterVolumeNode.gain.value; }, set: function (value) { this.masterVolumeNode.gain.setValueAtTime(value, 0); this.emit(Events.GLOBAL_VOLUME, this, value); } } }); module.exports = WebAudioSoundManager;