Source: sg2d-sound.js

"use strict";

import SGModel from './libs/sg-model/sg-model.js';
import SG2DUtils from './sg2d-utils.js';
import SG2DMath from './sg2d-math.js';
import SG2DCamera from './sg2d-camera.js';

/**
 * Звуки и музыка. Поддержка 2D окружения
 * @class
 * @alias SG2D.Sound
 * @returns {SG2D.Sound}
 */
var SG2DSound = { EMPTY: true };

function _SG2DSound() {
	
	Object.assign(this, new SGModel({
		music: true,
		musicVolume: 100, // from 0 to 100
		sounds: true,
		soundsVolume: 100, // from 0 to 100
		bass: false,
		muteOnLossFocus: true,
		volumeDecreaseDistance: 10, // Units changes in clusters. Distance at which the sound can no longer be heard. If value is 0, sound does not subside with increasing distance
		environment2D: true,
		
		/* // TODO: **
		 * Координаты относительно которых рассчитывается дистанция до источника звука
		 * @example
		 * ```js
		 * player.on("position", (position)=>{ this.set("environment2DPosition", position); }); // Пример обновления координат в экземпляре SG2D.Sound
		 * ```
		 */
		//environment2DPosition: { x: 0, y: 0 }, TODO
		
		view: "", // Current view code
		_focusLoss: false // global mute when focus loss browser
	}));
	
	this.sounds = {};
	this.musics = {}; // flat list of all music ([] * PIXI.sound.Sound)
	this.music_views = {}; // Views and list music
	this.music_view = null; // Current view object
	
	// @protected
	this.bass = void 0;
	
	this._options = {
		config: void 0,
		music_dir:  void 0,
		sounds_dir: void 0,
		library_pathfile: void 0
	};
	
	this._initializationRunned = false;
	
	this._gestureDetected = false;
	
	/**
	 * Загрузчик звуковой библиотеки и установщик параметров. SG2D.Sound.load() можно вызывать много раз для загрузки индивидуальной конфигурации, например, для каждого уровня игры.
	 * @function SG2D.Sound#load
	 * @param {object}		[options={}] - Настройки путей
	 * @param {object|string}	[options.config="./res/sound.json"] - Объект конфигурации или путь к JSON-файлус конфигурацией
	 * @param {string}			[options.music_dir="./res/music/"] - Основная директория с музыкой
	 * @param {string}			[options.sounds_dir="./res/sounds/"] - Основная директория со звуками
	 * @param {string}			[options.library_pathfile="./libs/pixi/pixi-sound.js"] - Путь к файлу библиотеки PIXI.Sound применяется только при первой передаче параметра
	 * @param {object}		[properties={}] - Начальные параметры звука
	 * @param {boolean}		[properties.sounds=true] - Включить звуки
	 * @param {boolean}		[properties.music=true] - Включить музыку
	 * @param {number}			[properties.musicVolume=100] - Громкость музыки от 0 до 100
	 * @param {number}			[properties.soundsVolume=100] - Громкость звуков от 0 до 100
	 * @param {boolean}		[properties.muteOnLossFocus=true] - Выключать звук при потере фокуса приложения
	 * @param {number}			[properties.volumeDecreaseDistance=0] - Громкость звука зависит от расстояния до источника звука
	 * @param {boolean}		[properties.environment2D=true] - Включить 2D звуковое окружение
	 * @param {boolean}		[properties.bass=false] - Усиленные низкие частоты
	 * @param {string}		[properties.view=void 0] - Текущий view
	 * @return {Promise}
	 */
	this.load = (options = {}, properties = {})=>{
		
		if (typeof window === "undefined" || window.document === void 0) {
			console.error("Error in SG2D.Sound.load()! window.document is not set!");
			return;
		}
		
		let promise;
		
		this._options.config = options.config;
		this._options.music_dir = options.music_dir || this._options.music_dir || "./res/music/";
		this._options.sounds_dir = options.sounds_dir || this._options.sounds_dir || "./res/sounds/";
		this._options.library_pathfile = options.library_pathfile || this._options.library_pathfile || "./libs/pixi/pixi-sound.js";
		
		for (var p in properties) this.set(p, properties[p]);
		
		if (! this._initializationRunned) {
			this._initializationRunned = true;
			
			this.onEndMusic = this._onEndMusic.bind(this);
			this.visibilityChange = this._visibilityChange.bind(this);
			document.addEventListener("visibilitychange", this._visibilityChange);
			
			this.on("music", (music)=>{
				if (music) {
					this.musicResume();
				} else {
					this.musicPause();
				}
			});
			
			this.on("musicVolume", (musicVolume)=>{
				if (this.music_view && this.music_view.instance) {
					this.music_view.instance.volume = musicVolume / 100;
				}
			});
			
			this.on("view", (view)=>{
				this.musicPlay(view);
			});
			
			promise = new Promise((resolve, reject)=>{
				if (this._gestureDetected) {
					this._libraryLoad(options, resolve, reject);
				} else {
					let t = setInterval(()=>{
						if (this._gestureDetected) {
							clearInterval(t);
							this._libraryLoad(options, resolve, reject);
						}
					}, 100);
				}
			});
			
		} else if (this._options.config) {
			promise = new Promise((resolve, reject)=>{
				this._loadConfig(this._options.config, resolve, reject);
			});
		} else {
			promise = Promise.resolve();
		}
		
		return promise;
	};
	
	this._libraryLoaded = false;
	
	this._libraryLoad = (options = {}, resolve, reject)=>{
		
		let promise;
		
		if (! this._libraryLoaded) {
			this._libraryLoaded = true;
			
			promise = SG2DUtils.loadJS(this._options.library_pathfile, (event)=>{
				
				this.visibilityChange();
				
				this.bass = [
					new PIXI.sound.filters.ReverbFilter(1, 100),
					new PIXI.sound.filters.EqualizerFilter(13, 15, 6, -1, 0, 0, 0, 0, 0, 0)
				];
				
				if (options.config) {
					this._loadConfig(options.config, resolve, reject);
				} else {
					resolve();
				}
			});
			
			promise.catch(error=>{
				reject("Error in SG2D.Sound! See options.library_pathfile=\"" + this._options.library_pathfile + "\"!");
			});
		} else {
			promise = Promise.resolve();
		}
		
		return promise;
	};
	
	this._sg2dconnect = (sg2d)=>{
		this.sg2d = sg2d;
	};
	
	this._loadConfig = (config, resolve, reject)=>{
		if (typeof config === "object") {
			this._parseConfig(config);
			resolve();
		} else if (typeof config === "string") {
			fetch(config).then(response=>{
				if (! response.ok) {
					let sError = "Error in SG2D.Sound! response.status="+response.status+". See config=\"" + config + "\"!";
					reject(sError);
					throw new Error(sError);
				} else {
					return response.json();
				}
			}).then(json=>{
				this._parseConfig.call(this, json);
				resolve();
			}).catch(error=>{
				reject("Error in SG2D.Sound! See config=\"" + config + "\"!");
				debugger; // TODO
			});
		}
	};
	
	this._parseConfig = (json)=>{
		var temp, sound;
		if (json.sounds) {
			for (var name in json.sounds) {
				temp = json.sounds[name];
				this.sounds[name] = sound = typeof temp === "object" ? temp : { file: temp };
				sound.name = name;
				sound.sound = PIXI.sound.add(name, {
					autoPlay: false,
					preload: true,
					url: this._options.sounds_dir+sound.file,
					loaded: (err, sound)=>{
						if (err) {
							if (typeof sound !== "undefined") sound.isError = true;
							console.warn(''+err);
						}
					}
				});

				if (this.properties.bass) {
					sound.sound.filters = this.bass;
				}
			}
		}
		if (json.music) {
			for (let viewcode in json.music) {
				temp = json.music[viewcode];
				let list = typeof temp === "string" ? [temp] : (Array.isArray(temp) ? temp : []);
				let musics = [];
				for (var i = 0; i < list.length; i++) {
					musics[i] = this.musics[list[i]] = PIXI.sound.add(list[i], {
						autoPlay: false,
						preload: false,
						singleInstance: true,
						url: this._options.music_dir+list[i],
						loaded: (err, music)=>{
							if (err) {
								if (typeof music !== "undefined") music.isError = true;
								console.warn(''+err);
							} else {
								if (this.properties.view && this.properties.view === viewcode && ! this.music_view) {
									this.musicPlay(viewcode);
								}
							}
						},
						complete: (sound, b, c)=>{
							debugger;
						}
					});
				}
				this.music_views[viewcode] = {
					viewcode: viewcode, status: false,
					list: musics, current_index: 0,
					instance: null // Current music instance
				}
			}
		}
		
		if (this.properties.view && (! this.music_view || this.music_view.viewcode !== this.properties.view)) {
			this.musicPlay(this.properties.view);
		}
	};
	
	this._visibilityChange = ()=>{
		if (document.visibilityState === "visible") {
			this.set("_focusLoss", false);
			if (PIXI.sound) PIXI.sound.unmuteAll();
		} else {
			this.set("_focusLoss", true);
			if (PIXI.sound && this.properties.muteOnLossFocus) PIXI.sound.muteAll();
		}
	};
	
	/**
	 * Запуск проигрывания музыки
	 * @function SG2D.Sound#musicPlay
	 * @param {string|bool}	[viewcode=true] - Код страницы или true. Если true, то воспроизводится текущая музыка.
	 * @param {object}		[options={}] - Параметры переданные в метод play(), например громкость звука, скорость воспроизведения, время начала и окончания.
	 * @param {boolean}	[strict=false] - При true если мелодия не загружена, то консоль выдаст ошибку.
	 * @return {boolean} true, если успешно
	 */
	this.musicPlay = (viewcode = true, options = {}, strict = false)=>{
		
		if (viewcode === true) {
			if (! this.music_view) return false;
		} else {
			if (this.music_view && this.music_view.viewcode === viewcode) {
				// no code
			} else {
				if (this.music_view) {
					this.music_view.instance && this.music_view.instance.destroy();
					this.music_view.instance = null;
					this.music_view.status = false;
				}
				this.music_view = this.music_views[viewcode];
			}
		}
		
		if (! this.music_view) {
			if (strict) console.error("SG2D.Sound Error! The music file may not have been loaded yet!");
			return false;
		}
		
		this.set("view", this.music_view.viewcode, void 0, SGModel.FLAG_NO_CALLBACKS);
		
		this.music_view.status = true;
		
		if (! this.music_view.list.length) return false;
		
		if (this.properties.music && PIXI.sound) {
			
			options = SGModel.defaults(options, {
				volume: this.properties.musicVolume / 100
			});
			
			if (this.music_view.instance) {
				if (this.music_view.instance.paused) {
					this.music_view.instance.paused = false;
				}
			} else {
			
				let music = this.music_view.list[this.music_view.current_index];
				if (music.isError) return false;

				if (this.properties.bass) music.filters = this.bass;

				let music_view = this.music_view;
				let result = music.play(options);
				if (typeof result.then === "function") {
					result.then((instance)=>{
						music_view.instance = instance;
						instance.on("end", this.onEndMusic);
						if (! music_view.status) music_view.instance.paused = true;
					});
				} else {
					music_view.instance = result;
					music_view.instance.on("end", this.onEndMusic);
				}
			}
		}
		return true;
	};
	
	this._onEndMusic = (instance)=>{
		this.music_view.current_index++;
		if (this.music_view.current_index >= this.music_view.list.length) this.music_view.current_index = 0;
		this.music_view.instance = null;
		this.musicPlay();
	};
	
	this.musicPause = ()=>{
		if (this.music_view) {
			this.music_view.status = false;
			if (this.music_view.instance) {
				this.music_view.instance.paused = true;
			}
		}
	};
	
	this.musicResume = ()=>{
		this.musicPlay();
	};
	
	
	this._sound = { file: "" };
	
	this._config = {};
	
	/**
	 * Play sound
	 * @function SG2D.Sound#play
	 * @param {string|object} Sound name or base sound object from sounds.json
	 * @param {object} config_or_tile Sound settings overriding basic sounds.json or Tile instance
	 * @param {object} tile If a tile is specified, then position is taken from it to calculate the distance and sound volume
	 * @return {object} Экземпляр звука PIXI Sound
	 */
	this.play = (sound, config_or_tile = void 0, tile = void 0)=>{
		
		var instance = null;
		
		if (typeof sound === "string") {
			let name = sound;
			sound = this.sounds[name];
			if (! sound) this.sounds[name] = sound = { name: name, file: name + ".mp3" };
		}
		
		var config = SG2DSound._config;
		if (typeof config_or_tile === "object") {
			if (config_or_tile.constructor.isTile) {
				tile = config_or_tile;
			} else {
				config = config_or_tile;
			}
		}
		
		if (this.properties.sounds && PIXI.sound) {
			if (! sound.sound) return; // Sounds have not yet been loaded into loadLibAndSounds()
			if (! sound.sound.isLoaded) return false;
			
			var options = {};
			options.volume = (config.volume || sound.volume || 1) * this.properties.soundsVolume / 100;
			
			if (this.properties.volumeDecreaseDistance) {
				let camera = SG2DCamera.getInstance(true);
				if (tile && camera) {
					let maxd = this.properties.volumeDecreaseDistance * 64;
					let pp = camera.get("position");
					let tp = tile.get("position");
					let d = SG2DMath.distance_p(pp, tp);
					options.volume *= Math.max(0, 1 - d / maxd);
				}
				if (SG2DCamera.getInstance(true)) {
					options.volume = options.volume * SG2DCamera.get("scale") / SG2DCamera.SCALE_NORMAL;
				}
			}
			
			options.speed = config.speed || sound.speed || 1;
			options.start = config.start || sound.start || 0;
			if (config.end) options.end = config.end; else if (sound.end) options.end = sound.end;
			
			instance = sound.sound.play(options);
			
			if (tile) tile.sound_instance = instance;
			
			// 2D Environment
			if (this.properties.environment2D) {
				let camera = SG2DCamera.getInstance(true);
				if (tile && camera) {
					// TODO: PIXI.Sound does not currently support instance-level filters!
					//tile.sound.filters[0].pan = Math.min(1, Math.max(-1, 3*(dx - ppx)/visd));
					// START OF CRAWLER: // TODO are waiting for the official release, or you need to look at https://codepen.io/Rumyra/pen/qyMzqN/ or https://howlerjs.com/
					let pan = SG2DMath.sin( SG2DMath.angle_p1p2_deg(camera.get("position"), tile.get("position"), 0) - SG2DCamera.get("rotate") );
					//console.log("pan="+pan+", camera_rotate=" + ca +", pta="+pta);
					let panner = new StereoPannerNode(instance._source.context, {pan: pan});
					instance._source.connect(instance._gain).connect(panner).connect(instance._source.context.destination);
					// /END OF CRAWLER
				}
			}
		}
		
		return instance;
	};
	
	this.destroy = ()=>{
		document.removeEventListener("visibilitychange", this.visibilityChange);
	};
}

if (typeof window !== "undefined" && window.document) {
	let fKeyDown = event=>{
		if (event.keyCode === 20 || event.keyCode === 18 || event.keyCode === 17 || event.keyCode === 16) return; // CapsLock, Alt, Ctrl, Shift - these gesture keys are not read out and a warning arrives "WebAudioContext.ts:101 The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page. https://goo.gl/7K7WLu"
		document.removeEventListener("keydown", fKeyDown);
		document.removeEventListener("pointerup", fPointerUp);
		SG2DSound._gestureDetected = true;
	};

	let fPointerUp = event=>{
		document.removeEventListener("keydown", fKeyDown);
		document.removeEventListener("pointerup", fPointerUp);
		SG2DSound._gestureDetected = true;
	};

	document.addEventListener("keydown", fKeyDown);
	document.addEventListener("pointerup", fPointerUp);
	
	_SG2DSound.prototype = SGModel.prototype;
	SG2DSound = new _SG2DSound();
}

export default SG2DSound;