455 lines
17 KiB
JavaScript
455 lines
17 KiB
JavaScript
/**
|
|
* @typedef {Object} AmbientSoundPlaybackConfig
|
|
* @property {Sound} sound The Sound node which should be controlled for playback
|
|
* @property {foundry.canvas.sources.PointSoundSource} source The SoundSource which defines the area of effect
|
|
* for the sound
|
|
* @property {AmbientSound} object An AmbientSound object responsible for the sound, or undefined
|
|
* @property {Point} listener The coordinates of the closest listener or undefined if there is none
|
|
* @property {number} distance The minimum distance between a listener and the AmbientSound origin
|
|
* @property {boolean} muffled Is the closest listener muffled
|
|
* @property {boolean} walls Is playback constrained or muffled by walls?
|
|
* @property {number} volume The final volume at which the Sound should be played
|
|
*/
|
|
|
|
/**
|
|
* This Canvas Layer provides a container for AmbientSound objects.
|
|
* @category - Canvas
|
|
*/
|
|
class SoundsLayer extends PlaceablesLayer {
|
|
|
|
/**
|
|
* Track whether to actively preview ambient sounds with mouse cursor movements
|
|
* @type {boolean}
|
|
*/
|
|
livePreview = false;
|
|
|
|
/**
|
|
* A mapping of ambient audio sources which are active within the rendered Scene
|
|
* @type {Collection<string,foundry.canvas.sources.PointSoundSource>}
|
|
*/
|
|
sources = new foundry.utils.Collection();
|
|
|
|
/**
|
|
* Darkness change event handler function.
|
|
* @type {_onDarknessChange}
|
|
*/
|
|
#onDarknessChange;
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
static get layerOptions() {
|
|
return foundry.utils.mergeObject(super.layerOptions, {
|
|
name: "sounds",
|
|
zIndex: 900
|
|
});
|
|
}
|
|
|
|
/** @inheritdoc */
|
|
static documentName = "AmbientSound";
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
get hookName() {
|
|
return SoundsLayer.name;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Methods */
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritDoc */
|
|
async _draw(options) {
|
|
await super._draw(options);
|
|
this.#onDarknessChange = this._onDarknessChange.bind(this);
|
|
canvas.environment.addEventListener("darknessChange", this.#onDarknessChange);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritDoc */
|
|
async _tearDown(options) {
|
|
this.stopAll();
|
|
canvas.environment.removeEventListener("darknessChange", this.#onDarknessChange);
|
|
this.#onDarknessChange = undefined;
|
|
return super._tearDown(options);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @override */
|
|
_activate() {
|
|
super._activate();
|
|
for ( const p of this.placeables ) p.renderFlags.set({refreshField: true});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Initialize all AmbientSound sources which are present on this layer
|
|
*/
|
|
initializeSources() {
|
|
for ( let sound of this.placeables ) {
|
|
sound.initializeSoundSource();
|
|
}
|
|
for ( let sound of this.preview.children ) {
|
|
sound.initializeSoundSource();
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Update all AmbientSound effects in the layer by toggling their playback status.
|
|
* Sync audio for the positions of tokens which are capable of hearing.
|
|
* @param {object} [options={}] Additional options forwarded to AmbientSound synchronization
|
|
*/
|
|
refresh(options={}) {
|
|
if ( !this.placeables.length ) return;
|
|
for ( const sound of this.placeables ) sound.source.refresh();
|
|
if ( game.audio.locked ) {
|
|
return game.audio.pending.push(() => this.refresh(options));
|
|
}
|
|
const listeners = this.getListenerPositions();
|
|
this._syncPositions(listeners, options);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Preview ambient audio for a given mouse cursor position
|
|
* @param {Point} position The cursor position to preview
|
|
*/
|
|
previewSound(position) {
|
|
if ( !this.placeables.length || game.audio.locked ) return;
|
|
return this._syncPositions([position], {fade: 50});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Terminate playback of all ambient audio sources
|
|
*/
|
|
stopAll() {
|
|
this.placeables.forEach(s => s.sync(false));
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Get an array of listener positions for Tokens which are able to hear environmental sound.
|
|
* @returns {Point[]}
|
|
*/
|
|
getListenerPositions() {
|
|
const listeners = canvas.tokens.controlled.map(token => token.center);
|
|
if ( !listeners.length && !game.user.isGM ) {
|
|
for ( const token of canvas.tokens.placeables ) {
|
|
if ( token.actor?.isOwner && token.isVisible ) listeners.push(token.center);
|
|
}
|
|
}
|
|
return listeners;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Sync the playing state and volume of all AmbientSound objects based on the position of listener points
|
|
* @param {Point[]} listeners Locations of listeners which have the capability to hear
|
|
* @param {object} [options={}] Additional options forwarded to AmbientSound synchronization
|
|
* @protected
|
|
*/
|
|
_syncPositions(listeners, options) {
|
|
if ( !this.placeables.length || game.audio.locked ) return;
|
|
/** @type {Record<string, Partial<AmbientSoundPlaybackConfig>>} */
|
|
const paths = {};
|
|
for ( const /** @type {AmbientSound} */ object of this.placeables ) {
|
|
const {path, easing, volume, walls} = object.document;
|
|
if ( !path ) continue;
|
|
const {sound, source} = object;
|
|
|
|
// Track a singleton record per unique audio path
|
|
paths[path] ||= {sound, source, object, volume: 0};
|
|
const config = paths[path];
|
|
if ( !config.sound && sound ) Object.assign(config, {sound, source, object}); // First defined Sound
|
|
|
|
// Identify the closest listener to each sound source
|
|
if ( !object.isAudible || !source.active ) continue;
|
|
for ( let l of listeners ) {
|
|
const v = volume * source.getVolumeMultiplier(l, {easing});
|
|
if ( v > config.volume ) {
|
|
Object.assign(config, {source, object, listener: l, volume: v, walls});
|
|
config.sound ??= sound; // We might already have defined Sound
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute the effective volume for each sound path
|
|
for ( const config of Object.values(paths) ) {
|
|
this._configurePlayback(config);
|
|
config.object.sync(config.volume > 0, config.volume, {...options, muffled: config.muffled});
|
|
}
|
|
}
|
|
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Configure playback by assigning the muffled state and final playback volume for the sound.
|
|
* This method should mutate the config object by assigning the volume and muffled properties.
|
|
* @param {AmbientSoundPlaybackConfig} config
|
|
* @protected
|
|
*/
|
|
_configurePlayback(config) {
|
|
const {source, walls} = config;
|
|
|
|
// Inaudible sources
|
|
if ( !config.listener ) {
|
|
config.volume = 0;
|
|
return;
|
|
}
|
|
|
|
// Muffled by walls
|
|
if ( !walls ) {
|
|
if ( config.listener.equals(source) ) return false; // GM users listening to the source
|
|
const polygonCls = CONFIG.Canvas.polygonBackends.sound;
|
|
const x = polygonCls.testCollision(config.listener, source, {mode: "first", type: "sound"});
|
|
config.muffled = x && (x._distance < 1); // Collided before reaching the source
|
|
}
|
|
else config.muffled = false;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Actions to take when the darkness level of the Scene is changed
|
|
* @param {PIXI.FederatedEvent} event
|
|
* @internal
|
|
*/
|
|
_onDarknessChange(event) {
|
|
const {darknessLevel, priorDarknessLevel} = event.environmentData;
|
|
for ( const sound of this.placeables ) {
|
|
const {min, max} = sound.document.darkness;
|
|
if ( darknessLevel.between(min, max) === priorDarknessLevel.between(min, max) ) continue;
|
|
sound.initializeSoundSource();
|
|
if ( this.active ) sound.renderFlags.set({refreshState: true});
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Play a one-shot Sound originating from a predefined point on the canvas.
|
|
* The sound plays locally for the current client only.
|
|
* To play a sound for all connected clients use SoundsLayer#emitAtPosition.
|
|
*
|
|
* @param {string} src The sound source path to play
|
|
* @param {Point} origin The canvas coordinates from which the sound originates
|
|
* @param {number} radius The radius of effect in distance units
|
|
* @param {object} options Additional options which configure playback
|
|
* @param {number} [options.volume=1.0] The maximum volume at which the effect should be played
|
|
* @param {boolean} [options.easing=true] Should volume be attenuated by distance?
|
|
* @param {boolean} [options.walls=true] Should the sound be constrained by walls?
|
|
* @param {boolean} [options.gmAlways=true] Should the sound always be played for GM users regardless
|
|
* of actively controlled tokens?
|
|
* @param {AmbientSoundEffect} [options.baseEffect] A base sound effect to apply to playback
|
|
* @param {AmbientSoundEffect} [options.muffledEffect] A muffled sound effect to apply to playback, a sound may
|
|
* only be muffled if it is not constrained by walls
|
|
* @param {Partial<PointSourceData>} [options.sourceData] Additional data passed to the SoundSource constructor
|
|
* @param {SoundPlaybackOptions} [options.playbackOptions] Additional options passed to Sound#play
|
|
* @returns {Promise<foundry.audio.Sound|null>} A Promise which resolves to the played Sound, or null
|
|
*
|
|
* @example Play the sound of a trap springing
|
|
* ```js
|
|
* const src = "modules/my-module/sounds/spring-trap.ogg";
|
|
* const origin = {x: 5200, y: 3700}; // The origin point for the sound
|
|
* const radius = 30; // Audible in a 30-foot radius
|
|
* await canvas.sounds.playAtPosition(src, origin, radius);
|
|
* ```
|
|
*
|
|
* @example A Token casts a spell
|
|
* ```js
|
|
* const src = "modules/my-module/sounds/spells-sprite.ogg";
|
|
* const origin = token.center; // The origin point for the sound
|
|
* const radius = 60; // Audible in a 60-foot radius
|
|
* await canvas.sounds.playAtPosition(src, origin, radius, {
|
|
* walls: false, // Not constrained by walls with a lowpass muffled effect
|
|
* muffledEffect: {type: "lowpass", intensity: 6},
|
|
* sourceData: {
|
|
* angle: 120, // Sound emitted at a limited angle
|
|
* rotation: 270 // Configure the direction of sound emission
|
|
* }
|
|
* playbackOptions: {
|
|
* loopStart: 12, // Audio sprite timing
|
|
* loopEnd: 16,
|
|
* fade: 300, // Fade-in 300ms
|
|
* onended: () => console.log("Do something after the spell sound has played")
|
|
* }
|
|
* });
|
|
* ```
|
|
*/
|
|
async playAtPosition(src, origin, radius, {volume=1, easing=true, walls=true, gmAlways=true,
|
|
baseEffect, muffledEffect, sourceData, playbackOptions}={}) {
|
|
|
|
// Construct a Sound and corresponding SoundSource
|
|
const sound = new foundry.audio.Sound(src, {context: game.audio.environment});
|
|
const source = new CONFIG.Canvas.soundSourceClass({object: null});
|
|
source.initialize({
|
|
x: origin.x,
|
|
y: origin.y,
|
|
radius: canvas.dimensions.distancePixels * radius,
|
|
walls,
|
|
...sourceData
|
|
});
|
|
/** @type {Partial<AmbientSoundPlaybackConfig>} */
|
|
const config = {sound, source, listener: undefined, volume: 0, walls};
|
|
|
|
// Identify the closest listener position
|
|
const listeners = (gmAlways && game.user.isGM) ? [origin] : this.getListenerPositions();
|
|
for ( const l of listeners ) {
|
|
const v = volume * source.getVolumeMultiplier(l, {easing});
|
|
if ( v > config.volume ) Object.assign(config, {listener: l, volume: v});
|
|
}
|
|
|
|
// Configure playback volume and muffled state
|
|
this._configurePlayback(config);
|
|
if ( !config.volume ) return null;
|
|
|
|
// Load the Sound and apply special effects
|
|
await sound.load();
|
|
const sfx = CONFIG.soundEffects;
|
|
let effect;
|
|
if ( config.muffled && (muffledEffect?.type in sfx) ) {
|
|
const muffledCfg = sfx[muffledEffect.type];
|
|
effect = new muffledCfg.effectClass(sound.context, muffledEffect);
|
|
}
|
|
if ( !effect && (baseEffect?.type in sfx) ) {
|
|
const baseCfg = sfx[baseEffect.type];
|
|
effect = new baseCfg.effectClass(sound.context, baseEffect);
|
|
}
|
|
if ( effect ) sound.effects.push(effect);
|
|
|
|
// Initiate sound playback
|
|
await sound.play({...playbackOptions, loop: false, volume: config.volume});
|
|
return sound;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Emit playback to other connected clients to occur at a specified position.
|
|
* @param {...*} args Arguments passed to SoundsLayer#playAtPosition
|
|
* @returns {Promise<void>} A Promise which resolves once playback for the initiating client has completed
|
|
*/
|
|
async emitAtPosition(...args) {
|
|
game.socket.emit("playAudioPosition", args);
|
|
return this.playAtPosition(...args);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Event Listeners and Handlers */
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Handle mouse cursor movements which may cause ambient audio previews to occur
|
|
*/
|
|
_onMouseMove() {
|
|
if ( !this.livePreview ) return;
|
|
if ( canvas.tokens.active && canvas.tokens.controlled.length ) return;
|
|
this.previewSound(canvas.mousePosition);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
_onDragLeftStart(event) {
|
|
super._onDragLeftStart(event);
|
|
const interaction = event.interactionData;
|
|
|
|
// Snap the origin to the grid
|
|
if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);
|
|
|
|
// Create a pending AmbientSoundDocument
|
|
const cls = getDocumentClass("AmbientSound");
|
|
const doc = new cls({type: "l", ...interaction.origin}, {parent: canvas.scene});
|
|
|
|
// Create the preview AmbientSound object
|
|
const sound = new this.constructor.placeableClass(doc);
|
|
interaction.preview = this.preview.addChild(sound);
|
|
interaction.soundState = 1;
|
|
this.preview._creating = false;
|
|
sound.draw();
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
_onDragLeftMove(event) {
|
|
const {destination, soundState, preview, origin} = event.interactionData;
|
|
if ( soundState === 0 ) return;
|
|
const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y);
|
|
preview.document.updateSource({radius: radius / canvas.dimensions.distancePixels});
|
|
preview.initializeSoundSource();
|
|
preview.renderFlags.set({refreshState: true});
|
|
event.interactionData.soundState = 2;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
_onDragLeftDrop(event) {
|
|
// Snap the destination to the grid
|
|
const interaction = event.interactionData;
|
|
if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
|
|
const {soundState, destination, origin, preview} = interaction;
|
|
if ( soundState !== 2 ) return;
|
|
|
|
// Render the preview sheet for confirmation
|
|
const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y);
|
|
if ( radius < (canvas.dimensions.size / 2) ) return;
|
|
preview.document.updateSource({radius: radius / canvas.dimensions.distancePixels});
|
|
preview.initializeSoundSource();
|
|
preview.renderFlags.set({refreshState: true});
|
|
preview.sheet.render(true);
|
|
this.preview._creating = true;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
_onDragLeftCancel(event) {
|
|
if ( this.preview._creating ) return;
|
|
return super._onDragLeftCancel(event);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Handle PlaylistSound document drop data.
|
|
* @param {DragEvent} event The drag drop event
|
|
* @param {object} data The dropped transfer data.
|
|
*/
|
|
async _onDropData(event, data) {
|
|
const playlistSound = await PlaylistSound.implementation.fromDropData(data);
|
|
if ( !playlistSound ) return false;
|
|
let origin;
|
|
if ( (data.x === undefined) || (data.y === undefined) ) {
|
|
const coords = this._canvasCoordinatesFromDrop(event, {center: false});
|
|
if ( !coords ) return false;
|
|
origin = {x: coords[0], y: coords[1]};
|
|
} else {
|
|
origin = {x: data.x, y: data.y};
|
|
}
|
|
if ( !event.shiftKey ) origin = this.getSnappedPoint(origin);
|
|
if ( !canvas.dimensions.rect.contains(origin.x, origin.y) ) return false;
|
|
const soundData = {
|
|
path: playlistSound.path,
|
|
volume: playlistSound.volume,
|
|
x: origin.x,
|
|
y: origin.y,
|
|
radius: canvas.dimensions.distance * 2
|
|
};
|
|
return this._createPreview(soundData, {top: event.clientY - 20, left: event.clientX + 40});
|
|
}
|
|
}
|