overte-JulianGro/scripts/system/appreciate/appreciate_app.js
2021-01-18 19:56:12 -05:00

1138 lines
41 KiB
JavaScript

/*
Appreciate
Created by Zach Fox on 2019-01-30
Copyright 2019 High Fidelity, Inc.
Distributed under the Apache License, Version 2.0.
See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
*/
(function () {
// *************************************
// START UTILITY FUNCTIONS
// *************************************
// #region Utilities
var MS_PER_S = 1000;
var CM_PER_M = 100;
var HALF = 0.5;
// Returns the first valid joint position from the list of supplied test joint positions.
// If none are valid, returns MyAvatar.position.
function getValidJointPosition(jointsToTest) {
var currentJointIndex;
for (var i = 0; i < jointsToTest.length; i++) {
currentJointIndex = MyAvatar.getJointIndex(jointsToTest[i]);
if (currentJointIndex > -1) {
return MyAvatar.getJointPosition(jointsToTest[i]);
}
}
return MyAvatar.position;
}
// Returns the world position halfway between the user's hands
function getAppreciationPosition() {
var validLeftJoints = ["LeftHandMiddle2", "LeftHand", "LeftArm"];
var leftPosition = getValidJointPosition(validLeftJoints);
var validRightJoints = ["RightHandMiddle2", "RightHand", "RightArm"];;
var rightPosition = getValidJointPosition(validRightJoints);
var centerPosition = Vec3.sum(leftPosition, rightPosition);
centerPosition = Vec3.multiply(centerPosition, HALF);
return centerPosition;
}
// Returns a linearly scaled value based on `factor` and the other inputs
function linearScale(factor, minInput, maxInput, minOutput, maxOutput) {
return minOutput + (maxOutput - minOutput) *
(factor - minInput) / (maxInput - minInput);
}
// Linearly scales an RGB color between 0 and 1 based on RGB color values
// between 0 and 255.
function linearScaleColor(intensity, min, max) {
var output = {
"red": 0,
"green": 0,
"blue": 0
};
output.red = linearScale(intensity, 0, 1, min.red, max.red);
output.green = linearScale(intensity, 0, 1, min.green, max.green);
output.blue = linearScale(intensity, 0, 1, min.blue, max.blue);
return output;
}
function randomFloat(min, max) {
return Math.random() * (max - min) + min;
}
// Updates the Current Intensity Meter UI element. Called when intensity changes.
function updateCurrentIntensityUI() {
ui.sendMessage({method: "updateCurrentIntensityUI", currentIntensity: currentIntensity});
}
// #endregion
// *************************************
// END UTILITY FUNCTIONS
// *************************************
// If the interval that updates the intensity interval exists,
// clear it.
var updateIntensityEntityInterval = false;
var UPDATE_INTENSITY_ENTITY_INTERVAL_MS = 75;
function maybeClearUpdateIntensityEntityInterval() {
if (updateIntensityEntityInterval) {
Script.clearInterval(updateIntensityEntityInterval);
updateIntensityEntityInterval = false;
}
if (intensityEntity) {
Entities.deleteEntity(intensityEntity);
intensityEntity = false;
}
}
// Determines if any XYZ JSON object has changed "enough" based on
// last xyz values and current xyz values.
// Used for determining if angular velocity and dimensions have changed enough.
var lastAngularVelocity = {
"x": 0,
"y": 0,
"z": 0
};
var ANGVEL_DISTANCE_THRESHOLD_PERCENT_CHANGE = 0.35;
var lastDimensions= {
"x": 0,
"y": 0,
"z": 0
};
var DIMENSIONS_DISTANCE_THRESHOLD_PERCENT_CHANGE = 0.2;
function xyzVecChangedEnough(current, last, thresh) {
var currentLength = Math.sqrt(
Math.pow(current.x, TWO) + Math.pow(current.y, TWO) + Math.pow(current.z, TWO));
var lastLength = Math.sqrt(
Math.pow(last.x, TWO) + Math.pow(last.y, TWO) + Math.pow(last.z, TWO));
var change = Math.abs(currentLength - lastLength);
if (change/lastLength > thresh) {
return true;
}
return false;
}
// Determines if color values have changed "enough" based on
// last color and current color
var lastColor = {
"red": 0,
"blue": 0,
"green": 0
};
var COLOR_DISTANCE_THRESHOLD_PERCENT_CHANGE = 0.35;
var TWO = 2;
function colorChangedEnough(current, last, thresh) {
var currentLength = Math.sqrt(
Math.pow(current.red, TWO) + Math.pow(current.green, TWO) + Math.pow(current.blue, TWO));
var lastLength = Math.sqrt(
Math.pow(last.red, TWO) + Math.pow(last.green, TWO) + Math.pow(last.blue, TWO));
var change = Math.abs(currentLength - lastLength);
if (change/lastLength > thresh) {
return true;
}
return false;
}
// Updates the intensity entity based on the user's avatar's hand position and the
// current intensity of their appreciation.
// Many of these property values are empirically determined.
var intensityEntity = false;
var INTENSITY_ENTITY_MAX_DIMENSIONS = {
"x": 0.24,
"y": 0.24,
"z": 0.24
};
var INTENSITY_ENTITY_MIN_ANGULAR_VELOCITY = {
"x": -0.21,
"y": -0.21,
"z": -0.21
};
var INTENSITY_ENTITY_MAX_ANGULAR_VELOCITY = {
"x": 0.21,
"y": 0.21,
"z": 0.21
};
var intensityEntityColorMin = {
"red": 82,
"green": 196,
"blue": 145
};
var INTENSITY_ENTITY_COLOR_MAX_DEFAULT = {
"red": 5,
"green": 255,
"blue": 5
};
var MIN_COLOR_MULTIPLIER = 0.4;
var intensityEntityColorMax = JSON.parse(Settings.getValue("appreciate/entityColor",
JSON.stringify(INTENSITY_ENTITY_COLOR_MAX_DEFAULT)));
var ANGVEL_ENTITY_MULTIPLY_FACTOR = 62;
var INTENSITY_ENTITY_NAME = "Appreciation Dodecahedron";
var INTENSITY_ENTITY_PROPERTIES = {
"name": INTENSITY_ENTITY_NAME,
"type": "Shape",
"shape": "Dodecahedron",
"dimensions": {
"x": 0,
"y": 0,
"z": 0
},
"angularVelocity": {
"x": 0,
"y": 0,
"z": 0
},
"angularDamping": 0,
"grab": {
"grabbable": false,
"equippableLeftRotation": {
"x": -0.0000152587890625,
"y": -0.0000152587890625,
"z": -0.0000152587890625,
"w": 1
},
"equippableRightRotation": {
"x": -0.0000152587890625,
"y": -0.0000152587890625,
"z": -0.0000152587890625,
"w": 1
}
},
"collisionless": true,
"ignoreForCollisions": true,
"queryAACube": {
"x": -0.17320507764816284,
"y": -0.17320507764816284,
"z": -0.17320507764816284,
"scale": 0.3464101552963257
},
"damping": 0,
"color": intensityEntityColorMin,
"clientOnly": false,
"avatarEntity": true,
"localEntity": false,
"faceCamera": false,
"isFacingAvatar": false
};
var currentInitialAngularVelocity = {
"x": 0,
"y": 0,
"z": 0
};
function updateIntensityEntity() {
if (!showAppreciationEntity) {
return;
}
if (currentIntensity > 0) {
if (intensityEntity) {
intensityEntityColorMin.red = intensityEntityColorMax.red * MIN_COLOR_MULTIPLIER;
intensityEntityColorMin.green = intensityEntityColorMax.green * MIN_COLOR_MULTIPLIER;
intensityEntityColorMin.blue = intensityEntityColorMax.blue * MIN_COLOR_MULTIPLIER;
var color = linearScaleColor(currentIntensity, intensityEntityColorMin, intensityEntityColorMax);
var propsToUpdate = {
position: getAppreciationPosition()
};
var currentDimensions = Vec3.multiply(INTENSITY_ENTITY_MAX_DIMENSIONS, currentIntensity);
if (xyzVecChangedEnough(currentDimensions, lastDimensions, DIMENSIONS_DISTANCE_THRESHOLD_PERCENT_CHANGE)) {
propsToUpdate.dimensions = currentDimensions;
lastDimensions = currentDimensions;
}
var currentAngularVelocity = Vec3.multiply(currentInitialAngularVelocity,
currentIntensity * ANGVEL_ENTITY_MULTIPLY_FACTOR);
if (xyzVecChangedEnough(currentAngularVelocity, lastAngularVelocity, ANGVEL_DISTANCE_THRESHOLD_PERCENT_CHANGE)) {
propsToUpdate.angularVelocity = currentAngularVelocity;
lastAngularVelocity = currentAngularVelocity;
}
var currentColor = color;
if (colorChangedEnough(currentColor, lastColor, COLOR_DISTANCE_THRESHOLD_PERCENT_CHANGE)) {
propsToUpdate.color = currentColor;
lastColor = currentColor;
}
Entities.editEntity(intensityEntity, propsToUpdate);
} else {
var props = INTENSITY_ENTITY_PROPERTIES;
props.position = getAppreciationPosition();
currentInitialAngularVelocity.x =
randomFloat(INTENSITY_ENTITY_MIN_ANGULAR_VELOCITY.x, INTENSITY_ENTITY_MAX_ANGULAR_VELOCITY.x);
currentInitialAngularVelocity.y =
randomFloat(INTENSITY_ENTITY_MIN_ANGULAR_VELOCITY.y, INTENSITY_ENTITY_MAX_ANGULAR_VELOCITY.y);
currentInitialAngularVelocity.z =
randomFloat(INTENSITY_ENTITY_MIN_ANGULAR_VELOCITY.z, INTENSITY_ENTITY_MAX_ANGULAR_VELOCITY.z);
props.angularVelocity = currentInitialAngularVelocity;
intensityEntity = Entities.addEntity(props, "avatar");
}
} else {
if (intensityEntity) {
Entities.deleteEntity(intensityEntity);
intensityEntity = false;
}
maybeClearUpdateIntensityEntityInterval();
}
}
// Function that AppUI calls when the App's UI opens
function onOpened() {
updateCurrentIntensityUI();
}
// Locally pre-caches all of the sounds in the sounds/claps and sounds/whistles
// directories.
var NUM_CLAP_SOUNDS = 16;
var NUM_WHISTLE_SOUNDS = 17;
var clapSounds = [];
var whistleSounds = [];
function getSounds() {
for (var i = 1; i < NUM_CLAP_SOUNDS + 1; i++) {
clapSounds.push(SoundCache.getSound(Script.resolvePath(
"resources/sounds/claps/" + ("0" + i).slice(-2) + ".wav")));
}
for (i = 1; i < NUM_WHISTLE_SOUNDS + 1; i++) {
whistleSounds.push(SoundCache.getSound(Script.resolvePath(
"resources/sounds/whistles/" + ("0" + i).slice(-2) + ".wav")));
}
}
// Locally pre-caches the Cheering and Clapping animations
var whistlingAnimation = false;
var clappingAnimation = false;
function getAnimations() {
var animationURL = Script.resolvePath("resources/animations/Cheering.fbx");
var resource = AnimationCache.prefetch(animationURL);
var animation = AnimationCache.getAnimation(animationURL);
whistlingAnimation = {url: animationURL, animation: animation, resource: resource};
animationURL = Script.resolvePath("resources/animations/Clapping.fbx");
resource = AnimationCache.prefetch(animationURL);
animation = AnimationCache.getAnimation(animationURL);
clappingAnimation = {url: animationURL, animation: animation, resource: resource};
}
// If we're currently fading out the appreciation sounds on an interval,
// clear that interval.
function maybeClearSoundFadeInterval() {
if (soundFadeInterval) {
Script.clearInterval(soundFadeInterval);
soundFadeInterval = false;
}
}
// Fade out the appreciation sounds by quickly
// lowering the global current intensity.
var soundFadeInterval = false;
var FADE_INTERVAL_MS = 20;
var FADE_OUT_STEP_SIZE = 0.05; // Unitless
function fadeOutAndStopSound() {
maybeClearSoundFadeInterval();
soundFadeInterval = Script.setInterval(function() {
currentIntensity -= FADE_OUT_STEP_SIZE;
if (currentIntensity <= 0) {
if (soundInjector) {
soundInjector.stop();
soundInjector = false;
}
updateCurrentIntensityUI();
maybeClearSoundFadeInterval();
}
fadeIntensity(currentIntensity, INTENSITY_MAX_STEP_SIZE_DESKTOP);
}, FADE_INTERVAL_MS);
}
// Calculates the audio injector volume based on
// the current global appreciation intensity and some min/max values.
var MIN_VOLUME_CLAP = 0.05;
var MAX_VOLUME_CLAP = 1.0;
var MIN_VOLUME_WHISTLE = 0.07;
var MAX_VOLUME_WHISTLE = 0.16;
function calculateInjectorVolume() {
var minInputVolume = 0;
var maxInputVolume = MAX_CLAP_INTENSITY;
var minOutputVolume = MIN_VOLUME_CLAP;
var maxOutputVolume = MAX_VOLUME_CLAP;
if (currentSound === "whistle") {
minInputVolume = MAX_CLAP_INTENSITY;
maxInputVolume = MAX_WHISTLE_INTENSITY;
minOutputVolume = MIN_VOLUME_WHISTLE;
maxOutputVolume = MAX_VOLUME_WHISTLE;
}
var vol = linearScale(currentIntensity, minInputVolume,
maxInputVolume, minOutputVolume, maxOutputVolume);
return vol;
}
// Modifies the global currentIntensity. Moves towards the targetIntensity,
// but never moves faster than a given max step size per function call.
// Also clamps the intensity to a min of 0 and a max of 1.0.
var currentIntensity = 0;
var INTENSITY_MAX_STEP_SIZE = 0.003; // Unitless, determined empirically
var INTENSITY_MAX_STEP_SIZE_DESKTOP = 1; // Unitless, determined empirically
var MAX_CLAP_INTENSITY = 0.55; // Unitless, determined empirically
var MAX_WHISTLE_INTENSITY = 1.0; // Unitless, determined empirically
function fadeIntensity(targetIntensity, maxStepSize) {
if (!maxStepSize) {
maxStepSize = INTENSITY_MAX_STEP_SIZE;
}
var volumeDelta = targetIntensity - currentIntensity;
volumeDelta = Math.min(Math.abs(volumeDelta), maxStepSize);
if (targetIntensity < currentIntensity) {
volumeDelta *= -1;
}
currentIntensity += volumeDelta;
currentIntensity = Math.max(0.0, Math.min(
neverWhistleEnabled ? MAX_CLAP_INTENSITY : MAX_WHISTLE_INTENSITY, currentIntensity));
updateCurrentIntensityUI();
// Don't adjust volume or position while a sound is playing.
if (!soundInjector || soundInjector.isPlaying()) {
return;
}
var injectorOptions = {
position: getAppreciationPosition(),
volume: calculateInjectorVolume()
};
soundInjector.setOptions(injectorOptions);
}
// Call this function to actually play a sound.
// Doesn't play a new sound if a sound is playing AND (you're whistling OR you're in HMD)
// Injectors are placed between the user's hands (at the same location as the apprecation
// entity) and are randomly pitched between a MIN and MAX value.
// Only uses one injector, ever.
var soundInjector = false;
var MINIMUM_PITCH = 0.85;
var MAXIMUM_PITCH = 1.15;
function playSound(sound) {
if (soundInjector && soundInjector.isPlaying() && (currentSound === "whistle" || HMD.active)) {
return;
}
if (soundInjector) {
soundInjector.stop();
soundInjector = false;
}
soundInjector = Audio.playSound(sound, {
position: getAppreciationPosition(),
volume: calculateInjectorVolume(),
pitch: randomFloat(MINIMUM_PITCH, MAXIMUM_PITCH)
});
}
// Returns true if the global intensity and user settings dictate that clapping is the
// right thing to do.
function shouldClap() {
return (currentIntensity > 0.0 && neverWhistleEnabled) ||
(currentIntensity > 0.0 && currentIntensity <= MAX_CLAP_INTENSITY);
}
// Returns true if the global intensity and user settings dictate that whistling is the
// right thing to do.
function shouldWhistle() {
return currentIntensity > MAX_CLAP_INTENSITY &&
currentIntensity <= MAX_WHISTLE_INTENSITY;
}
// Selects the correct sound, then plays it.
var currentSound;
function selectAndPlaySound() {
if (shouldClap()) {
currentSound = "clap";
playSound(clapSounds[Math.floor(Math.random() * clapSounds.length)]);
} else if (shouldWhistle()) {
currentSound = "whistle";
playSound(whistleSounds[Math.floor(Math.random() * whistleSounds.length)]);
}
}
// If there exists a VR debounce timer (used for not playing sounds too often),
// clear it.
function maybeClearVRDebounceTimer() {
if (vrDebounceTimer) {
Script.clearTimeout(vrDebounceTimer);
vrDebounceTimer = false;
}
}
// Calculates the current intensity of appreciation based on the user's
// hand velocity (rotational and linear).
// Each type of velocity is weighted differently when determining the final intensity.
// The VR debounce timer length changes based on current intensity. This forces
// sounds to play further apart when the user isn't appreciating hard.
var MAX_VELOCITY_CM_PER_SEC = 110; // determined empirically
var MAX_ANGULAR_VELOCITY_LENGTH = 1.5; // determined empirically
var LINEAR_VELOCITY_WEIGHT = 0.7; // This and the line below must add up to 1.0
var ANGULAR_VELOCITY_LENGTH_WEIGHT = 0.3; // This and the line below must add up to 1.0
var vrDebounceTimer = false;
var VR_DEBOUNCE_TIMER_TIMEOUT_MIN_MS = 20; // determined empirically
var VR_DEBOUNCE_TIMER_TIMEOUT_MAX_MS = 200; // determined empirically
function calculateHandEffect(linearVelocity, angularVelocity){
var leftHandLinearVelocityCMPerSec = linearVelocity.left;
var rightHandLinearVelocityCMPerSec = linearVelocity.right;
var averageLinearVelocity = (leftHandLinearVelocityCMPerSec + rightHandLinearVelocityCMPerSec) / 2;
averageLinearVelocity = Math.min(averageLinearVelocity, MAX_VELOCITY_CM_PER_SEC);
var leftHandAngularVelocityLength = Vec3.length(angularVelocity.left);
var rightHandAngularVelocityLength = Vec3.length(angularVelocity.right);
var averageAngularVelocityIntensity = (leftHandAngularVelocityLength + rightHandAngularVelocityLength) / 2;
averageAngularVelocityIntensity = Math.min(averageAngularVelocityIntensity, MAX_ANGULAR_VELOCITY_LENGTH);
var appreciationIntensity =
averageLinearVelocity / MAX_VELOCITY_CM_PER_SEC * LINEAR_VELOCITY_WEIGHT +
averageAngularVelocityIntensity / MAX_ANGULAR_VELOCITY_LENGTH * ANGULAR_VELOCITY_LENGTH_WEIGHT;
fadeIntensity(appreciationIntensity);
var vrDebounceTimeout = VR_DEBOUNCE_TIMER_TIMEOUT_MIN_MS +
(VR_DEBOUNCE_TIMER_TIMEOUT_MAX_MS - VR_DEBOUNCE_TIMER_TIMEOUT_MIN_MS) * (1.0 - appreciationIntensity);
// This timer forces a minimum tail duration for all sound clips
if (!vrDebounceTimer) {
selectAndPlaySound();
vrDebounceTimer = Script.setTimeout(function() {
vrDebounceTimer = false;
}, vrDebounceTimeout);
}
}
// Gets both hands' linear velocity.
var lastLeftHandPosition = false;
var lastRightHandPosition = false;
function getHandsLinearVelocity() {
var linearVelocity = {
left: 0,
right: 0
};
var leftHandPosition = MyAvatar.getJointPosition("LeftHand");
var rightHandPosition = MyAvatar.getJointPosition("RightHand");
if (!lastLeftHandPosition || !lastRightHandPosition) {
lastLeftHandPosition = leftHandPosition;
lastRightHandPosition = rightHandPosition;
return linearVelocity;
}
var leftHandDistanceCM = Vec3.distance(leftHandPosition, lastLeftHandPosition) * CM_PER_M;
var rightHandDistanceCM = Vec3.distance(rightHandPosition, lastRightHandPosition) * CM_PER_M;
linearVelocity.left = leftHandDistanceCM / HAND_VELOCITY_CHECK_INTERVAL_MS * MS_PER_S;
linearVelocity.right = rightHandDistanceCM / HAND_VELOCITY_CHECK_INTERVAL_MS * MS_PER_S;
lastLeftHandPosition = leftHandPosition;
lastRightHandPosition = rightHandPosition;
return linearVelocity;
}
// Gets both hands' angular velocity.
var lastLeftHandRotation = false;
var lastRightHandRotation = false;
function getHandsAngularVelocity() {
var angularVelocity = {
left: {x: 0, y: 0, z: 0},
right: {x: 0, y: 0, z: 0}
};
var leftHandRotation = MyAvatar.getJointRotation(MyAvatar.getJointIndex("LeftHand"));
var rightHandRotation = MyAvatar.getJointRotation(MyAvatar.getJointIndex("RightHand"));
if (!lastLeftHandRotation || !lastRightHandRotation) {
lastLeftHandRotation = leftHandRotation;
lastRightHandRotation = rightHandRotation;
return angularVelocity;
}
var leftHandAngleDelta = Quat.multiply(leftHandRotation, Quat.inverse(lastLeftHandRotation));
var rightHandAngleDelta = Quat.multiply(rightHandRotation, Quat.inverse(lastRightHandRotation));
leftHandAngleDelta = Quat.safeEulerAngles(leftHandAngleDelta);
rightHandAngleDelta = Quat.safeEulerAngles(rightHandAngleDelta);
angularVelocity.left = Vec3.multiply(leftHandAngleDelta, 1 / HAND_VELOCITY_CHECK_INTERVAL_MS);
angularVelocity.right = Vec3.multiply(rightHandAngleDelta, 1 / HAND_VELOCITY_CHECK_INTERVAL_MS);
lastLeftHandRotation = leftHandRotation;
lastRightHandRotation = rightHandRotation;
return angularVelocity;
}
// Calculates the hand effect (see above). Gets called on an interval,
// but only if the user's hands are above their head. This saves processing power.
// Also sets up the `updateIntensityEntity` interval.
function handVelocityCheck() {
if (!handsAreAboveHead) {
return;
}
var handsLinearVelocity = getHandsLinearVelocity();
var handsAngularVelocity = getHandsAngularVelocity();
calculateHandEffect(handsLinearVelocity, handsAngularVelocity);
if (!updateIntensityEntityInterval && showAppreciationEntity) {
updateIntensityEntityInterval = Script.setInterval(updateIntensityEntity, UPDATE_INTENSITY_ENTITY_INTERVAL_MS);
}
}
// If handVelocityCheckInterval is set up, clear it.
function maybeClearHandVelocityCheck() {
if (handVelocityCheckInterval) {
Script.clearInterval(handVelocityCheckInterval);
handVelocityCheckInterval = false;
}
}
// If handVelocityCheckInterval is set up, clear it.
// Also stop the sound injector and set currentIntensity to 0.
function maybeClearHandVelocityCheckIntervalAndStopSound() {
maybeClearHandVelocityCheck();
if (soundInjector) {
soundInjector.stop();
soundInjector = false;
}
currentIntensity = 0.0;
}
// Sets up an interval that'll check the avatar's hand's velocities.
// This is used for calculating the effect.
// If the user isn't in HMD, we'll never set up this interval.
var handVelocityCheckInterval = false;
var HAND_VELOCITY_CHECK_INTERVAL_MS = 10;
function maybeSetupHandVelocityCheckInterval() {
// `!HMD.active` clause isn't really necessary, just extra protection
if (handVelocityCheckInterval || !HMD.active) {
return;
}
handVelocityCheckInterval = Script.setInterval(handVelocityCheck, HAND_VELOCITY_CHECK_INTERVAL_MS);
}
// Checks the position of the user's hands to determine if they're above their head.
// If they are, sets up the hand velocity check interval (see above).
// If they aren't, clears that interval and stops the apprecation sound.
var handsAreAboveHead = false;
function handPositionCheck() {
var leftHandPosition = MyAvatar.getJointPosition("LeftHand");
var rightHandPosition = MyAvatar.getJointPosition("RightHand");
var headJointPosition = MyAvatar.getJointPosition("Head");
var headY = headJointPosition.y;
handsAreAboveHead = (rightHandPosition.y > headY && leftHandPosition.y > headY);
if (handsAreAboveHead) {
maybeSetupHandVelocityCheckInterval();
} else {
maybeClearHandVelocityCheck();
fadeOutAndStopSound();
}
}
// If handPositionCheckInterval is set up, clear it.
function maybeClearHandPositionCheckInterval() {
if (handPositionCheckInterval) {
Script.clearInterval(handPositionCheckInterval);
handPositionCheckInterval = false;
}
}
// If the app is enabled, sets up an interval that'll check if the avatar's hands are above their head.
var handPositionCheckInterval = false;
var HAND_POSITION_CHECK_INTERVAL_MS = 200;
function maybeSetupHandPositionCheckInterval() {
if (!appreciateEnabled || !HMD.active) {
return;
}
maybeClearHandPositionCheckInterval();
handPositionCheckInterval = Script.setInterval(handPositionCheck, HAND_POSITION_CHECK_INTERVAL_MS);
}
// If the interval that periodically lowers the apprecation volume is set up, clear it.
function maybeClearSlowAppreciationInterval() {
if (slowAppreciationInterval) {
Script.clearInterval(slowAppreciationInterval);
slowAppreciationInterval = false;
}
}
// Stop appreciating. Called when Appreciating from Desktop mode.
function stopAppreciating() {
maybeClearStopAppreciatingTimeout();
maybeClearSlowAppreciationInterval();
maybeClearUpdateIntensityEntityInterval();
MyAvatar.restoreAnimation();
currentAnimationFPS = INITIAL_ANIMATION_FPS;
currentlyPlayingFrame = 0;
currentAnimationTimestamp = 0;
}
// If the timeout that stops the user's apprecation is set up, clear it.
function maybeClearStopAppreciatingTimeout() {
if (stopAppreciatingTimeout) {
Script.clearTimeout(stopAppreciatingTimeout);
stopAppreciatingTimeout = false;
}
}
function calculateCurrentAnimationFPS(frameCount) {
var animationTimestampDeltaMS = Date.now() - currentAnimationTimestamp;
var frameDelta = animationTimestampDeltaMS / MS_PER_S * currentAnimationFPS;
currentlyPlayingFrame = (currentlyPlayingFrame + frameDelta) % frameCount;
currentAnimationFPS = currentIntensity * CHEERING_FPS_MAX + INITIAL_ANIMATION_FPS;
currentAnimationFPS = Math.min(currentAnimationFPS, CHEERING_FPS_MAX);
if (currentAnimation === clappingAnimation) {
currentAnimationFPS += CLAP_ANIMATION_FPS_BOOST;
}
}
// Called on an interval. Slows down the user's appreciation!
var VOLUME_STEP_DOWN_DESKTOP = 0.01; // Unitless, determined empirically
function slowAppreciation() {
currentIntensity -= VOLUME_STEP_DOWN_DESKTOP;
fadeIntensity(currentIntensity, INTENSITY_MAX_STEP_SIZE_DESKTOP);
currentAnimation = selectAnimation();
if (!currentAnimation) {
stopAppreciating();
return;
}
var frameCount = currentAnimation.animation.frames.length;
calculateCurrentAnimationFPS(frameCount);
MyAvatar.overrideAnimation(currentAnimation.url, currentAnimationFPS, true, currentlyPlayingFrame, frameCount);
currentAnimationTimestamp = Date.now();
}
// Selects the proper animation to use when Appreciating in Desktop mode.
function selectAnimation() {
if (shouldClap()) {
if (currentAnimation === whistlingAnimation) {
currentAnimationTimestamp = 0;
}
return clappingAnimation;
} else if (shouldWhistle()) {
if (currentAnimation === clappingAnimation) {
currentAnimationTimestamp = 0;
}
return whistlingAnimation;
} else {
return false;
}
}
// Called when the Z key is pressed (and some other conditions are met).
// 1. (Maybe) clears old intervals
// 2. Steps up the global currentIntensity, then forces the effect/sound to fade/play immediately
// 3. Selects an animation to play based on various factors, then plays it
// - Stops appreciating if the selected animation is falsey
// 4. Sets up the "Slow Appreciation" interval which slows appreciation over time
// 5. Modifies the avatar's animation based on the current appreciation intensity
// - Since there's no way to modify animation FPS on-the-fly, we have to calculate
// where the animation should start based on where it was before changing FPS
// 6. Sets up the `updateIntensityEntity` interval if one isn't already setup
var INITIAL_ANIMATION_FPS = 7;
var SLOW_APPRECIATION_INTERVAL_MS = 100;
var CHEERING_FPS_MAX = 80;
var VOLUME_STEP_UP_DESKTOP = 0.035; // Unitless, determined empirically
var CLAP_ANIMATION_FPS_BOOST = 15;
var currentAnimation = false;
var currentAnimationFPS = INITIAL_ANIMATION_FPS;
var slowAppreciationInterval = false;
var currentlyPlayingFrame = 0;
var currentAnimationTimestamp;
function keyPressed() {
// Don't do anything if the animations aren't cached.
if (!whistlingAnimation || !clappingAnimation) {
return;
}
maybeClearSoundFadeInterval();
maybeClearStopAppreciatingTimeout();
currentIntensity += VOLUME_STEP_UP_DESKTOP;
fadeIntensity(currentIntensity, INTENSITY_MAX_STEP_SIZE_DESKTOP);
selectAndPlaySound();
currentAnimation = selectAnimation();
if (!currentAnimation) {
stopAppreciating();
return;
}
if (!slowAppreciationInterval) {
slowAppreciationInterval = Script.setInterval(slowAppreciation, SLOW_APPRECIATION_INTERVAL_MS);
}
var frameCount = currentAnimation.animation.frames.length;
if (currentAnimationTimestamp > 0) {
calculateCurrentAnimationFPS(frameCount);
} else {
currentlyPlayingFrame = 0;
}
MyAvatar.overrideAnimation(currentAnimation.url, currentAnimationFPS, true, currentlyPlayingFrame, frameCount);
currentAnimationTimestamp = Date.now();
if (!updateIntensityEntityInterval && showAppreciationEntity) {
updateIntensityEntityInterval = Script.setInterval(updateIntensityEntity, UPDATE_INTENSITY_ENTITY_INTERVAL_MS);
}
}
// The listener for all in-app keypresses. Listens for an unshifted, un-alted, un-ctrl'd
// "Z" keypress. Only listens when in Desktop mode. If the user is holding the key down,
// we make sure not to call the `keyPressed()` handler too often using the `desktopDebounceTimer`.
var desktopDebounceTimer = false;
var DESKTOP_DEBOUNCE_TIMEOUT_MS = 160;
function keyPressEvent(event) {
if (!appreciateEnabled) {
return;
}
if ((event.text.toUpperCase() === "Z") &&
!event.isShifted &&
!event.isMeta &&
!event.isControl &&
!event.isAlt &&
!HMD.active) {
if (event.isAutoRepeat) {
if (!desktopDebounceTimer) {
keyPressed();
desktopDebounceTimer = Script.setTimeout(function() {
desktopDebounceTimer = false;
}, DESKTOP_DEBOUNCE_TIMEOUT_MS);
}
} else {
keyPressed();
}
}
}
// Sets up a timeout that will fade out the appreciation sound, then stop it.
var stopAppreciatingTimeout = false;
var STOP_APPRECIATING_TIMEOUT_MS = 1000;
function stopAppreciatingSoon() {
maybeClearStopAppreciatingTimeout();
if (currentIntensity > 0) {
stopAppreciatingTimeout = Script.setTimeout(fadeOutAndStopSound, STOP_APPRECIATING_TIMEOUT_MS);
}
}
// When the "Z" key is released, we want to stop appreciating a short time later.
function keyReleaseEvent(event) {
if (!appreciateEnabled) {
return;
}
if ((event.text.toUpperCase() === "Z") &&
!event.isAutoRepeat) {
stopAppreciatingSoon();
}
}
// Enables or disables the app's main functionality
var appreciateEnabled = Settings.getValue("appreciate/enabled", false);
var neverWhistleEnabled = Settings.getValue("appreciate/neverWhistle", false);
var showAppreciationEntity = Settings.getValue("appreciate/showAppreciationEntity", true);
var keyEventsWired = false;
function enableOrDisableAppreciate() {
maybeClearHandPositionCheckInterval();
maybeClearHandVelocityCheckIntervalAndStopSound();
if (appreciateEnabled) {
maybeSetupHandPositionCheckInterval();
if (!keyEventsWired && !HMD.active) {
Controller.keyPressEvent.connect(keyPressEvent);
Controller.keyReleaseEvent.connect(keyReleaseEvent);
keyEventsWired = true;
}
} else {
stopAppreciating();
if (keyEventsWired) {
Controller.keyPressEvent.disconnect(keyPressEvent);
Controller.keyReleaseEvent.disconnect(keyReleaseEvent);
keyEventsWired = false;
}
}
}
// Handles incoming messages from the UI
// - "eventBridgeReady" - The App's UI will send this when it's ready to
// receive events over the Event Bridge
// - "appreciateSwitchClicked" - The App's UI will send this when the user
// clicks the main toggle switch in the top right of the app
// - "neverWhistleCheckboxClicked" - Sent when the user clicks the
// "Never Whistle" checkbox
// - "setEntityColor" - Sent when the user chooses a new Entity Color.
function onMessage(message) {
if (message.app !== "appreciate") {
return;
}
switch (message.method) {
case "eventBridgeReady":
ui.sendMessage({
method: "updateUI",
appreciateEnabled: appreciateEnabled,
neverWhistleEnabled: neverWhistleEnabled,
showAppreciationEntity: showAppreciationEntity,
isFirstRun: Settings.getValue("appreciate/firstRun", true),
entityColor: intensityEntityColorMax
});
break;
case "appreciateSwitchClicked":
Settings.setValue("appreciate/firstRun", false);
appreciateEnabled = message.appreciateEnabled;
Settings.setValue("appreciate/enabled", appreciateEnabled);
enableOrDisableAppreciate();
break;
case "neverWhistleCheckboxClicked":
neverWhistleEnabled = message.neverWhistle;
Settings.setValue("appreciate/neverWhistle", neverWhistleEnabled);
break;
case "showAppreciationEntityCheckboxClicked":
showAppreciationEntity = message.showAppreciationEntity;
Settings.setValue("appreciate/showAppreciationEntity", showAppreciationEntity);
break;
case "setEntityColor":
intensityEntityColorMax = message.entityColor;
Settings.setValue("appreciate/entityColor", JSON.stringify(intensityEntityColorMax));
break;
case "zKeyDown":
var pressEvent = {
"text": "Z",
"isShifted": false,
"isMeta": false,
"isControl": false,
"isAlt": false,
"isAutoRepeat": message.repeat
};
keyPressEvent(pressEvent);
break;
case "zKeyUp":
var releaseEvent = {
"text": "Z",
"isShifted": false,
"isMeta": false,
"isControl": false,
"isAlt": false,
"isAutoRepeat": false
};
keyReleaseEvent(releaseEvent);
break;
default:
console.log("Unhandled message from appreciate_ui.js: " + JSON.stringify(message));
break;
}
}
// Searches through all of your avatar entities and deletes any with the name
// that equals the one set when rezzing the Intensity Entity
function cleanupOldIntensityEntities() {
MyAvatar.getAvatarEntitiesVariant().forEach(function(avatarEntity) {
var name = Entities.getEntityProperties(avatarEntity.id, 'name').name;
if (name === INTENSITY_ENTITY_NAME && avatarEntity.id !== intensityEntity) {
Entities.deleteEntity(avatarEntity.id);
}
});
}
// Called when the script is stopped. STOP ALL THE THINGS!
function onScriptEnding() {
maybeClearHandPositionCheckInterval();
maybeClearHandVelocityCheckIntervalAndStopSound();
maybeClearSoundFadeInterval();
maybeClearVRDebounceTimer();
maybeClearUpdateIntensityEntityInterval();
cleanupOldIntensityEntities();
maybeClearStopAppreciatingTimeout();
stopAppreciating();
if (desktopDebounceTimer) {
Script.clearTimeout(desktopDebounceTimer);
desktopDebounceTimer = false;
}
if (keyEventsWired) {
Controller.keyPressEvent.disconnect(keyPressEvent);
Controller.keyReleaseEvent.disconnect(keyReleaseEvent);
keyEventsWired = false;
}
if (intensityEntity) {
Entities.deleteEntity(intensityEntity);
intensityEntity = false;
}
HMD.displayModeChanged.disconnect(enableOrDisableAppreciate);
}
// When called, this function will stop the versions of this script that are
// baked into the client installation IF there's another version of the script
// running that ISN'T the baked version.
function maybeStopBakedScriptVersions() {
var THIS_SCRIPT_FILENAME = "appreciate_app.js";
var RELATIVE_PATH_TO_BAKED_SCRIPT = "system/experiences/appreciate/appResources/appData/" + THIS_SCRIPT_FILENAME;
var bakedLocalScriptPaths = [];
var alsoRunningNonBakedVersion = false;
var runningScripts = ScriptDiscoveryService.getRunning();
runningScripts.forEach(function(scriptObject) {
if (scriptObject.local && scriptObject.url.indexOf(RELATIVE_PATH_TO_BAKED_SCRIPT) > -1) {
bakedLocalScriptPaths.push(scriptObject.path);
}
if (scriptObject.name === THIS_SCRIPT_FILENAME && scriptObject.url.indexOf(RELATIVE_PATH_TO_BAKED_SCRIPT) === -1) {
alsoRunningNonBakedVersion = true;
}
});
if (alsoRunningNonBakedVersion && bakedLocalScriptPaths.length > 0) {
for (var i = 0; i < bakedLocalScriptPaths.length; i++) {
ScriptDiscoveryService.stopScript(bakedLocalScriptPaths[i]);
}
}
}
// Called when the script starts up
var BUTTON_NAME = "APPRECIATE";
var APP_UI_URL = Script.resolvePath('resources/appreciate_ui.html');
var CLEANUP_INTENSITY_ENTITIES_STARTUP_DELAY_MS = 5000;
var AppUI = Script.require('appUi');
var ui;
function startup() {
ui = new AppUI({
buttonName: BUTTON_NAME,
home: APP_UI_URL,
// clap by Rena from the Noun Project
graphicsDirectory: Script.resolvePath("./resources/images/icons/"),
onOpened: onOpened,
onMessage: onMessage
});
cleanupOldIntensityEntities();
// We need this because sometimes avatar entities load after this script does.
Script.setTimeout(cleanupOldIntensityEntities, CLEANUP_INTENSITY_ENTITIES_STARTUP_DELAY_MS);
enableOrDisableAppreciate();
getSounds();
getAnimations();
HMD.displayModeChanged.connect(enableOrDisableAppreciate);
maybeStopBakedScriptVersions();
}
Script.scriptEnding.connect(onScriptEnding);
startup();
})();