diff --git a/applications/metadata.js b/applications/metadata.js index 3252569..255067b 100644 --- a/applications/metadata.js +++ b/applications/metadata.js @@ -52,6 +52,15 @@ var metadata = { "applications": [ "jsfile": "vr-grabscale/VRBuildGrabScale.js", "icon": "vr-grabscale/logo.png", "caption": "VR SCALE" + }, + { + "isActive": true, + "directory": "radar", + "name": "Radar", + "description": "Show where people are and teleport in the domain.", + "jsfile": "radar/radar.js", + "icon": "radar/assets/radar-i.svg", + "caption": "RADAR" } ] }; \ No newline at end of file diff --git a/applications/radar/LICENSE b/applications/radar/LICENSE new file mode 100644 index 0000000..3dbc148 --- /dev/null +++ b/applications/radar/LICENSE @@ -0,0 +1,3 @@ +Distributed under the Apache License, Version 2.0. + +See: http://www.apache.org/licenses/LICENSE-2.0.html \ No newline at end of file diff --git a/applications/radar/README.md b/applications/radar/README.md new file mode 100644 index 0000000..c0c0bf9 --- /dev/null +++ b/applications/radar/README.md @@ -0,0 +1,10 @@ +# radar.js + +Show where people are and teleport in the domain. + +Information: http://ctrlaltstudio.com/vircadia/radar + +The master source for this app is in the following repository: +https://github.com/ctrlaltdavid/cas-vircadia-stuff/tree/master/apps/radar + +[LICENSE](LICENSE) diff --git a/applications/radar/assets/radar-a.svg b/applications/radar/assets/radar-a.svg new file mode 100644 index 0000000..e42f5aa --- /dev/null +++ b/applications/radar/assets/radar-a.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/applications/radar/assets/radar-circle-overlay.png b/applications/radar/assets/radar-circle-overlay.png new file mode 100644 index 0000000..aff1c4a Binary files /dev/null and b/applications/radar/assets/radar-circle-overlay.png differ diff --git a/applications/radar/assets/radar-i.svg b/applications/radar/assets/radar-i.svg new file mode 100644 index 0000000..fc3c5d7 --- /dev/null +++ b/applications/radar/assets/radar-i.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/applications/radar/html/fonts/FiraSans-Regular.ttf b/applications/radar/html/fonts/FiraSans-Regular.ttf new file mode 100644 index 0000000..d9fdc0e Binary files /dev/null and b/applications/radar/html/fonts/FiraSans-Regular.ttf differ diff --git a/applications/radar/html/fonts/FiraSans-SemiBold.ttf b/applications/radar/html/fonts/FiraSans-SemiBold.ttf new file mode 100644 index 0000000..821a43d Binary files /dev/null and b/applications/radar/html/fonts/FiraSans-SemiBold.ttf differ diff --git a/applications/radar/html/fonts/Raleway-Regular.ttf b/applications/radar/html/fonts/Raleway-Regular.ttf new file mode 100644 index 0000000..c6ec2f0 Binary files /dev/null and b/applications/radar/html/fonts/Raleway-Regular.ttf differ diff --git a/applications/radar/html/fonts/Raleway-SemiBold.ttf b/applications/radar/html/fonts/Raleway-SemiBold.ttf new file mode 100644 index 0000000..d61efa3 Binary files /dev/null and b/applications/radar/html/fonts/Raleway-SemiBold.ttf differ diff --git a/applications/radar/html/radar.css b/applications/radar/html/radar.css new file mode 100644 index 0000000..b274d8f --- /dev/null +++ b/applications/radar/html/radar.css @@ -0,0 +1,474 @@ +/*! +radar.css + +Created by David Rowe on 19 Nov 2017. +Copyright 2017-2020 David Rowe. + +Information: http://ctrlaltstudio.com/vircadia/radar + +Distributed under the Apache License, Version 2.0. +See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html + +Attributions: +- Raleway font: https://github.com/impallari/Raleway. Copyright (c) 2010, Matt McInerney (matt@pixelspread.com), Copyright (c) + 2011, Pablo Impallari (www.impallari.com|impallari@gmail.com), Copyright (c) 2011, Rodrigo Fuenzalida + (www.rfuenzalida.com|hello@rfuenzalida.com), with Reserved Font Name < Raleway >. Licensed under the SIL Open Font License, + Version 1.1, available at http://scripts.sil.org/OFL. +- Fira Sans font: https://github.com/mozilla/Fira. Digitized data copyright (c) 2012-2015, The Mozilla + Foundation and Telefonica S.A. with Reserved Font Name < Fira >. Licensed under the SIL Open Font License, Version 1.1, + available at http://scripts.sil.org/OFL. +*/ + +@font-face { + font-family: Raleway-Regular; + src: url(./fonts/Raleway-Regular.ttf) +} + +@font-face { + font-family: Raleway-SemiBold; + src: url(./fonts/Raleway-SemiBold.ttf) +} + +@font-face { + font-family: FiraSans-Regular; + src: url(./fonts/FiraSans-Regular.ttf) +} + +@font-face { + font-family: FiraSans-SemiBold; + src: url(./fonts/FiraSans-SemiBold.ttf) +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; + user-select: none; +} + +html { + width: 100%; + height: 100%; + color: #e3e3e3; + background-color: #404040; +} + +body { + width: 100%; + height: 100%; + overflow: hidden; +} + +h1 { + position: relative; + top: 9px; + font-family: "Raleway-Regular"; + font-weight: normal; + font-size: 18px; + color: #ffffff; + text-shadow: 1px 1px #252525; +} + + h1 span { + font-size: 14px; + } + +hr { + width: 100%; + height: 2px; + border: none; + border-top: 1px solid #252525; + border-bottom: 1px solid #575757; +} + +header { + position: relative; + top: 0; + left: 0; + width: 100%; + height: 40px; + padding: 0 20px 0 20px; +} + + header div { + position: absolute; + right: 20px; + top: 8px; + font-size: 17px; + font-weight: bold; + } + + header div span { + padding-left: 10px; + } + + header div span:hover { + color: #00b4ef; + } + + header div #help-button { + font-size: 21px; + position: relative; + top: 1px; + } + +header hr { + position: absolute; + left: 0; + bottom: 0; +} + +section { + margin: 40px 20px 40px 20px; +} + +#radar-circle { + position: relative; + width: 420px; + height: 420px; + margin: 0 auto 0 auto; + border-radius: 210px; + background-color: rgb(48, 48, 48); +} + +#radar-circle-display { + width: 100%; + height: 100%; +} + +#radar-circle-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 214px; + border: 1px solid #575757; + background: radial-gradient(rgba(48, 48, 48, 0.0) 0%, rgba(48, 48, 48, 0.0) 60%, rgba(87, 87, 87, 1.0) 80%); + pointer-events: none; + clip-path: circle(210px at center); +} + + #radar-circle-overlay img { + width: 100%; + height: 100%; + pointer-events: none; + } + +.dot { + position: absolute; + width: 8px; + height: 8px; + margin-left: -4px; + margin-top: -4px; + border-radius: 4px; + padding: 0; + z-index: 20; + text-align: center; +} + + .dot:hover { + width: 12px; + height: 12px; + margin-top: -6px; + margin-left: -6px; + border-radius: 6px; + padding: 2px; + z-index: 30; + } + + .dot.highlighted { + width: 12px; + height: 12px; + margin-top: -6px; + margin-left: -6px; + border-radius: 6px; + border: 2px solid #00ff00; + padding: 2px; + } + + .dot.labeled { + z-index: 30 !important; + } + +.my-dot { + background-color: #00ff00 !important; + z-index: 10; +} + +#avatar-label { + position: absolute; + height: 14px; + padding: 0 3px 0 3px; + border-radius: 3px; + margin-top: -16px; + color: #ffffff; + background-color: rgba(48, 48, 48, 0.35); + font-family: "Raleway-Regular"; + font-weight: normal; + font-size: 14px; + line-height: 13px; + text-align: center; + white-space: nowrap; + z-index: 40; + display: none; +} + +#teleport-circle { + position: absolute; + width: 82px; + height: 82px; + border-radius: 41px; + background-color: rgba(0, 255, 0, 0.2); + display: none; +} + +#avatar-count { + position: absolute; + right: 0; + top: 0; + font-family: "Raleway-Regular"; + font-weight: normal; + font-size: 15px; +} + + #avatar-count span { + display: inline-block; + min-width: 15px; + text-align: right; + font-family: "FiraSans-Regular"; + font-size: 15px; + } + +#radar-scale { + width: 420px; + margin: 10px auto 10px auto; +} + + #radar-scale td { + width: 33%; + text-align: center; + font-family: "FiraSans-Regular"; + font-weight: normal; + font-size: 15px; + } + + #radar-scale td:first-child { + text-align: left; + } + + #radar-scale td:last-child { + text-align: right; + } + +footer { + position: relative; + width: 100%; + padding: 40px 20px 0 20px; +} + + footer hr { + position: absolute; + left: 0; + top: 0; + } + + +#scale { + width: 100%; +} + + #scale thead td { + text-align: left; + padding-bottom: 5px; + font-family: "Raleway-SemiBold"; + font-weight: normal; + font-size: 14px; + } + + #scale tbody td { + display: block; + float: left; + width: 7.6%; + text-align: center; + font-family: "FiraSans-Regular"; + font-weight: normal; + font-size: 15px; + } + + #scale input[type=radio]:checked + label::before { + line-height: 11px; + } + +.units { + font-family: "Raleway-Regular"; + font-weight: normal; + font-size: 13px; +} + +input[type=radio] { + display: none; +} + + input[type=radio] + label::before { + content: ""; + width: 12px; + height: 12px; + border-radius: 6px; + padding: 0; + background-image: linear-gradient(#7D7D7D, #6B6A6B); + display: inline-block; + margin: 0 10px 0 10px; + line-height: 11px; + position: relative; + top: 1px; + } + + input[type=radio]:checked + label::before { + font-size: 18px; + content: "\25cf"; + color: #00b4ef; + line-height: 9px; + position: relative; + top: 0; + } + + input[type=radio] + label:hover::before { + background-image: linear-gradient(#ffffff, #afafaf); + } + + +form.dialog { + position: absolute; + top: 40px; + bottom: 0; + left: 0; + right: 0; + margin: 20px; + padding: 20px; + z-index: 100; + border-radius: 15px; + border: 1px solid #676767; + box-shadow: 1px 1px #252525; + background-color: #505050; + font-family: "Raleway-Regular"; + font-weight: normal; + font-size: 14px; + color: #f3f3f3; + visibility: hidden; +} + + form.dialog.visible { + visibility: visible; + } + +h2 { + font-family: "Raleway-Regular"; + font-weight: normal; + font-size: 16px; + color: #ffffff; + text-shadow: 1px 1px #252525; +} + +#version { + position: absolute; + top: 20px; + right: 20px; + font-family: "FiraSans-Regular"; + font-weight: normal; + font-size: 15px; + font-style: italic; + color: #e3e3e3; +} + +p { + margin-top: 15px; +} + +ul { + margin-left: 20px; +} + +li { + margin-top: 3px; +} + + li li { + margin-top: 1px; + } + +a { + color: #00c0ff; + text-decoration: none; +} + + a:hover { + text-decoration: underline; + } + +.radio { + margin-left: 0; +} + + .radio li { + list-style-type: none; + margin-top: 5px; + } + +.ok { + position: absolute; + bottom: 20px; + right: 20px; +} + + .ok input { + width: 100px; + height: 30px; + border: none; + border-radius: 7.5px; + font-size: 13px; + font-weight: bold; + color: #f8f8f8; + } + + .ok input { + background: linear-gradient(#00b4ef, #1080b8); + } + + .ok input:focus { + outline: none; + } + + .ok input:hover { + background: linear-gradient(#00b4ef, #00b4ef); + } + +#help-content { + margin-top: 15px; + padding-right: 4px; + height: 502px; + overflow-y: auto; +} + + #help-content p:first-child { + margin-top: 0; + } + +::-webkit-scrollbar { + width: 18px; + height: 18px; +} + +::-webkit-scrollbar-button { + display: none; +} + +::-webkit-scrollbar-track { + border-radius: 9px; + background: #484848; +} + +::-webkit-scrollbar-thumb { + border-radius: 9px; + background: #707070; +} diff --git a/applications/radar/html/radar.html b/applications/radar/html/radar.html new file mode 100644 index 0000000..42d20fd --- /dev/null +++ b/applications/radar/html/radar.html @@ -0,0 +1,179 @@ + + + + + + + + + Radar + + + + +
+

