"use strict"; // equipEntity.js // // Distributed under the Apache License, Version 2.0. // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html /* global Script, Entities, MyAvatar, Controller, RIGHT_HAND, LEFT_HAND, AVATAR_SELF_ID, getControllerJointIndex, NULL_UUID, enableDispatcherModule, disableDispatcherModule, Messages, makeDispatcherModuleParameters, makeRunningValues, Settings, entityHasActions, Vec3, Overlays, flatten, Xform, getControllerWorldLocation, ensureDynamic */ Script.include("/~/system/libraries/Xform.js"); Script.include("/~/system/controllers/controllerDispatcherUtils.js"); Script.include("/~/system/libraries/controllers.js"); var DEFAULT_SPHERE_MODEL_URL = "http://hifi-content.s3.amazonaws.com/alan/dev/equip-Fresnel-3.fbx"; var EQUIP_SPHERE_SCALE_FACTOR = 0.65; // Each overlayInfoSet describes a single equip hotspot. // It is an object with the following keys: // timestamp - last time this object was updated, used to delete stale hotspot overlays. // entityID - entity assosicated with this hotspot // localPosition - position relative to the entity // hotspot - hotspot object // overlays - array of overlay objects created by Overlay.addOverlay() // currentSize - current animated scale value // targetSize - the target of our scale animations // type - "sphere" or "model". function EquipHotspotBuddy() { // holds map from {string} hotspot.key to {object} overlayInfoSet. this.map = {}; // array of all hotspots that are highlighed. this.highlightedHotspots = []; } EquipHotspotBuddy.prototype.clear = function() { var keys = Object.keys(this.map); for (var i = 0; i < keys.length; i++) { var overlayInfoSet = this.map[keys[i]]; this.deleteOverlayInfoSet(overlayInfoSet); } this.map = {}; this.highlightedHotspots = []; }; EquipHotspotBuddy.prototype.highlightHotspot = function(hotspot) { this.highlightedHotspots.push(hotspot.key); }; EquipHotspotBuddy.prototype.updateHotspot = function(hotspot, timestamp) { var overlayInfoSet = this.map[hotspot.key]; if (!overlayInfoSet) { // create a new overlayInfoSet overlayInfoSet = { timestamp: timestamp, entityID: hotspot.entityID, localPosition: hotspot.localPosition, hotspot: hotspot, currentSize: 0, targetSize: 1, overlays: [] }; var diameter = hotspot.radius * 2; // override default sphere with a user specified model, if it exists. overlayInfoSet.overlays.push(Overlays.addOverlay("model", { name: "hotspot overlay", url: hotspot.modelURL ? hotspot.modelURL : DEFAULT_SPHERE_MODEL_URL, position: hotspot.worldPosition, rotation: { x: 0, y: 0, z: 0, w: 1 }, dimensions: diameter * EQUIP_SPHERE_SCALE_FACTOR, scale: hotspot.modelScale, ignoreRayIntersection: true })); overlayInfoSet.type = "model"; this.map[hotspot.key] = overlayInfoSet; } else { overlayInfoSet.timestamp = timestamp; } }; EquipHotspotBuddy.prototype.updateHotspots = function(hotspots, timestamp) { var _this = this; hotspots.forEach(function(hotspot) { _this.updateHotspot(hotspot, timestamp); }); this.highlightedHotspots = []; }; EquipHotspotBuddy.prototype.update = function(deltaTime, timestamp, controllerData) { var HIGHLIGHT_SIZE = 1.1; var NORMAL_SIZE = 1.0; var keys = Object.keys(this.map); for (var i = 0; i < keys.length; i++) { var overlayInfoSet = this.map[keys[i]]; // this overlayInfo is highlighted. if (this.highlightedHotspots.indexOf(keys[i]) != -1) { overlayInfoSet.targetSize = HIGHLIGHT_SIZE; } else { overlayInfoSet.targetSize = NORMAL_SIZE; } // start to fade out this hotspot. if (overlayInfoSet.timestamp != timestamp) { overlayInfoSet.targetSize = 0; } // animate the size. var SIZE_TIMESCALE = 0.1; var tau = deltaTime / SIZE_TIMESCALE; if (tau > 1.0) { tau = 1.0; } overlayInfoSet.currentSize += (overlayInfoSet.targetSize - overlayInfoSet.currentSize) * tau; if (overlayInfoSet.timestamp != timestamp && overlayInfoSet.currentSize <= 0.05) { // this is an old overlay, that has finished fading out, delete it! overlayInfoSet.overlays.forEach(Overlays.deleteOverlay); delete this.map[keys[i]]; } else { // update overlay position, rotation to follow the object it's attached to. var props = controllerData.nearbyEntityPropertiesByID[overlayInfoSet.entityID]; if (props) { var entityXform = new Xform(props.rotation, props.position); var position = entityXform.xformPoint(overlayInfoSet.localPosition); var dimensions; if (overlayInfoSet.type == "sphere") { dimensions = overlayInfoSet.hotspot.radius * 2 * overlayInfoSet.currentSize * EQUIP_SPHERE_SCALE_FACTOR; } else { dimensions = overlayInfoSet.hotspot.radius * 2 * overlayInfoSet.currentSize; } overlayInfoSet.overlays.forEach(function(overlay) { Overlays.editOverlay(overlay, { position: position, rotation: props.rotation, dimensions: dimensions }); }); } else { overlayInfoSet.overlays.forEach(Overlays.deleteOverlay); delete this.map[keys[i]]; } } } }; (function() { var ATTACH_POINT_SETTINGS = "io.highfidelity.attachPoints"; var EQUIP_RADIUS = 0.2; // radius used for palm vs equip-hotspot for equipping. var HAPTIC_PULSE_STRENGTH = 1.0; var HAPTIC_PULSE_DURATION = 13.0; var HAPTIC_TEXTURE_STRENGTH = 0.1; var HAPTIC_TEXTURE_DURATION = 3.0; var HAPTIC_TEXTURE_DISTANCE = 0.002; var HAPTIC_DEQUIP_STRENGTH = 0.75; var HAPTIC_DEQUIP_DURATION = 50.0; var TRIGGER_SMOOTH_RATIO = 0.1; // Time averaging of trigger - 0.0 disables smoothing var TRIGGER_OFF_VALUE = 0.1; var TRIGGER_ON_VALUE = TRIGGER_OFF_VALUE + 0.05; // Squeezed just enough to activate search or near grab var BUMPER_ON_VALUE = 0.5; function getWearableData(props) { var wearable = {}; try { if (!props.userDataParsed) { props.userDataParsed = JSON.parse(props.userData); } wearable = props.userDataParsed.wearable ? props.userDataParsed.wearable : {}; } catch (err) { } return wearable; } function getEquipHotspotsData(props) { var equipHotspots = []; try { if (!props.userDataParsed) { props.userDataParsed = JSON.parse(props.userData); } equipHotspots = props.userDataParsed.equipHotspots ? props.userDataParsed.equipHotspots : []; } catch (err) { } return equipHotspots; } function getAttachPointSettings() { try { var str = Settings.getValue(ATTACH_POINT_SETTINGS); if (str === "false" || str === "") { return {}; } else { return JSON.parse(str); } } catch (err) { print("Error parsing attachPointSettings: " + err); return {}; } } function setAttachPointSettings(attachPointSettings) { var str = JSON.stringify(attachPointSettings); Settings.setValue(ATTACH_POINT_SETTINGS, str); } function getAttachPointForHotspotFromSettings(hotspot, hand) { var attachPointSettings = getAttachPointSettings(); var jointName = (hand === RIGHT_HAND) ? "RightHand" : "LeftHand"; var joints = attachPointSettings[hotspot.key]; if (joints) { return joints[jointName]; } else { return undefined; } } function storeAttachPointForHotspotInSettings(hotspot, hand, offsetPosition, offsetRotation) { var attachPointSettings = getAttachPointSettings(); var jointName = (hand === RIGHT_HAND) ? "RightHand" : "LeftHand"; var joints = attachPointSettings[hotspot.key]; if (!joints) { joints = {}; attachPointSettings[hotspot.key] = joints; } joints[jointName] = [offsetPosition, offsetRotation]; setAttachPointSettings(attachPointSettings); } function EquipEntity(hand) { this.hand = hand; this.targetEntityID = null; this.prevHandIsUpsideDown = false; this.triggerValue = 0; this.parameters = makeDispatcherModuleParameters( 300, this.hand === RIGHT_HAND ? ["rightHand"] : ["leftHand"], [], 100); var equipHotspotBuddy = new EquipHotspotBuddy(); // returns a list of all equip-hotspots assosiated with this entity. // @param {UUID} entityID // @returns {Object[]} array of objects with the following fields. // * key {string} a string that can be used to uniquely identify this hotspot // * entityID {UUID} // * localPosition {Vec3} position of the hotspot in object space. // * worldPosition {vec3} position of the hotspot in world space. // * radius {number} radius of equip hotspot // * joints {Object} keys are joint names values are arrays of two elements: // offset position {Vec3} and offset rotation {Quat}, both are in the coordinate system of the joint. // * modelURL {string} url for model to use instead of default sphere. // * modelScale {Vec3} scale factor for model this.collectEquipHotspots = function(props) { var result = []; var entityID = props.id; var entityXform = new Xform(props.rotation, props.position); var equipHotspotsProps = getEquipHotspotsData(props); if (equipHotspotsProps && equipHotspotsProps.length > 0) { var i, length = equipHotspotsProps.length; for (i = 0; i < length; i++) { var hotspot = equipHotspotsProps[i]; if (hotspot.position && hotspot.radius && hotspot.joints) { result.push({ key: entityID.toString() + i.toString(), entityID: entityID, localPosition: hotspot.position, worldPosition: entityXform.xformPoint(hotspot.position), radius: hotspot.radius, joints: hotspot.joints, modelURL: hotspot.modelURL, modelScale: hotspot.modelScale }); } } } else { var wearableProps = getWearableData(props); if (wearableProps && wearableProps.joints) { result.push({ key: entityID.toString() + "0", entityID: entityID, localPosition: { x: 0, y: 0, z: 0 }, worldPosition: entityXform.pos, radius: EQUIP_RADIUS, joints: wearableProps.joints, modelURL: null, modelScale: null }); } } return result; }; this.hotspotIsEquippable = function(hotspot, controllerData) { var props = controllerData.nearbyEntityPropertiesByID[hotspot.entityID]; var hasParent = true; if (props.parentID === NULL_UUID) { hasParent = false; } if (hasParent || entityHasActions(hotspot.entityID)) { return false; } return true; }; this.handToController = function() { return (this.hand === RIGHT_HAND) ? Controller.Standard.RightHand : Controller.Standard.LeftHand; }; this.updateSmoothedTrigger = function(controllerData) { var triggerValue = controllerData.triggerValues[this.hand]; // smooth out trigger value this.triggerValue = (this.triggerValue * TRIGGER_SMOOTH_RATIO) + (triggerValue * (1.0 - TRIGGER_SMOOTH_RATIO)); }; this.triggerSmoothedGrab = function() { return this.triggerClicked; }; this.triggerSmoothedSqueezed = function() { return this.triggerValue > TRIGGER_ON_VALUE; }; this.triggerSmoothedReleased = function() { return this.triggerValue < TRIGGER_OFF_VALUE; }; this.secondaryReleased = function() { return this.rawSecondaryValue < BUMPER_ON_VALUE; }; this.chooseNearEquipHotspots = function(candidateEntityProps, controllerData) { var _this = this; var collectedHotspots = flatten(candidateEntityProps.map(function(props) { return _this.collectEquipHotspots(props); })); var controllerLocation = controllerData.controllerLocations[_this.hand]; var worldControllerPosition = controllerLocation.position; var equippableHotspots = collectedHotspots.filter(function(hotspot) { var hotspotDistance = Vec3.distance(hotspot.worldPosition, worldControllerPosition); return _this.hotspotIsEquippable(hotspot, controllerData) && hotspotDistance < hotspot.radius; }); return equippableHotspots; }; this.chooseBestEquipHotspot = function(candidateEntityProps, controllerData) { var equippableHotspots = this.chooseNearEquipHotspots(candidateEntityProps, controllerData); if (equippableHotspots.length > 0) { // sort by distance; var controllerLocation = controllerData.controllerLocations[this.hand]; var worldControllerPosition = controllerLocation.position; equippableHotspots.sort(function(a, b) { var aDistance = Vec3.distance(a.worldPosition, worldControllerPosition); var bDistance = Vec3.distance(b.worldPosition, worldControllerPosition); return aDistance - bDistance; }); return equippableHotspots[0]; } else { return null; } }; this.dropGestureReset = function() { this.prevHandIsUpsideDown = false; }; this.dropGestureProcess = function (deltaTime) { var worldHandRotation = getControllerWorldLocation(this.handToController(), true).orientation; var localHandUpAxis = this.hand === RIGHT_HAND ? { x: 1, y: 0, z: 0 } : { x: -1, y: 0, z: 0 }; var worldHandUpAxis = Vec3.multiplyQbyV(worldHandRotation, localHandUpAxis); var DOWN = { x: 0, y: -1, z: 0 }; var DROP_ANGLE = Math.PI / 3; var HYSTERESIS_FACTOR = 1.1; var ROTATION_ENTER_THRESHOLD = Math.cos(DROP_ANGLE); var ROTATION_EXIT_THRESHOLD = Math.cos(DROP_ANGLE * HYSTERESIS_FACTOR); var rotationThreshold = this.prevHandIsUpsideDown ? ROTATION_EXIT_THRESHOLD : ROTATION_ENTER_THRESHOLD; var handIsUpsideDown = false; if (Vec3.dot(worldHandUpAxis, DOWN) > rotationThreshold) { handIsUpsideDown = true; } if (handIsUpsideDown != this.prevHandIsUpsideDown) { this.prevHandIsUpsideDown = handIsUpsideDown; Controller.triggerHapticPulse(HAPTIC_DEQUIP_STRENGTH, HAPTIC_DEQUIP_DURATION, this.hand); } return handIsUpsideDown; }; this.clearEquipHaptics = function() { this.prevPotentialEquipHotspot = null; }; this.updateEquipHaptics = function(potentialEquipHotspot, currentLocation) { if (potentialEquipHotspot && !this.prevPotentialEquipHotspot || !potentialEquipHotspot && this.prevPotentialEquipHotspot) { Controller.triggerHapticPulse(HAPTIC_TEXTURE_STRENGTH, HAPTIC_TEXTURE_DURATION, this.hand); this.lastHapticPulseLocation = currentLocation; } else if (potentialEquipHotspot && Vec3.distance(this.lastHapticPulseLocation, currentLocation) > HAPTIC_TEXTURE_DISTANCE) { Controller.triggerHapticPulse(HAPTIC_TEXTURE_STRENGTH, HAPTIC_TEXTURE_DURATION, this.hand); this.lastHapticPulseLocation = currentLocation; } this.prevPotentialEquipHotspot = potentialEquipHotspot; }; this.startEquipEntity = function (controllerData) { this.dropGestureReset(); this.clearEquipHaptics(); Controller.triggerHapticPulse(HAPTIC_PULSE_STRENGTH, HAPTIC_PULSE_DURATION, this.hand); // if an object is "equipped" and has a predefined offset, use it. var offsets = getAttachPointForHotspotFromSettings(this.grabbedHotspot, this.hand); if (offsets) { this.offsetPosition = offsets[0]; this.offsetRotation = offsets[1]; } else { var handJointName = this.hand === RIGHT_HAND ? "RightHand" : "LeftHand"; if (this.grabbedHotspot.joints[handJointName]) { this.offsetPosition = this.grabbedHotspot.joints[handJointName][0]; this.offsetRotation = this.grabbedHotspot.joints[handJointName][1]; } } var handJointIndex; if (this.ignoreIK) { handJointIndex = this.controllerJointIndex; } else { handJointIndex = MyAvatar.getJointIndex(this.hand === RIGHT_HAND ? "RightHand" : "LeftHand"); } var reparentProps = { parentID: AVATAR_SELF_ID, parentJointIndex: handJointIndex, velocity: {x: 0, y: 0, z: 0}, angularVelocity: {x: 0, y: 0, z: 0}, localPosition: this.offsetPosition, localRotation: this.offsetRotation }; Entities.editEntity(this.targetEntityID, reparentProps); Messages.sendMessage('Hifi-Object-Manipulation', JSON.stringify({ action: 'equip', grabbedEntity: this.targetEntityID, joint: this.hand === RIGHT_HAND ? "RightHand" : "LeftHand" })); }; this.endEquipEntity = function () { Entities.editEntity(this.targetEntityID, { parentID: NULL_UUID, parentJointIndex: -1 }); var args = [this.hand === RIGHT_HAND ? "right" : "left", MyAvatar.sessionUUID]; Entities.callEntityMethod(this.targetEntityID, "releaseEquip", args); ensureDynamic(this.targetEntityID); this.targetEntityID = null; }; this.updateInputs = function (controllerData) { this.rawTriggerValue = controllerData.triggerValues[this.hand]; this.triggerClicked = controllerData.triggerClicks[this.hand]; this.rawSecondaryValue = controllerData.secondaryValues[this.hand]; this.updateSmoothedTrigger(controllerData); }; this.checkNearbyHotspots = function (controllerData, deltaTime, timestamp) { this.controllerJointIndex = getControllerJointIndex(this.hand); if (this.triggerSmoothedReleased() && this.secondaryReleased()) { this.waitForTriggerRelease = false; } var controllerLocation = getControllerWorldLocation(this.handToController(), true); var worldHandPosition = controllerLocation.position; var candidateEntityProps = controllerData.nearbyEntityProperties[this.hand]; var potentialEquipHotspot = this.chooseBestEquipHotspot(candidateEntityProps, controllerData); if (!this.waitForTriggerRelease) { this.updateEquipHaptics(potentialEquipHotspot, worldHandPosition); } var nearEquipHotspots = this.chooseNearEquipHotspots(candidateEntityProps, controllerData); equipHotspotBuddy.updateHotspots(nearEquipHotspots, timestamp); if (potentialEquipHotspot) { equipHotspotBuddy.highlightHotspot(potentialEquipHotspot); } equipHotspotBuddy.update(deltaTime, timestamp, controllerData); if (potentialEquipHotspot) { if (this.triggerSmoothedSqueezed() && !this.waitForTriggerRelease) { this.grabbedHotspot = potentialEquipHotspot; this.targetEntityID = this.grabbedHotspot.entityID; this.startEquipEntity(controllerData); } return makeRunningValues(true, [potentialEquipHotspot.entityID], []); } else { return makeRunningValues(false, [], []); } }; this.isReady = function (controllerData, deltaTime) { var timestamp = Date.now(); this.updateInputs(controllerData); return this.checkNearbyHotspots(controllerData, deltaTime, timestamp); }; this.run = function (controllerData, deltaTime) { var timestamp = Date.now(); this.updateInputs(controllerData); if (!this.targetEntityID) { return this.checkNearbyHotspots(controllerData, deltaTime, timestamp); } if (controllerData.secondaryValues[this.hand]) { // this.secondaryReleased() will always be true when not depressed // so we cannot simply rely on that for release - ensure that the // trigger was first "prepared" by being pushed in before the release this.preparingHoldRelease = true; } if (this.preparingHoldRelease && !controllerData.secondaryValues[this.hand]) { // we have an equipped object and the secondary trigger was released // short-circuit the other checks and release it this.preparingHoldRelease = false; this.endEquipEntity(); return makeRunningValues(false, [], []); } var dropDetected = this.dropGestureProcess(deltaTime); if (this.triggerSmoothedReleased()) { this.waitForTriggerRelease = false; } if (dropDetected && this.prevDropDetected != dropDetected) { this.waitForTriggerRelease = true; } // highlight the grabbed hotspot when the dropGesture is detected. if (dropDetected) { equipHotspotBuddy.updateHotspot(this.grabbedHotspot, timestamp); equipHotspotBuddy.highlightHotspot(this.grabbedHotspot); } if (dropDetected && !this.waitForTriggerRelease && this.triggerSmoothedGrab()) { this.waitForTriggerRelease = true; // store the offset attach points into preferences. if (this.grabbedHotspot && this.targetEntityID) { var prefprops = Entities.getEntityProperties(this.targetEntityID, ["localPosition", "localRotation"]); if (prefprops && prefprops.localPosition && prefprops.localRotation) { storeAttachPointForHotspotInSettings(this.grabbedHotspot, this.hand, prefprops.localPosition, prefprops.localRotation); } } this.endEquipEntity(); return makeRunningValues(false, [], []); } this.prevDropDetected = dropDetected; equipHotspotBuddy.update(deltaTime, timestamp, controllerData); return makeRunningValues(true, [this.targetEntityID], []); }; this.cleanup = function () { if (this.targetEntityID) { this.endEquipEntity(); } }; } var leftEquipEntity = new EquipEntity(LEFT_HAND); var rightEquipEntity = new EquipEntity(RIGHT_HAND); enableDispatcherModule("LeftEquipEntity", leftEquipEntity); enableDispatcherModule("RightEquipEntity", rightEquipEntity); this.cleanup = function () { leftEquipEntity.cleanup(); rightEquipEntity.cleanup(); disableDispatcherModule("LeftEquipEntity"); disableDispatcherModule("RightEquipEntity"); }; Script.scriptEnding.connect(this.cleanup); }());