mirror of
https://github.com/scratchfoundation/scratch-vm.git
synced 2024-12-25 15:32:40 -05:00
350 lines
11 KiB
JavaScript
350 lines
11 KiB
JavaScript
const MathUtil = require('../util/math-util');
|
|
const Cast = require('../util/cast');
|
|
const Clone = require('../util/clone');
|
|
|
|
/**
|
|
* Occluded boolean value to make its use more understandable.
|
|
* @const {boolean}
|
|
*/
|
|
const STORE_WAITING = true;
|
|
|
|
class Scratch3SoundBlocks {
|
|
constructor (runtime) {
|
|
/**
|
|
* The runtime instantiating this block package.
|
|
* @type {Runtime}
|
|
*/
|
|
this.runtime = runtime;
|
|
|
|
this.waitingSounds = {};
|
|
|
|
// Clear sound effects on green flag and stop button events.
|
|
this.stopAllSounds = this.stopAllSounds.bind(this);
|
|
this._stopWaitingSoundsForTarget = this._stopWaitingSoundsForTarget.bind(this);
|
|
this._clearEffectsForAllTargets = this._clearEffectsForAllTargets.bind(this);
|
|
if (this.runtime) {
|
|
this.runtime.on('PROJECT_STOP_ALL', this.stopAllSounds);
|
|
this.runtime.on('PROJECT_STOP_ALL', this._clearEffectsForAllTargets);
|
|
this.runtime.on('STOP_FOR_TARGET', this._stopWaitingSoundsForTarget);
|
|
this.runtime.on('PROJECT_START', this._clearEffectsForAllTargets);
|
|
}
|
|
|
|
this._onTargetCreated = this._onTargetCreated.bind(this);
|
|
if (this.runtime) {
|
|
runtime.on('targetWasCreated', this._onTargetCreated);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The key to load & store a target's sound-related state.
|
|
* @type {string}
|
|
*/
|
|
static get STATE_KEY () {
|
|
return 'Scratch.sound';
|
|
}
|
|
|
|
/**
|
|
* The default sound-related state, to be used when a target has no existing sound state.
|
|
* @type {SoundState}
|
|
*/
|
|
static get DEFAULT_SOUND_STATE () {
|
|
return {
|
|
effects: {
|
|
pitch: 0,
|
|
pan: 0
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The minimum and maximum MIDI note numbers, for clamping the input to play note.
|
|
* @type {{min: number, max: number}}
|
|
*/
|
|
static get MIDI_NOTE_RANGE () {
|
|
return {min: 36, max: 96}; // C2 to C7
|
|
}
|
|
|
|
/**
|
|
* The minimum and maximum beat values, for clamping the duration of play note, play drum and rest.
|
|
* 100 beats at the default tempo of 60bpm is 100 seconds.
|
|
* @type {{min: number, max: number}}
|
|
*/
|
|
static get BEAT_RANGE () {
|
|
return {min: 0, max: 100};
|
|
}
|
|
|
|
/** The minimum and maximum tempo values, in bpm.
|
|
* @type {{min: number, max: number}}
|
|
*/
|
|
static get TEMPO_RANGE () {
|
|
return {min: 20, max: 500};
|
|
}
|
|
|
|
/** The minimum and maximum values for each sound effect.
|
|
* @type {{effect:{min: number, max: number}}}
|
|
*/
|
|
static get EFFECT_RANGE () {
|
|
return {
|
|
pitch: {min: -360, max: 360}, // -3 to 3 octaves
|
|
pan: {min: -100, max: 100} // 100% left to 100% right
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {Target} target - collect sound state for this target.
|
|
* @returns {SoundState} the mutable sound state associated with that target. This will be created if necessary.
|
|
* @private
|
|
*/
|
|
_getSoundState (target) {
|
|
let soundState = target.getCustomState(Scratch3SoundBlocks.STATE_KEY);
|
|
if (!soundState) {
|
|
soundState = Clone.simple(Scratch3SoundBlocks.DEFAULT_SOUND_STATE);
|
|
target.setCustomState(Scratch3SoundBlocks.STATE_KEY, soundState);
|
|
target.soundEffects = soundState.effects;
|
|
}
|
|
return soundState;
|
|
}
|
|
|
|
/**
|
|
* When a Target is cloned, clone the sound state.
|
|
* @param {Target} newTarget - the newly created target.
|
|
* @param {Target} [sourceTarget] - the target used as a source for the new clone, if any.
|
|
* @listens Runtime#event:targetWasCreated
|
|
* @private
|
|
*/
|
|
_onTargetCreated (newTarget, sourceTarget) {
|
|
if (sourceTarget) {
|
|
const soundState = sourceTarget.getCustomState(Scratch3SoundBlocks.STATE_KEY);
|
|
if (soundState && newTarget) {
|
|
newTarget.setCustomState(Scratch3SoundBlocks.STATE_KEY, Clone.simple(soundState));
|
|
this._syncEffectsForTarget(newTarget);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieve the block primitives implemented by this package.
|
|
* @return {object.<string, Function>} Mapping of opcode to Function.
|
|
*/
|
|
getPrimitives () {
|
|
return {
|
|
sound_play: this.playSound,
|
|
sound_playuntildone: this.playSoundAndWait,
|
|
sound_stopallsounds: this.stopAllSounds,
|
|
sound_seteffectto: this.setEffect,
|
|
sound_changeeffectby: this.changeEffect,
|
|
sound_cleareffects: this.clearEffects,
|
|
sound_sounds_menu: this.soundsMenu,
|
|
sound_beats_menu: this.beatsMenu,
|
|
sound_effects_menu: this.effectsMenu,
|
|
sound_setvolumeto: this.setVolume,
|
|
sound_changevolumeby: this.changeVolume,
|
|
sound_volume: this.getVolume
|
|
};
|
|
}
|
|
|
|
getMonitored () {
|
|
return {
|
|
sound_volume: {
|
|
isSpriteSpecific: true,
|
|
getId: targetId => `${targetId}_volume`
|
|
}
|
|
};
|
|
}
|
|
|
|
playSound (args, util) {
|
|
// Don't return the promise, it's the only difference for AndWait
|
|
this._playSound(args, util);
|
|
}
|
|
|
|
playSoundAndWait (args, util) {
|
|
return this._playSound(args, util, STORE_WAITING);
|
|
}
|
|
|
|
_playSound (args, util, storeWaiting) {
|
|
const index = this._getSoundIndex(args.SOUND_MENU, util);
|
|
if (index >= 0) {
|
|
const {target} = util;
|
|
const {sprite} = target;
|
|
const {soundId} = sprite.sounds[index];
|
|
if (sprite.soundBank) {
|
|
if (storeWaiting === STORE_WAITING) {
|
|
this._addWaitingSound(target.id, soundId);
|
|
} else {
|
|
this._removeWaitingSound(target.id, soundId);
|
|
}
|
|
return sprite.soundBank.playSound(target, soundId);
|
|
}
|
|
}
|
|
}
|
|
|
|
_addWaitingSound (targetId, soundId) {
|
|
if (!this.waitingSounds[targetId]) {
|
|
this.waitingSounds[targetId] = new Set();
|
|
}
|
|
this.waitingSounds[targetId].add(soundId);
|
|
}
|
|
|
|
_removeWaitingSound (targetId, soundId) {
|
|
if (!this.waitingSounds[targetId]) {
|
|
return;
|
|
}
|
|
this.waitingSounds[targetId].delete(soundId);
|
|
}
|
|
|
|
_getSoundIndex (soundName, util) {
|
|
// if the sprite has no sounds, return -1
|
|
const len = util.target.sprite.sounds.length;
|
|
if (len === 0) {
|
|
return -1;
|
|
}
|
|
|
|
// look up by name first
|
|
const index = this.getSoundIndexByName(soundName, util);
|
|
if (index !== -1) {
|
|
return index;
|
|
}
|
|
|
|
// then try using the sound name as a 1-indexed index
|
|
const oneIndexedIndex = parseInt(soundName, 10);
|
|
if (!isNaN(oneIndexedIndex)) {
|
|
return MathUtil.wrapClamp(oneIndexedIndex - 1, 0, len - 1);
|
|
}
|
|
|
|
// could not be found as a name or converted to index, return -1
|
|
return -1;
|
|
}
|
|
|
|
getSoundIndexByName (soundName, util) {
|
|
const sounds = util.target.sprite.sounds;
|
|
for (let i = 0; i < sounds.length; i++) {
|
|
if (sounds[i].name === soundName) {
|
|
return i;
|
|
}
|
|
}
|
|
// if there is no sound by that name, return -1
|
|
return -1;
|
|
}
|
|
|
|
stopAllSounds () {
|
|
if (this.runtime.targets === null) return;
|
|
const allTargets = this.runtime.targets;
|
|
for (let i = 0; i < allTargets.length; i++) {
|
|
this._stopAllSoundsForTarget(allTargets[i]);
|
|
}
|
|
}
|
|
|
|
_stopAllSoundsForTarget (target) {
|
|
if (target.sprite.soundBank) {
|
|
target.sprite.soundBank.stopAllSounds(target);
|
|
if (this.waitingSounds[target.id]) {
|
|
this.waitingSounds[target.id].clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
_stopWaitingSoundsForTarget (target) {
|
|
if (target.sprite.soundBank) {
|
|
if (this.waitingSounds[target.id]) {
|
|
for (const soundId of this.waitingSounds[target.id].values()) {
|
|
target.sprite.soundBank.stop(target, soundId);
|
|
}
|
|
this.waitingSounds[target.id].clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
setEffect (args, util) {
|
|
return this._updateEffect(args, util, false);
|
|
}
|
|
|
|
changeEffect (args, util) {
|
|
return this._updateEffect(args, util, true);
|
|
}
|
|
|
|
_updateEffect (args, util, change) {
|
|
const effect = Cast.toString(args.EFFECT).toLowerCase();
|
|
const value = Cast.toNumber(args.VALUE);
|
|
|
|
const soundState = this._getSoundState(util.target);
|
|
if (!soundState.effects.hasOwnProperty(effect)) return;
|
|
|
|
if (change) {
|
|
soundState.effects[effect] += value;
|
|
} else {
|
|
soundState.effects[effect] = value;
|
|
}
|
|
|
|
const {min, max} = Scratch3SoundBlocks.EFFECT_RANGE[effect];
|
|
soundState.effects[effect] = MathUtil.clamp(soundState.effects[effect], min, max);
|
|
|
|
this._syncEffectsForTarget(util.target);
|
|
// Yield until the next tick.
|
|
return Promise.resolve();
|
|
}
|
|
|
|
_syncEffectsForTarget (target) {
|
|
if (!target || !target.sprite.soundBank) return;
|
|
target.soundEffects = this._getSoundState(target).effects;
|
|
|
|
target.sprite.soundBank.setEffects(target);
|
|
}
|
|
|
|
clearEffects (args, util) {
|
|
this._clearEffectsForTarget(util.target);
|
|
}
|
|
|
|
_clearEffectsForTarget (target) {
|
|
const soundState = this._getSoundState(target);
|
|
for (const effect in soundState.effects) {
|
|
if (!soundState.effects.hasOwnProperty(effect)) continue;
|
|
soundState.effects[effect] = 0;
|
|
}
|
|
this._syncEffectsForTarget(target);
|
|
}
|
|
|
|
_clearEffectsForAllTargets () {
|
|
if (this.runtime.targets === null) return;
|
|
const allTargets = this.runtime.targets;
|
|
for (let i = 0; i < allTargets.length; i++) {
|
|
this._clearEffectsForTarget(allTargets[i]);
|
|
}
|
|
}
|
|
|
|
setVolume (args, util) {
|
|
const volume = Cast.toNumber(args.VOLUME);
|
|
return this._updateVolume(volume, util);
|
|
}
|
|
|
|
changeVolume (args, util) {
|
|
const volume = Cast.toNumber(args.VOLUME) + util.target.volume;
|
|
return this._updateVolume(volume, util);
|
|
}
|
|
|
|
_updateVolume (volume, util) {
|
|
volume = MathUtil.clamp(volume, 0, 100);
|
|
util.target.volume = volume;
|
|
this._syncEffectsForTarget(util.target);
|
|
|
|
// Yield until the next tick.
|
|
return Promise.resolve();
|
|
}
|
|
|
|
getVolume (args, util) {
|
|
return util.target.volume;
|
|
}
|
|
|
|
soundsMenu (args) {
|
|
return args.SOUND_MENU;
|
|
}
|
|
|
|
beatsMenu (args) {
|
|
return args.BEATS;
|
|
}
|
|
|
|
effectsMenu (args) {
|
|
return args.EFFECT;
|
|
}
|
|
}
|
|
|
|
module.exports = Scratch3SoundBlocks;
|