Radar

+
+ + ? +
+
+
+ +
+
+
+ +
+
+
+
+
Avatars: 0
+
+ + +
0m
+
+ + + +
+

Settings

+ +

Show own avatar:

+ + +

Refresh rate:

+ + +
+ +
+
+ +
+

About

+ +
2.3.0
+ +
+ +

Shows where people are in the domain.

+ + + +

Settings:

+ + +

Disclaimers:

+ + +

More information: http://ctrlaltstudio.com
+ Copyright 2017-2020 David Rowe.
+ Distributed under the Apache License, 2.0..
+ Donations appreciated. +

+ +
+ +
+ +
+
+ + + + + diff --git a/applications/radar/html/radar.js b/applications/radar/html/radar.js new file mode 100644 index 0000000..18caca0 --- /dev/null +++ b/applications/radar/html/radar.js @@ -0,0 +1,1275 @@ +/*! +radar.js + +Created by David Rowe on 19 Nov 2017. +Copyright 2017-2020 David Rowe. + +Information: http://ctrlaltstudio.com/vircadia/radar + +Distributed under the Apache License, Version 2.0. +See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +*/ + +/* global EventBridge */ +/* eslint-env browser */ + +(function () { + + "use strict"; + + var APP_VERSION = "2.3.0", + + INSTRUMENT = false, + teleportSearchTimestamp, + updateAvatarsTimestamp, + + // Controls. + RADAR_RANGE_DEFAULT = 20, + + // EventBridge ID. + SCRIPT_ID = "cas.radar", + + // EventBridge messages. + READY_MESSAGE = "readyMessage", // Engine <=> Dialog + VERSIONS_MESSAGE = "versionsMessage", // Engine <== Dialog + GET_CONTROLS_MESSAGE = "getControlsMessage", // Engine <== Dialog + SET_CONTROLS_MESSAGE = "setControlsMessage", // Engine <=> Dialog + GET_SETTINGS_MESSAGE = "getSettingsMessage", // Engine <== Dialog + SET_SETTINGS_MESSAGE = "setSettingsMessage", // Engine <=> Dialog + ROTATION_MESSAGE = "rotationMessage", // Engine ==> Dialog + AVATARS_MESSAGE = "avatarsMessage", // Engine <=> Dialog - World coordinates relative to camera. + CLEAR_MESSAGE = "clearMessage", // Engine ==> Dialog + TELEPORT_MESSAGE = "teleportMessage", // Engine <== Dialog - World coordinates relative to camera. + OPEN_URL_MESSAGE = "openURLMessage", // Engine <== Dialog + LOG_MESSAGE = "logMessage", // Engine <== Dialog + + // Application objects. + Communications, + Menu, + Radar, + Controls, + SettingsDialog, + HelpDialog, + Controller, + + isHMDMode = false, + + //INFO = "INFO:", + WARNING = "WARNING:", + ERROR = "ERROR:", + ERROR_MISSING_CASE = "Missing case:", + ERROR_REINITIALIZATION = "Reinitialization"; + + //#region Utilities ======================================================================================================== + + function log() { + var i, length, strings = []; + + for (i = 0, length = arguments.length; i < length; i++) { + strings.push(arguments[i]); + } + + Communications.log(strings.join(" ")); + } + + //#endregion + + //#region Communications =================================================================================================== + + Communications = (function () { + // Communications with the main script. + + var + isEventBridgeConnected = false; + + function log(message) { + EventBridge.emitWebEvent(JSON.stringify({ + id: SCRIPT_ID, + type: LOG_MESSAGE, + message: message + })); + } + + function sendControls(controls) { + EventBridge.emitWebEvent(JSON.stringify({ + id: SCRIPT_ID, + type: SET_CONTROLS_MESSAGE, + controls: controls + })); + } + + function sendSettings(settings) { + EventBridge.emitWebEvent(JSON.stringify({ + id: SCRIPT_ID, + type: SET_SETTINGS_MESSAGE, + settings: settings + })); + } + + function teleportBy(vector, isAvatar) { + EventBridge.emitWebEvent(JSON.stringify({ + id: SCRIPT_ID, + type: TELEPORT_MESSAGE, + vector: vector, + isAvatar: isAvatar + })); + } + + function checkVersions() { + EventBridge.emitWebEvent(JSON.stringify({ + id: SCRIPT_ID, + type: VERSIONS_MESSAGE, + scriptVersion: APP_VERSION, + htmlVersion: document.getElementById("version").innerHTML + })); + } + + function requestControls() { + EventBridge.emitWebEvent(JSON.stringify({ + id: SCRIPT_ID, + type: GET_CONTROLS_MESSAGE + })); + } + + function requestSettings() { + EventBridge.emitWebEvent(JSON.stringify({ + id: SCRIPT_ID, + type: GET_SETTINGS_MESSAGE + })); + } + + function openURL(url) { + EventBridge.emitWebEvent(JSON.stringify({ + id: SCRIPT_ID, + type: OPEN_URL_MESSAGE, + url: url + })); + } + + function refreshAvatars() { + EventBridge.emitWebEvent(JSON.stringify({ + id: SCRIPT_ID, + type: AVATARS_MESSAGE + })); + } + + function onScriptEventReceived(data) { + var message; + + try { + message = JSON.parse(data); + } catch (e) { + return; + } + + if (message.id !== SCRIPT_ID) { + return; + } + + switch (message.type) { + case READY_MESSAGE: + isEventBridgeConnected = true; + isHMDMode = message.hmd; + break; + case ROTATION_MESSAGE: + case AVATARS_MESSAGE: + case CLEAR_MESSAGE: + case SET_CONTROLS_MESSAGE: + case SET_SETTINGS_MESSAGE: + // Nothing to do. + break; + default: + log(ERROR, ERROR_MISSING_CASE, 100, message); + } + + Controller.onMessageReceived(message); + } + + function setUp() { + // Set up even bridge. + // The EventBridge is not always completely available straight away. + var SETUP_RETRY_DELAY = 500; + + if (!isEventBridgeConnected) { + EventBridge.scriptEventReceived.connect(onScriptEventReceived); + EventBridge.emitWebEvent(JSON.stringify({ + id: SCRIPT_ID, + type: READY_MESSAGE + })); + + setTimeout(setUp, SETUP_RETRY_DELAY); + } + } + + function tearDown() { + // Disconnect event bridge. + EventBridge.scriptEventReceived.disconnect(onScriptEventReceived); + } + + return { + checkVersions: checkVersions, + requestControls: requestControls, + requestSettings: requestSettings, + sendControls: sendControls, + refreshAvatars: refreshAvatars, + sendSettings: sendSettings, + openURL: openURL, + teleportBy: teleportBy, + log: log, + setUp: setUp, + tearDown: tearDown + }; + + }()); + + //#endregion + + //#region Radar ============================================================================================================ + + Radar = (function () { + // The radar display circle in the main window. + + var radarRange = RADAR_RANGE_DEFAULT, + RADAR_RANGE_SCALE = 1.02, // Don't display avatar dots outside circle. + radarRangeScale = RADAR_RANGE_SCALE * radarRange, + + RADAR_CIRCLE_RADIUS = 210, // Reflects value in CSS. + radarCircleDisplay, + radarCircleRotation, + DEG_TO_RAD = Math.PI / 180, + + avatarData, + avatarDataProcessIndex, + + avatarDots = {}, + avatarDotsCounter = 0, // Lightweight stand-in for a time stamp. + DOT_RADIUS = 4, // Reflects value in CSS. + hoveredDot = null, + + avatarLabel, + isAvatarLabelVisible = false, + isPersistingAvatarLabel = false, + isMouseOverDot = false, + isMouseOverLabel = false, + uuidForLabel = null, + dotForLabel = null, + + radarCircleOverlay, + + isIgnoreCircleAction = true, + MAX_CLICK_DURATION = 500, + radarCircleCentre, + + teleportCircle, + TELEPORT_CIRCLE_RADIUS = 41, // Reflects value in CSS. + teleportCircleOffset, + isTeleporting = false, + teleportingTimer = null, + + teleportSearchContext = {}, + teleportSearchRadius, + highlightedAvatarIndexes = [], + + updateAvatarDataTimer = null, + avatarCount, + + radarScaleLeft, + radarScaleRight, + + isRunning = false; + + //#region Avatar Label ------------------------------------------------------------------------------------------------- + + function showAvatarLabel(avatarDot) { + uuidForLabel = avatarDot.uuid; + dotForLabel = avatarDot.dot; + dotForLabel.appendChild(avatarLabel); + dotForLabel.classList.add("labeled"); + avatarLabel.innerHTML = avatarDot.name; + avatarLabel.style.display = "block"; + avatarLabel.style.marginLeft = (-avatarLabel.offsetWidth / 2 + DOT_RADIUS) + "px"; + isAvatarLabelVisible = true; + } + + function hideAvatarLabel() { + avatarLabel.style.display = "none"; + uuidForLabel = null; + if (dotForLabel) { + dotForLabel.classList.remove("labeled"); + } + dotForLabel = null; + isAvatarLabelVisible = false; + } + + function onMouseEnterDot(event) { + var uuid; + + if (isMouseOverLabel) { + // Mouse hasn't entered the actual dot; it has entered the label. + return; + } + + isMouseOverDot = true; + uuid = event.target.getAttribute("uuid"); + hoveredDot = avatarDots[uuid]; + + if (!hoveredDot) { + // Should never happen but handle just in case. + hideAvatarLabel(); + return; + } + + if (!isPersistingAvatarLabel) { + hoveredDot.dot.style.transform = "rotate(" + (-radarCircleRotation) + "deg)"; + showAvatarLabel(hoveredDot); + } + } + + function onMouseLeaveDot() { + if (!isMouseOverDot) { + // Mouse wasn't over actual dot; it was over label. + return; + } + + isMouseOverDot = false; + hoveredDot = null; + + if (isAvatarLabelVisible && !isPersistingAvatarLabel) { + hideAvatarLabel(); + } + } + + function onMouseOverLabel() { + if (!isPersistingAvatarLabel) { + // Moved onto the label from the dot. + hideAvatarLabel(); + } else { + isMouseOverLabel = true; + } + } + + function onMouseOutLabel() { + isMouseOverLabel = false; + } + + function doRadarCircleOverlayClick() { + if (isMouseOverDot) { + // Clicked dot. + if (isPersistingAvatarLabel) { + if (hoveredDot.uuid === uuidForLabel) { + // Hide label for current dot. + isPersistingAvatarLabel = false; + hideAvatarLabel(); + } else { + // Transfer label to a new dot. + hoveredDot.dot.style.transform = "rotate(" + (-radarCircleRotation) + "deg)"; + showAvatarLabel(hoveredDot); + } + } else { + if (!isAvatarLabelVisible) { + // Handle label being hidden because just hid for the current dot. + hoveredDot.dot.style.transform = "rotate(" + (-radarCircleRotation) + "deg)"; + showAvatarLabel(hoveredDot); + } + isPersistingAvatarLabel = true; + } + } else { + // Clicked radar circle background. + if (isAvatarLabelVisible && isPersistingAvatarLabel) { + hideAvatarLabel(); + isPersistingAvatarLabel = false; + } + } + } + + //#endregion + + //#region Teleport ----------------------------------------------------------------------------------------------------- + + function calculateRadarVector(x, y) { + // Calculate radar-relative x, z coordinates from screen x, y. + var deltaX = x - radarCircleCentre.x, + deltaY = radarCircleCentre.y - y, + distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY), + angle = Math.atan2(deltaY / distance, deltaX / distance); + + angle = angle + radarCircleRotation * DEG_TO_RAD; + distance = distance * radarRangeScale / RADAR_CIRCLE_RADIUS; + + return { + x: distance * Math.cos(angle), + y: 0, + z: -distance * Math.sin(angle) + }; + } + + function setTeleportSearchRadius() { + // Avatar dots must be wholly within the teleport search circle, not just touching. + teleportSearchRadius = (TELEPORT_CIRCLE_RADIUS - DOT_RADIUS) / RADAR_CIRCLE_RADIUS * radarRangeScale; + } + + function highlightAvatarDots(avatarIndexes) { + var i, length; + + // Remove old highlights. + for (i = highlightedAvatarIndexes.length - 1; i >= 0; i -= 1) { + if (avatarIndexes.indexOf(highlightedAvatarIndexes[i]) === -1) { + avatarDots[avatarData[highlightedAvatarIndexes[i]].uuid].dot.classList.remove("highlighted"); + highlightedAvatarIndexes.splice(i, 1); + } + } + + // Add new highlights. + for (i = 0, length = avatarIndexes.length; i < length; i += 1) { + if (highlightedAvatarIndexes.indexOf(avatarIndexes[i]) === -1) { + avatarDots[avatarData[avatarIndexes[i]].uuid].dot.classList.add("highlighted"); + highlightedAvatarIndexes.push(avatarIndexes[i]); + } + } + } + + function doTeleportSearch() { + var index, + finishIndex, + avatarPosition, + targetVector, + deltaVector, + distanceSquared, + closestDistanceSquared, + teleportSearchRadiusSquared, + closestAvatarIndex = -1, + avatarsInCircle = [], + targetElevation, + MAX_ELEVATION_DIFFERENCE = 1.0, + avatarsToHighlight = [], + i, length; + + if (avatarData.length === 0) { + return; + } + + if (INSTRUMENT) { + teleportSearchTimestamp = Date.now(); + } + + index = Math.min(teleportSearchContext.currentIndex, avatarData.length - 1); + finishIndex = Math.min(teleportSearchContext.finishIndex, avatarData.length - 1); + + // Find horizontally-closest avatar within teleport search circle. + targetVector = + calculateRadarVector(teleportSearchContext.targetPosition.x, teleportSearchContext.targetPosition.y); + teleportSearchRadiusSquared = teleportSearchRadius * teleportSearchRadius; + closestDistanceSquared = 2 * radarRange * radarRange; + do { + index = (index + 1) % avatarData.length; + + avatarPosition = avatarData[index].vector; + deltaVector = { x: targetVector.x - avatarPosition.x, z: targetVector.z - avatarPosition.z }; + + // Use squared distances to save a Math.sqrt() call each loop. + distanceSquared = deltaVector.x * deltaVector.x + deltaVector.z * deltaVector.z; + if (distanceSquared < teleportSearchRadiusSquared) { + avatarsInCircle.push(index); + if (distanceSquared < closestDistanceSquared) { + closestDistanceSquared = distanceSquared; + closestAvatarIndex = index; + } + } + + } while (index !== finishIndex); + + if (closestAvatarIndex !== -1) { + teleportSearchContext.closestAvatarData = avatarData[closestAvatarIndex]; + targetElevation = avatarData[closestAvatarIndex].vector.y; + for (i = 0, length = avatarsInCircle.length; i < length; i += 1) { + if (Math.abs(avatarData[avatarsInCircle[i]].vector.y - targetElevation) <= MAX_ELEVATION_DIFFERENCE) { + avatarsToHighlight.push(avatarsInCircle[i]); + } + } + } else { + teleportSearchContext.closestAvatarData = null; + targetElevation = null; + } + + teleportSearchContext.closestAvatarIndex = closestAvatarIndex; + teleportSearchContext.teleportVector = { + x: targetVector.x, + y: targetElevation, + z: targetVector.z + }; + teleportSearchContext.currentIndex = index; + + highlightAvatarDots(avatarsToHighlight); + + if (INSTRUMENT) { + log("HTML script : teleport search : " + (Date.now() - teleportSearchTimestamp)); + // 2019 01 23 + // Simulation: + // - 100 avatars: 0ms + // - 1000 avatars: 1ms + // - 10000 avatars: 3ms + // Conclusion: Don't need to split into chunks. + } + } + + function startTeleportSearch(x, y) { + // Start searching for elevation of avatar closest to x, y display position. + // Searches in a circular pass through avatarData starting at the start of avatarData. + + isTeleporting = true; + teleportCircle.style.display = "block"; + + teleportSearchContext = { + targetPosition: { x: x, y: y }, + closestAvatarIndex: -1, + closestAvatarData: null, + teleportVector: { x: 0, y: 0, z: 0 }, + avatarsAtElevation: [], + currentIndex: -1, + finishIndex: avatarData.length - 1 + }; + doTeleportSearch(); + } + + function updateTeleportSearch(x, y) { + // Update x, y display position that search is being done for. + // Continue searching in a circular pass through avatarData starting at current search index. + + teleportSearchContext.targetPosition = { x: x, y: y }; + teleportSearchContext.finishIndex = Math.min(teleportSearchContext.currentIndex, avatarData.length - 1); + doTeleportSearch(); + } + + function finishTeleportSearch() { + // Finish searching for elevation of avatar closest to x, y display position. + + isTeleporting = false; + teleportCircle.style.display = "none"; + highlightAvatarDots([]); + } + + function refreshTeleportSearch() { + // Update search results. + // Some of the new avatarData may have already been processed but don't worry about that. + + teleportSearchContext.finishIndex = Math.min(teleportSearchContext.currentIndex, avatarData.length - 1); + doTeleportSearch(); + } + + function doRadarCircleOverlayTeleport() { + // Teleports to near target position if mouse is over an avatar dot. + Communications.teleportBy(teleportSearchContext.teleportVector, isMouseOverDot); + } + + function calcRadarCircleCentre() { + var radarCircle = document.getElementById("radar-circle"); + radarCircleCentre = { + x: radarCircle.offsetLeft + RADAR_CIRCLE_RADIUS, + y: radarCircle.offsetTop + RADAR_CIRCLE_RADIUS + }; + teleportCircleOffset = { + x: -radarCircleCentre.x + RADAR_CIRCLE_RADIUS - TELEPORT_CIRCLE_RADIUS, + y: -radarCircleCentre.y + RADAR_CIRCLE_RADIUS - TELEPORT_CIRCLE_RADIUS + }; + } + + function isPointInRadarCircle(x, y) { + var deltaX = x - radarCircleCentre.x, + deltaY = y - radarCircleCentre.y; + return Math.sqrt(deltaX * deltaX + deltaY * deltaY) <= RADAR_CIRCLE_RADIUS; + } + + function updateTeleportCirclePosition(x, y) { + teleportCircle.style.left = (x + teleportCircleOffset.x).toString() + "px"; + teleportCircle.style.top = (y + teleportCircleOffset.y).toString() + "px"; + } + + function handlePressOnRadarCircleOverlay(x, y) { + if (!isPointInRadarCircle(x, y)) { + isIgnoreCircleAction = true; + return; + } + isIgnoreCircleAction = false; + + teleportingTimer = setTimeout(function () { + teleportingTimer = null; + startTeleportSearch(x, y); + updateTeleportCirclePosition(x, y); + }, MAX_CLICK_DURATION); + } + + function handleMoveOnRadarCircleOverlay(x, y) { + if (isIgnoreCircleAction) { + return; + } + + if (!isPointInRadarCircle(x, y)) { + isIgnoreCircleAction = true; + if (isTeleporting) { + finishTeleportSearch(); + } + } + + if (isTeleporting) { + updateTeleportSearch(x, y); + updateTeleportCirclePosition(x, y); + } + } + + function handleLeaveOnRadarCircleOverlay() { + isIgnoreCircleAction = true; + if (isTeleporting) { + finishTeleportSearch(); + } + } + + function handleReleaseOnRadarCircleOverlay() { + if (isIgnoreCircleAction) { + return; + } + + if (isTeleporting) { + finishTeleportSearch(); + doRadarCircleOverlayTeleport(); + } else { + clearTimeout(teleportingTimer); + teleportingTimer = null; + doRadarCircleOverlayClick(); + } + } + + function onMouseDownOnRadarCircleOverlay(event) { + if (!isHMDMode) { + handlePressOnRadarCircleOverlay(event.x, event.y); + } + } + + function onMouseMoveOnRadarCircleOverlay(event) { + if (!isHMDMode) { + handleMoveOnRadarCircleOverlay(event.x, event.y); + } + } + + function onMouseLeaveOnRadarCircleOverlay() { + if (!isHMDMode) { + handleLeaveOnRadarCircleOverlay(); + } + } + + function onMouseUpRadarOnCircleOverlay() { + if (!isHMDMode) { + handleReleaseOnRadarCircleOverlay(); + } + } + + function onTouchStartOnRadarCircleOverlay(event) { + if (isHMDMode) { + handlePressOnRadarCircleOverlay(event.touches[0].clientX, event.touches[0].clientY); + } + } + + function onTouchMoveOnRadarCircleOverlay(event) { + if (isHMDMode) { + handleMoveOnRadarCircleOverlay(event.touches[0].clientX, event.touches[0].clientY); + } + } + + function onTouchCancelOnRadarCircleOverlay() { + if (isHMDMode) { + handleLeaveOnRadarCircleOverlay(); + } + } + + function onTouchEndOnRadarCircleOverlay(event) { + if (isHMDMode) { + handleReleaseOnRadarCircleOverlay(event.changedTouches[0].clientX, event.changedTouches[0].clientY); + } + } + + //#endregion + + //#region Avatar Dots -------------------------------------------------------------------------------------------------- + + function maybeAddDot(uuid, name) { + var dot, + avatarDot; + if (!avatarDots.hasOwnProperty(uuid)) { + dot = document.createElement("div"); + dot.className = "dot"; + dot.setAttribute("uuid", uuid); + dot.addEventListener("mouseenter", onMouseEnterDot); + dot.addEventListener("mouseleave", onMouseLeaveDot); + radarCircleDisplay.appendChild(dot); + avatarDots[uuid] = { + dot: dot, + uuid: uuid + }; + } + avatarDot = avatarDots[uuid]; + avatarDot.name = name; // User may have updated their display name so update our copy. + avatarDot.counter = avatarDotsCounter; + return avatarDot.dot; + } + + function removeExtraDots() { + var uuid; + for (uuid in avatarDots) { + if (avatarDots[uuid].counter < avatarDotsCounter) { + radarCircleDisplay.removeChild(avatarDots[uuid].dot); + delete avatarDots[uuid]; + } + } + } + + function elevationColor(scale) { + if (scale > 0) { + return "hsl(210,100%," + (100 - scale * 50) + "%)"; // Fade white to sky blue. + } + return "hsl(0,100%," + (100 + scale * 50) + "%)"; // Fade white to red. + } + + function updateAvatarData(range, data) { + var startTime, + datum, + vector, + dot, + MAX_LOOP_TIME = 50, + RECALL_DELAY = 5; + + updateAvatarDataTimer = null; + + if (range) { + // Start processing run. + + if (INSTRUMENT) { + updateAvatarsTimestamp = Date.now(); + } + + avatarData = range === radarRange ? data : []; // Ignore data if it's for a previous radar range. + avatarDataProcessIndex = 0; + + avatarCount.innerHTML = avatarData.length.toString(); + avatarDotsCounter += 1; + } + + if (INSTRUMENT) { + // 2019 01 09: + // - Localhost with one avatar recording, no teleport searching, second call seems to happen at ~5000 avatars. + log("HTML script : avatars to display : " + (avatarData.length - avatarDataProcessIndex)); + } + + // Yielding loop for process run to allow UI to happen while processing lots of avatar data. + startTime = Date.now(); + while (avatarDataProcessIndex < avatarData.length > 0 && (Date.now() - startTime) < MAX_LOOP_TIME) { + datum = avatarData[avatarDataProcessIndex]; + vector = datum.vector; + dot = maybeAddDot(datum.uuid, datum.name); + dot.style.left = ((1 + vector.x / radarRangeScale) * RADAR_CIRCLE_RADIUS).toString() + "px"; + dot.style.top = ((1 + vector.z / radarRangeScale) * RADAR_CIRCLE_RADIUS).toString() + "px"; + dot.style.backgroundColor = elevationColor(vector.y / radarRangeScale); + if (datum.isMyAvatar) { + dot.classList.add("my-dot"); + } + + // Re-add persisted label if dot has reappeared after going off screen. + if (isPersistingAvatarLabel && datum.uuid === uuidForLabel) { + dot.style.transform = "rotate(" + (-radarCircleRotation) + "deg)"; + showAvatarLabel(avatarDots[datum.uuid]); + } + + avatarDataProcessIndex += 1; + } + + if (avatarDataProcessIndex < avatarData.length && isRunning) { + // Continue processing run. + updateAvatarDataTimer = setTimeout(updateAvatarData, RECALL_DELAY); + } else { + // Finish processing run. + removeExtraDots(); + Communications.refreshAvatars(); // Notify main script that have finished displaying. + + if (INSTRUMENT) { + log("HTML script : update avatars : " + (Date.now() - updateAvatarsTimestamp)); + // 2019 01 07 + // Simulation: + // - 100 avatars: 3ms for first call; 1.5ms for subsequent calls. + // - 1000 avatars: 20ms for first call; 8ms for subsequent calls. + } + } + + if (isTeleporting) { + // Update teleport search UI whether or not avatar data has finished being processed. + refreshTeleportSearch(); + } + } + + function clearAvatarData() { + avatarData = []; + avatarDotsCounter += 1; + removeExtraDots(); + } + + //#endregion + + //#region Circle ------------------------------------------------------------------------------------------------------ + + function updateRotation(rotation) { + radarCircleDisplay.style.transform = "rotate(" + rotation + "deg)"; + if (isPersistingAvatarLabel) { + dotForLabel.style.transform = "rotate(" + (-rotation) + "deg)"; + } else if (hoveredDot) { + hoveredDot.dot.style.transform = "rotate(" + (-rotation) + "deg)"; + } + radarCircleRotation = rotation; + } + + function setCircleScale() { + var scale = radarRange + "m"; + scale = scale.replace("000m", ",000m"); + radarScaleLeft.innerHTML = scale; + radarScaleRight.innerHTML = scale; + } + + //#endregion + + //#region Controls ----------------------------------------------------------------------------------------------------- + + function setRange(range) { + clearAvatarData(); // Recreates all avatar dots so that they're the right colour and not outside the circle. + radarRange = range; + radarRangeScale = RADAR_RANGE_SCALE * radarRange; + setCircleScale(); + setTeleportSearchRadius(); + } + + //#endregion + + //#region Set up and tear down ----------------------------------------------------------------------------------------- + + function setUp() { + radarCircleDisplay = document.getElementById("radar-circle-display"); + radarScaleLeft = document.getElementById("radar-scale-left"); + radarScaleRight = document.getElementById("radar-scale-right"); + avatarCount = document.getElementById("avatar-count").getElementsByTagName("span")[0]; + + avatarLabel = document.getElementById("avatar-label"); + avatarLabel.addEventListener("mouseover", onMouseOverLabel, true); + avatarLabel.addEventListener("mouseout", onMouseOutLabel, true); + + calcRadarCircleCentre(); + + radarCircleOverlay = document.getElementById("radar-circle-display"); + + // Touch events don't occur in desktop mode so use mouse events. + radarCircleOverlay.addEventListener("mousedown", onMouseDownOnRadarCircleOverlay); + radarCircleOverlay.addEventListener("mousemove", onMouseMoveOnRadarCircleOverlay); + radarCircleOverlay.addEventListener("mouseleave", onMouseLeaveOnRadarCircleOverlay); + radarCircleOverlay.addEventListener("mouseup", onMouseUpRadarOnCircleOverlay); + + // Mouse events don't occur the same in HMD mode so use touch events. + radarCircleDisplay.addEventListener("touchstart", onTouchStartOnRadarCircleOverlay); + radarCircleDisplay.addEventListener("touchmove", onTouchMoveOnRadarCircleOverlay); + radarCircleDisplay.addEventListener("touchcancel", onTouchCancelOnRadarCircleOverlay); + radarCircleDisplay.addEventListener("touchend", onTouchEndOnRadarCircleOverlay); + + teleportCircle = document.getElementById("teleport-circle"); + + isRunning = true; + } + + function tearDown() { + if (updateAvatarDataTimer !== null) { + clearTimeout(updateAvatarDataTimer); + updateAvatarDataTimer = null; + } + isRunning = false; + } + + //#endregion ----------------------------------------------------------------------------------------------------------- + + return { + updateRotation: updateRotation, + updateAvatarData: updateAvatarData, + clearAvatarData: clearAvatarData, + setRange: setRange, + setUp: setUp, + tearDown: tearDown + }; + + }()); + + //#endregion + + //#region Controls ========================================================================================================= + + Controls = (function () { + // The radar controls in the main window. + + var controlsForm, + controlsChangedCallback, + scaleButtons = [], + i, length; + + function setControls(controls) { + controlsForm["scale"].value = controls.range.toString(); + } + + function setControlsChangedCallback(callback) { + controlsChangedCallback = callback; + } + + function onControlsChanged() { + controlsChangedCallback({ + range: parseInt(controlsForm["scale"].value) + }); + } + + controlsForm = document.getElementById("controls"); + + scaleButtons = document.getElementsByName("scale"); + for (i = 0, length = scaleButtons.length; i < length; i++) { + scaleButtons[i].addEventListener("change", onControlsChanged); + } + + return { + setControls: setControls, + controlsChanged: { + connect: setControlsChangedCallback + } + }; + + }()); + + //#endregion + + //#region Settings Dialog ================================================================================================== + + SettingsDialog = (function () { + // The settings dialog. + // - Changes in settings values are communicated immediately they're changed. This enables the user to freely move + // between the two dialogs and removed the need for a cancel button. + // - The dialog is not automatically closed upon the OK button being pressed. + + var settingsDialog, + settingsChangedCallback, + okClickedCallback, + inputs, + i, length; + + function setSettings(settings) { + settingsDialog["show-own"].value = settings.showOwn.toString(); + settingsDialog["refresh-rate"].value = settings.refreshRate.toString(); + } + + function setSettingsChangedCallback(callback) { + settingsChangedCallback = callback; + } + + function setOKClickedCallback(callback) { + okClickedCallback = callback; + } + + function open() { + settingsDialog.classList.add("visible"); + } + + function close() { + settingsDialog.classList.remove("visible"); + } + + function onSettingsChanged() { + settingsChangedCallback({ + showOwn: parseInt(settingsDialog["show-own"].value), + refreshRate: parseInt(settingsDialog["refresh-rate"].value) + }); + } + + function onOKClicked() { + okClickedCallback(); + } + + settingsDialog = document.getElementById("settings"); + + inputs = document.getElementsByName("show-own"); + for (i = 0, length = inputs.length; i < length; i++) { + inputs[i].addEventListener("change", onSettingsChanged); + } + + inputs = document.getElementsByName("refresh-rate"); + for (i = 0, length = inputs.length; i < length; i++) { + inputs[i].addEventListener("change", onSettingsChanged); + } + + document.querySelector("#settings .ok input").addEventListener("click", onOKClicked); + + return { + setSettings: setSettings, + settingsChanged: { + connect: setSettingsChangedCallback + }, + okClicked: { + connect: setOKClickedCallback + }, + open: open, + close: close + }; + + }()); + + //#endregion + + //#region Help Dialog ====================================================================================================== + + HelpDialog = (function () { + // The help dialog. + // - The dialog is not automatically closed upon the OK button being pressed. + + var helpDialog, + okClickedCallback, + linkClickedCallback; + + function open() { + var links, + i, length; + links = document.querySelectorAll("a"); + for (i = 0, length = links.length; i < length; i++) { + links[i].addEventListener("click", function (event) { + linkClickedCallback(event.target.href); + event.preventDefault(); + }); + } + helpDialog.classList.add("visible"); + } + + function close() { + helpDialog.classList.remove("visible"); + } + + function setOKClickedCallback(callback) { + okClickedCallback = callback; + } + + function setLinkClickedCallback(callback) { + linkClickedCallback = callback; + } + + helpDialog = document.getElementById("help"); + document.querySelector("#help .ok input").addEventListener("click", function () { + okClickedCallback(); + }); + + return { + okClicked: { + connect: setOKClickedCallback + }, + linkClicked: { + connect: setLinkClickedCallback + }, + open: open, + close: close + }; + + }()); + + //#endregion + + //#region Menu ============================================================================================================= + + Menu = (function () { + // The title bar menu (i.e., settings and help buttons). + + var + settingsChangedCallback, + + NO_DIALOG = 0, + HELP_DIALOG = 1, + SETTINGS_DIALOG = 2, + currentDialog = NO_DIALOG; + + function setSettingsChangedCallback(callback) { + settingsChangedCallback = callback; + SettingsDialog.settingsChanged.connect(settingsChangedCallback); + } + + function onSettingsButtonClick() { + switch (currentDialog) { + case SETTINGS_DIALOG: + SettingsDialog.close(); + currentDialog = NO_DIALOG; + break; + case HELP_DIALOG: + HelpDialog.close(); + SettingsDialog.open(); + currentDialog = SETTINGS_DIALOG; + break; + case NO_DIALOG: + SettingsDialog.open(); + currentDialog = SETTINGS_DIALOG; + break; + default: + log(ERROR, ERROR_MISSING_CASE, 200); + } + } + + function onHelpButtonClick() { + switch (currentDialog) { + case SETTINGS_DIALOG: + SettingsDialog.close(); + HelpDialog.open(); + currentDialog = HELP_DIALOG; + break; + case HELP_DIALOG: + HelpDialog.close(); + currentDialog = NO_DIALOG; + break; + case NO_DIALOG: + HelpDialog.open(); + currentDialog = HELP_DIALOG; + break; + default: + log(ERROR, ERROR_MISSING_CASE, 201); + } + } + + function onLinkClick(url) { + Communications.openURL(url); + } + + document.getElementById("settings-button").addEventListener("click", onSettingsButtonClick); + SettingsDialog.okClicked.connect(onSettingsButtonClick); + + document.getElementById("help-button").addEventListener("click", onHelpButtonClick); + HelpDialog.okClicked.connect(onHelpButtonClick); + HelpDialog.linkClicked.connect(onLinkClick); + + return { + settingsChanged: { + connect: setSettingsChangedCallback + } + }; + + }()); + + //#endregion + + //#region Controller ======================================================================================================= + + Controller = (function () { + // The overall application logic. + + var isControlsInitialized = false, + isSettingsInitialized = false, + SETTINGS_DELAY = 50; + + function setControls(controls) { + if (isControlsInitialized) { + log(WARNING, ERROR_REINITIALIZATION, "A"); + } + Controls.setControls(controls); + Radar.setRange(controls.range); + isControlsInitialized = true; + } + + function setSettings(settings) { + if (isSettingsInitialized) { + log(WARNING, ERROR_REINITIALIZATION, "B"); + } + SettingsDialog.setSettings(settings); + isSettingsInitialized = true; + } + + function onMessageReceived(message) { + switch (message.type) { + case READY_MESSAGE: + Communications.checkVersions(); + if (!isControlsInitialized) { + Communications.requestControls(); + } + setTimeout(function () { + if (!isSettingsInitialized) { + Communications.requestSettings(); + } + }, SETTINGS_DELAY); + break; + case SET_CONTROLS_MESSAGE: + setControls(message.controls); + break; + case SET_SETTINGS_MESSAGE: + setSettings(message.settings); + break; + case ROTATION_MESSAGE: + Radar.updateRotation(message.rotation); + break; + case AVATARS_MESSAGE: + Radar.updateAvatarData(message.range, message.data); + break; + case CLEAR_MESSAGE: + Radar.clearAvatarData(); + break; + default: + log(ERROR, ERROR_MISSING_CASE, 300, message); + } + } + + function onSettingsChanged(settings) { + Communications.sendSettings(settings); + } + + function onControlsChanged(controls) { + Communications.sendControls(controls); + Radar.setRange(controls.range); + } + + function setUp() { + Controls.controlsChanged.connect(onControlsChanged); + Menu.settingsChanged.connect(onSettingsChanged); + } + + function tearDown() { + // Nothing to do. + } + + return { + onMessageReceived: onMessageReceived, + setUp: setUp, + tearDown: tearDown + }; + + }()); + + //#endregion + + //#region Set up and tear down ============================================================================================= + + function onUnload() { + // Tear down objects. + Communications.tearDown(); + Controller.tearDown(); + Radar.tearDown(); + } + + function onLoad() { + var nodes, + i, length; + + nodes = document.querySelectorAll(".std"); + for (i = 0, length = nodes.length; i < length; i++) { + nodes[i].parentNode.removeChild(nodes[i]); + } + + // Set up primary objects. + Radar.setUp(); + Controller.setUp(); + Communications.setUp(); + + // Handle document unload. + document.body.onunload = function () { + onUnload(); + }; + } + + onLoad(); + + //#endregion + +}()); diff --git a/applications/radar/radar.js b/applications/radar/radar.js new file mode 100644 index 0000000..bf2d192 --- /dev/null +++ b/applications/radar/radar.js @@ -0,0 +1,844 @@ +/*! +radar.js + +Created by David Rowe on 16 Nov 2017. +Copyright 2017-2020 David Rowe. + +Information: http://ctrlaltstudio.com/vircadia/radar + +Disclaimers: +1. The user identification provided by this app is not guaranteed: users can set their display name to whatever they like. +2. The content of the display names displayed by this app is not moderated by this app. + +Distributed under the Apache License, Version 2.0. +See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +*/ + +/* global AvatarManager, Camera, HMD, MyAvatar, Overlays, Quat, Settings, Vec3, Window, location */ + +(function () { + + "use strict"; + + var APP_NAME = "Radar", + APP_VERSION = "2.3.0", // Version number also needs to be set in web page HTML and JavaScript files. + + SIMULATE = false, + INSTRUMENT = false, + + // Controls. + RADAR_RANGE_DEFAULT = 20, + + // EventBridge ID. + SCRIPT_ID = "cas.radar", + + // EventBridge messages. + READY_MESSAGE = "readyMessage", // Engine <=> Dialog + VERSIONS_MESSAGE = "versionsMessage", // Engine <== Dialog + GET_CONTROLS_MESSAGE = "getControlsMessage", // Engine <== Dialog + SET_CONTROLS_MESSAGE = "setControlsMessage", // Engine <=> Dialog + GET_SETTINGS_MESSAGE = "getSettingsMessage", // Engine <== Dialog + SET_SETTINGS_MESSAGE = "setSettingsMessage", // Engine <=> Dialog + ROTATION_MESSAGE = "rotationMessage", // Engine ==> Dialog + AVATARS_MESSAGE = "avatarsMessage", // Engine <=> Dialog - World coordinates relative to camera. + CLEAR_MESSAGE = "clearMessage", // Engine ==> Dialog + TELEPORT_MESSAGE = "teleportMessage", // Engine <== Dialog - World coordinates relative to camera. + OPEN_URL_MESSAGE = "openURLMessage", // Engine <== Dialog + LOG_MESSAGE = "logMessage", // Engine <== Dialog + + // Application objects. + Preferences, + Communications, + Updates, + App, + + // Global objects. + tablet = null, + + INFO = "INFO:", + //WARN = "WARN:", + ERROR = "ERROR:", + ERROR_MISSING_CASE = "Missing case:"; + + if (SIMULATE) { + Script.include("simulate.js"); + } + + function log() { + var i, length, strings = []; + + for (i = 0, length = arguments.length; i < length; i++) { + strings.push(arguments[i]); + } + + print("[CtrlAltStudio radar.js] " + strings.join(" ")); + } + + //#region Preferences ====================================================================================================== + + Preferences = (function () { + // Manages the application preferences - "controls" and "settings" in the dialog. + + var PREFERENCES_ROOT = "com.ctrlaltstudio.radar.", + RADAR_RANGE_SETTING = PREFERENCES_ROOT + "range", // Must be different name to variable for obfuscation. + radarRange = RADAR_RANGE_DEFAULT, + SHOW_OWN_SETTING = PREFERENCES_ROOT + "ownAvatar", // Must be different name to variable for obfuscation. + SHOW_OWN_NEVER = 0, + SHOW_OWN_THIRD = 1, + SHOW_OWN_ALWAYS = 2, + SHOW_OWN_DEFAULT = SHOW_OWN_THIRD, + showOwn = SHOW_OWN_DEFAULT, + REFRESH_RATE_SETTING = PREFERENCES_ROOT + "updateRate", // Must be different name to variable for obfuscation. + REFRESH_RATE_FASTEST = 4, + REFRESH_RATE_FASTER = 3, + REFRESH_RATE_MEDIUM = 2, + REFRESH_RATE_SLOWER = 1, + REFRESH_RATE_SLOWEST = 0, + REFRESH_RATE_DEFAULT = REFRESH_RATE_MEDIUM, + refreshRate = REFRESH_RATE_DEFAULT, + preferencesChangedCallback = null; + + function getPreferences() { + return { + radarRange: radarRange, + showOwn: showOwn, + refreshRate: refreshRate + }; + } + + function getRadarRange() { + return radarRange; + } + + function setRadarRange(range) { + radarRange = range; + Settings.setValue(RADAR_RANGE_SETTING, radarRange); + preferencesChangedCallback(getPreferences()); + } + + function getShowOwn() { + return showOwn; + } + + function setShowOwn(show) { + showOwn = show; + Settings.setValue(SHOW_OWN_SETTING, showOwn); + preferencesChangedCallback(getPreferences()); + } + + function getRefreshRate() { + return refreshRate; + } + + function setRefreshRate(rate) { + refreshRate = rate; + Settings.setValue(REFRESH_RATE_SETTING, refreshRate); + preferencesChangedCallback(getPreferences()); + } + + function setPreferencesChangedCallback(callback) { + preferencesChangedCallback = callback; + } + + function setUp() { + radarRange = Settings.getValue(RADAR_RANGE_SETTING, RADAR_RANGE_DEFAULT); + showOwn = Settings.getValue(SHOW_OWN_SETTING, SHOW_OWN_DEFAULT); + refreshRate = Settings.getValue(REFRESH_RATE_SETTING, REFRESH_RATE_DEFAULT); + } + + function tearDown() { + // Nothing to do. + } + + return { + getRadarRange: getRadarRange, + setRadarRange: setRadarRange, + SHOW_OWN_NEVER: SHOW_OWN_NEVER, + SHOW_OWN_THIRD: SHOW_OWN_THIRD, + SHOW_OWN_ALWAYS: SHOW_OWN_ALWAYS, + getShowOwn: getShowOwn, + setShowOwn: setShowOwn, + REFRESH_RATE_SLOWEST: REFRESH_RATE_SLOWEST, + REFRESH_RATE_SLOWER: REFRESH_RATE_SLOWER, + REFRESH_RATE_MEDIUM: REFRESH_RATE_MEDIUM, + REFRESH_RATE_FASTER: REFRESH_RATE_FASTER, + REFRESH_RATE_FASTEST: REFRESH_RATE_FASTEST, + getRefreshRate: getRefreshRate, + setRefreshRate: setRefreshRate, + getPreferences: getPreferences, + preferencesChanged: { + connect: setPreferencesChangedCallback + }, + setUp: setUp, + tearDown: tearDown + }; + + }()); + + //#endregion + + //#region Communications =================================================================================================== + + Communications = (function () { + // Manages the communications with the Web page script. + + var + isVersionsChecked = false, + readyCallback = null; + + function onWebEventReceived(data) { + var message, + avatarPosition, + cameraVector, + ERROR_FILE_VERSIONS_DONT_MATCH = APP_NAME + " script file version numbers don't match", + ERROR_PLEASE_RELOAD_SCRIPT = "Please reload " + APP_NAME + " script."; + + try { + message = JSON.parse(data); + } catch (e) { + return; + } + + if (message.id !== SCRIPT_ID) { + return; + } + + switch (message.type) { + case READY_MESSAGE: + if (readyCallback) { + readyCallback(); + } + tablet.emitScriptEvent(JSON.stringify({ + id: SCRIPT_ID, + type: READY_MESSAGE, + hmd: HMD.active + })); + break; + case GET_CONTROLS_MESSAGE: + tablet.emitScriptEvent(JSON.stringify({ + id: SCRIPT_ID, + type: SET_CONTROLS_MESSAGE, + controls: { + range: Preferences.getRadarRange() + } + })); + break; + case SET_CONTROLS_MESSAGE: + Preferences.setRadarRange(message.controls.range); + break; + case GET_SETTINGS_MESSAGE: + tablet.emitScriptEvent(JSON.stringify({ + id: SCRIPT_ID, + type: SET_SETTINGS_MESSAGE, + settings: { + showOwn: Preferences.getShowOwn(), + refreshRate: Preferences.getRefreshRate() + } + })); + break; + case SET_SETTINGS_MESSAGE: + Preferences.setShowOwn(message.settings.showOwn); + Preferences.setRefreshRate(message.settings.refreshRate); + break; + case AVATARS_MESSAGE: + Updates.avatarsDisplayed(); + break; + case TELEPORT_MESSAGE: + avatarPosition = MyAvatar.position; + cameraVector = Vec3.subtract(Camera.position, avatarPosition); + if (message.vector.y === null) { + message.vector.y = -cameraVector.y; + } + MyAvatar.goToLocation(Vec3.sum(Vec3.sum(avatarPosition, message.vector), cameraVector), + false, undefined, message.isAvatar, true); + break; + case OPEN_URL_MESSAGE: + Window.openUrl(message.url); + break; + case VERSIONS_MESSAGE: + if (!isVersionsChecked) { + if (message.scriptVersion !== APP_VERSION || message.htmlVersion !== APP_VERSION) { + log(ERROR, ERROR_FILE_VERSIONS_DONT_MATCH + ": " + APP_VERSION + ", " + message.scriptVersion + + " (script), " + message.htmlVersion + " (HTML)"); + Window.alert(ERROR_FILE_VERSIONS_DONT_MATCH + "!\n" + ERROR_PLEASE_RELOAD_SCRIPT); + } + isVersionsChecked = true; + } + break; + case LOG_MESSAGE: + log(message.message); + break; + default: + log(ERROR, ERROR_MISSING_CASE, 0, data); + } + } + + function sendAvatarData(radarRange, avatarData) { + tablet.emitScriptEvent(JSON.stringify({ + id: SCRIPT_ID, + type: AVATARS_MESSAGE, + range: radarRange, + data: avatarData + })); + } + + function clearAvatarData() { + tablet.emitScriptEvent(JSON.stringify({ + id: SCRIPT_ID, + type: CLEAR_MESSAGE + })); + } + + function sendRotation(rotation) { + tablet.emitScriptEvent(JSON.stringify({ + id: SCRIPT_ID, + type: ROTATION_MESSAGE, + rotation: rotation + })); + } + + function connectReadyCallback(callback) { + readyCallback = callback; + } + + function disconnectReadyCallback(callback) { + if (readyCallback === callback) { + readyCallback = null; + } + } + + return { + onWebEventReceived: onWebEventReceived, + ready: { + connect: connectReadyCallback, + disconnect: disconnectReadyCallback + }, + sendAvatarData: sendAvatarData, + clearAvatarData: clearAvatarData, + sendRotation: sendRotation + }; + + }()); + + //#endregion + + //#region Updates ========================================================================================================== + + Updates = (function () { + // Main update loop. + + var radarRange, + RADAR_SEARCH_MULTIPLIER = Math.sqrt(2), // Encompass search cylinder. + radarSearchRange, + cameraMode, + showOwn, + isShowOwn, + FIRST_PERSON_CAMERA_MODES = ["first person", "first person look at"], + refreshRate, + + ROTATION_INTERVALS = [ + 200, // Slow + 150, + 100, // Medium + 50, + 25 // Fast + ], + rotationInterval = ROTATION_INTERVALS[Preferences.REFRESH_RATE_MEDIUM], + rotationTimer = null, + + AVATAR_INTERVALS = [ + 2000, // Slow + 1000, + 750, // Medium + 350, + 100 // Fast + ], + avatarsInterval = AVATAR_INTERVALS[Preferences.REFRESH_RATE_MEDIUM], + avatarsUpdateTimer = null, + timeToPrepareDataStart, + timeToPrepareData = 0, + timeToDisplayDataStart, + timeToDisplayData = 0, + targetSendTime = 0, + avatarsSendTimer = null, + isDisplayingData = false, + + MAX_DISPLAY_NAME_LENGTH = 30, + + mySessionUUID, + + isRunning = false; + + function calculateIsShowOwn() { + isShowOwn = showOwn === Preferences.SHOW_OWN_ALWAYS + || showOwn === Preferences.SHOW_OWN_THIRD && FIRST_PERSON_CAMERA_MODES.indexOf(cameraMode) === -1; + } + + function calculateIntervals() { + rotationInterval = ROTATION_INTERVALS[refreshRate]; + avatarsInterval = AVATAR_INTERVALS[refreshRate]; + } + + function setCameraMode(mode) { + cameraMode = mode; + calculateIsShowOwn(); + } + + function setPreferences(preferences) { + showOwn = preferences.showOwn; + calculateIsShowOwn(); + radarRange = preferences.radarRange; + radarSearchRange = RADAR_SEARCH_MULTIPLIER * radarRange; + refreshRate = preferences.refreshRate; + calculateIntervals(); + } + + function updateRotation() { + var tabletOrientation, + tabletDirection, + tabletHorizontalDirection, + rotation; + + if (App.isHMDMode()) { + tabletOrientation = Overlays.getProperty(HMD.tabletID, "orientation"); + tabletDirection = Quat.getUp(tabletOrientation); // Out the top of the tablet. + if (Vec3.dot(tabletDirection, Vec3.UNIT_Y) > 0.5) { + tabletDirection = Vec3.multiply(-1, Quat.getForward(tabletOrientation)); // Out the back of the tablet. + } + tabletHorizontalDirection = Vec3.cross(tabletDirection, Vec3.UNIT_Y); + + rotation = Vec3.orientedAngle(Vec3.UNIT_X, tabletHorizontalDirection, Vec3.UNIT_Y); + } else { + rotation = Quat.safeEulerAngles(Camera.orientation).y; + } + + Communications.sendRotation(rotation); + + rotationTimer = Script.setTimeout(updateRotation, rotationInterval); + } + + /* + // Code for displaying avatar dots at positions and elevations for screen snap or testing elevation colours. + var sessionIDs = [Uuid.generate(), Uuid.generate(), Uuid.generate(), Uuid.generate(), Uuid.generate(), Uuid.generate(), + Uuid.generate(), Uuid.generate(), Uuid.generate(), Uuid.generate(), Uuid.generate(), Uuid.generate()]; + + function updateAvatars() { + var avatarPositions, + avatarNames, + avatarDataRange = 10.0, + avatarData = [], + i; + + avatarPositions = [ + { x: 0, y: 0, z: 0 }, + { x: -2.5, y: 0, z: -1 }, + { x: -2.8, y: 0, z: -1.3 }, + { x: -3, y: 0, z: -7 }, + { x: -4, y: 0, z: -7.7 }, + { x: -4.3, y: 0, z: -7.3 }, + { x: -4.3, y: 0, z: -7.8 }, + { x: -3.24, y: 0, z: -8.4 }, + { x: 1, y: -7, z: 6 }, + { x: 5, y: 8, z: -3 } + ]; + avatarNames = ["ctrlaltdavid", "", "", "", "", "", "", "", "", "wade", "", ""]; + + for (i = 0; i < avatarPositions.length; i += 1) { + avatarData.push({ + uuid: sessionIDs[i], + vector: avatarPositions[i], + name: avatarNames[i] + }); + } + avatarData[0].isMyAvatar = true; + + Communications.sendAvatarData(avatarDataRange, avatarData); + avatarsUpdateTimer = Script.setTimeout(updateAvatars, avatarsInterval); + } + */ + + function limitDisplayName(name) { + // Limit display name here so that extraneous data is not sent in message to HTML code. + if (name.length > MAX_DISPLAY_NAME_LENGTH) { + return name.slice(0, MAX_DISPLAY_NAME_LENGTH) + "…"; + } + return name; + } + + function updateAvatars() { + // Finds avatars in cylinder, not sphere. + // Avatar data update loop: + // - The desired update loop time is avatarsInterval. + // - The update loop time is extended if the HTML script takes longer to display the data. + // - The update loop comprises: + // - Preparing the avatar data. + // - Sending the avatar data at the target interval or as soon as the previous data has been displayed. + // - Scheduling preparing the next avatar data so that it is ready at the anticipated send time. + // - Displaying the avatar data in the HTML script. (Includes teleport elevation searching.) + + var cameraPosition, + avatarIDs, + myAvatarIndex, + palData, + vector, + sessionUUID, + avatarDatum, + avatarData = [], + avatarDataRange, // The radar range that avatarData is for. + i, length, + MINIMUM_UPDATE_DELAY = 2, + sendDelay, + MINIMUM_SEND_DELAY = 2, + SEND_RETRY_INTERVAL = 25; + + timeToPrepareDataStart = Date.now(); + + // Timer has fired. + avatarsUpdateTimer = null; + + // Get avatar data. + cameraPosition = Camera.position; + avatarIDs = AvatarManager.getAvatarsInRange(cameraPosition, radarSearchRange); + avatarDataRange = radarRange; + + // Remove own avatar if necessary. + myAvatarIndex = avatarIDs.indexOf(mySessionUUID); + if (myAvatarIndex !== -1 && !isShowOwn) { + avatarIDs.splice(myAvatarIndex, 1); + } + + // Collect avatar data. + palData = AvatarManager.getPalData(avatarIDs)["data"]; // Property name as string to avoid obfuscation. + for (i = 0, length = palData.length; i < length; i++) { + // If session display name is undefined then the data is messed up (e.g., spheres problem). + // The pal.js script also ignores items with empty name fields. + if (!palData[i].sessionDisplayName) { + continue; + } + + sessionUUID = palData[i].sessionUUID; + + // FIXME: AvatarManager.getPalData() returns with sessionUUID === "" for own avatar. + // Manuscript case 19693. + if (sessionUUID === "") { + if (isShowOwn) { + sessionUUID = mySessionUUID; + } else { + continue; + } + } + + vector = Vec3.subtract(palData[i].position, cameraPosition); + if (Math.abs(vector.y) <= radarRange) { + if (Vec3.length({ x: vector.x, y: 0, z: vector.z }) <= radarRange) { + avatarDatum = { + uuid: sessionUUID, + vector: vector, + name: limitDisplayName(palData[i].sessionDisplayName) + }; + if (sessionUUID === mySessionUUID) { + // Don't set value for each avatar so as to reduce EventBridge message size. + avatarDatum.isMyAvatar = true; + } + avatarData.push(avatarDatum); + } + } + } + + timeToPrepareData = Date.now() - timeToPrepareDataStart; + if (INSTRUMENT) { + log("Main script : prepare : " + timeToPrepareData); + // 2019 01 07 + // Simulation: + // - 100 avatars: 3ms + // - 1000 avatars: 48ms + } + + // Send avatar data at target time, if can. + sendDelay = targetSendTime - Date.now(); // Should hover around 0 for a reasonably loaded radar. + if (INSTRUMENT) { + log("Main script : delay : " + sendDelay); + } + + // Schedule preparing next data set so that it is ready to send at later of target interval or display update from + // previous data set. + avatarsUpdateTimer = Script.setTimeout(updateAvatars, + Math.max(Math.max(avatarsInterval, timeToDisplayData) - timeToPrepareData + sendDelay, MINIMUM_UPDATE_DELAY)); + + function sendData() { + var instrumentTimestamp; + + if (!isRunning) { + return; + } + + // Delay sending data until after previous lot has been processed. + if (isDisplayingData) { + if (INSTRUMENT) { + log("Main script : reschedule send"); + } + avatarsSendTimer = Script.setTimeout(sendData, SEND_RETRY_INTERVAL); + return; + } + avatarsSendTimer = null; + + if (INSTRUMENT) { + instrumentTimestamp = Date.now(); + } + + // Set target for sending next lot of data. + targetSendTime = Date.now() + avatarsInterval; + + // Send current lost of data. + isDisplayingData = true; + timeToDisplayDataStart = Date.now(); + Communications.sendAvatarData(avatarDataRange, avatarData); + + if (INSTRUMENT) { + log("Main script : send : " + (Date.now() - instrumentTimestamp)); + // 2019 01 07 + // Simulation: + // - 100 avatars: 1ms + // - 1000 avatars: 5ms + } + } + + avatarsSendTimer = Script.setTimeout(sendData, + Math.max(sendDelay, MINIMUM_SEND_DELAY)); // Caters for negative sendDelay values. + } + + function onSessionUUIDChanged() { + mySessionUUID = MyAvatar.sessionUUID; + } + + function start() { + isRunning = true; + mySessionUUID = MyAvatar.sessionUUID; + MyAvatar.sessionUUIDChanged.connect(onSessionUUIDChanged); + if (avatarsUpdateTimer === null) { + targetSendTime = Date.now() + avatarsInterval; + avatarsUpdateTimer = Script.setTimeout(updateAvatars, avatarsInterval); + } + if (rotationTimer === null) { + rotationTimer = Script.setTimeout(updateRotation, rotationInterval); + } + } + + function avatarsDisplayed() { + isDisplayingData = false; + timeToDisplayData = Date.now() - timeToDisplayDataStart; + + if (INSTRUMENT) { + log("Main script : display : " + timeToDisplayData); + // 2019 01 07 + // Simulation: + // - 100 avatars: 16ms + // - 1000 avatars: 25ms + } + } + + function stop() { + isRunning = false; + isDisplayingData = false; + if (avatarsSendTimer !== null) { + Script.clearTimeout(avatarsSendTimer); + avatarsSendTimer = null; + } + if (avatarsUpdateTimer !== null) { + Script.clearTimeout(avatarsUpdateTimer); + avatarsUpdateTimer = null; + } + if (rotationTimer !== null) { + Script.clearTimeout(rotationTimer); + rotationTimer = null; + } + MyAvatar.sessionUUIDChanged.disconnect(onSessionUUIDChanged); + } + + function setUp() { + setPreferences(Preferences.getPreferences()); + Preferences.preferencesChanged.connect(setPreferences); + } + + function tearDown() { + // Nothing to do. + } + + return { + setPreferences: setPreferences, + setCameraMode: setCameraMode, + start: start, + avatarsDisplayed: avatarsDisplayed, + stop: stop, + setUp: setUp, + tearDown: tearDown + }; + + }()); + + //#endregion + + //#region App ============================================================================================================== + + App = (function () { + // Manages the interactions with the Interface app environment. + + var APP_ICON_ACTIVE = Script.resolvePath("./assets/radar-a.svg"), + APP_ICON_INACTIVE = Script.resolvePath("./assets/radar-i.svg"), + APP_HTML_PAGE = Script.resolvePath("./html/radar.html"), + APP_BUTTON_TEXT = "RADAR", + + button = null, + isAppActive = false, + + HMD_TABLET_BECOMES_TOOLBAR_SETTING = "hmdTabletBecomesToolbar", + isHMDTabletBecomesToolbar = false, + isHMDActive = false; + + + function onDisplayModeChanged(isHMDMode) { + // Close app if have switched between desktop and HMD modes, else tablet can't be used or toolbar button stays on. + if (isAppActive && isHMDMode !== isHMDActive) { + isHMDTabletBecomesToolbar = Settings.getValue(HMD_TABLET_BECOMES_TOOLBAR_SETTING, false); + isHMDActive = isHMDMode; + tablet.gotoHomeScreen(); + } + } + + function onPossibleDomainChangeRequired() { + // Clear radar display so that out-of-date display doesn't unexpectedly jump before updating to new location. + Communications.clearAvatarData(); + } + + function startApp() { + + isHMDTabletBecomesToolbar = Settings.getValue(HMD_TABLET_BECOMES_TOOLBAR_SETTING, false); + isHMDActive = HMD.active; + + HMD.displayModeChanged.connect(onDisplayModeChanged); + location.possibleDomainChangeRequired.connect(onPossibleDomainChangeRequired); + location.possibleDomainChangeRequiredViaICEForID.connect(onPossibleDomainChangeRequired); + + Camera.modeUpdated.connect(Updates.setCameraMode); + Updates.setCameraMode(Camera.mode); + + Communications.ready.connect(Updates.start); + tablet.webEventReceived.connect(Communications.onWebEventReceived); + } + + function stopApp() { + Updates.stop(); + + tablet.webEventReceived.disconnect(Communications.onWebEventReceived); + Communications.ready.disconnect(Updates.start); + + Camera.modeUpdated.disconnect(Updates.setCameraMode); + + location.possibleDomainChangeRequiredViaICEForID.disconnect(onPossibleDomainChangeRequired); + location.possibleDomainChangeRequired.disconnect(onPossibleDomainChangeRequired); + HMD.displayModeChanged.disconnect(onDisplayModeChanged); + } + + function onButtonClicked() { + if (!isAppActive) { + tablet.gotoWebScreen(APP_HTML_PAGE); + } else { + tablet.gotoHomeScreen(); + } + } + + function onTabletScreenChanged(type, url) { + var active; + + active = url.slice(0, APP_HTML_PAGE.length) === APP_HTML_PAGE; + if (active === isAppActive) { + return; + } + + isAppActive = active; + button.editProperties({ isActive: isAppActive }); + if (isAppActive) { + startApp(); + } else { + stopApp(); + } + } + + function isHMDMode() { + return isHMDActive && !isHMDTabletBecomesToolbar; + } + + function setUp() { + tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system"); + if (!tablet) { + log(ERROR, "Tablet not found!"); + return; + } + + button = tablet.addButton({ + icon: APP_ICON_INACTIVE, + activeIcon: APP_ICON_ACTIVE, + text: APP_BUTTON_TEXT, + isActive: false + }); + + if (!button) { + log(ERROR, "Tablet button not created!"); + tablet = null; + return; + } + + tablet.screenChanged.connect(onTabletScreenChanged); + button.clicked.connect(onButtonClicked); + } + + function tearDown() { + if (isAppActive) { + stopApp(); + tablet.gotoHomeScreen(); // Close desktop window. + } + + if (button) { + button.clicked.disconnect(onButtonClicked); + tablet.removeButton(button); + button = null; + } + + if (tablet) { + tablet.screenChanged.disconnect(onTabletScreenChanged); + tablet = null; + } + } + + return { + isHMDMode: isHMDMode, + setUp: setUp, + tearDown: tearDown + }; + + }()); + + //#endregion + + //#region Set up and tear down ============================================================================================= + + function setUp() { + log(INFO, APP_NAME, APP_VERSION); + App.setUp(); + Preferences.setUp(); + Updates.setUp(); + } + + function tearDown() { + Script.scriptEnding.disconnect(tearDown); + App.tearDown(); + Preferences.tearDown(); + Updates.tearDown(); + } + + setUp(); + Script.scriptEnding.connect(tearDown); + + //#endregion + +}());