diff --git a/package.json b/package.json index 1a2a4de3fcf9897845e94e022b156bcc199ce6a7..033fc274b7e8b599ffd39c09c4cb1e399e4548e1 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "aframe-input-mapping-component": "https://github.com/johnshaughnessy/aframe-input-mapping-component#feature/map-to-array", "aframe-physics-extras": "https://github.com/infinitelee/aframe-physics-extras#fix/physics-collider-crash", "aframe-physics-system": "https://github.com/donmccurdy/aframe-physics-system", - "aframe-teleport-controls": "https://github.com/netpro2k/aframe-teleport-controls#feature/teleport-origin", + "aframe-teleport-controls": "https://github.com/netpro2k/aframe-teleport-controls#feature/pauseable", "aframe-xr": "github:brianpeiris/aframe-xr#3162aed", "classnames": "^2.2.5", "detect-browser": "^2.1.0", diff --git a/src/assets/hud/avatar.jpg b/src/assets/hud/avatar.jpg new file mode 100755 index 0000000000000000000000000000000000000000..af8093c601f6dcbc74b431ff7f7aca414cf4dc01 Binary files /dev/null and b/src/assets/hud/avatar.jpg differ diff --git a/src/assets/hud/muted.png b/src/assets/hud/muted.png index 7a15a9ea9e9125e04739214c0fad7c0226d5eca2..b557fd0195e53a1dc9b4873db65b69afb0b7d1b1 100644 Binary files a/src/assets/hud/muted.png and b/src/assets/hud/muted.png differ diff --git a/src/assets/hud/unmuted.png b/src/assets/hud/unmuted.png new file mode 100755 index 0000000000000000000000000000000000000000..71f1af21bdd72aaa22ebe740e84a481aa1dc8b3f Binary files /dev/null and b/src/assets/hud/unmuted.png differ diff --git a/src/assets/stylesheets/ui-root.scss b/src/assets/stylesheets/ui-root.scss index 9d252f04d8ccb94b203a5cd2ef46ec2caf062d2b..74f4ad4a37f344162f1ee029a51fd5e0525e2728 100644 --- a/src/assets/stylesheets/ui-root.scss +++ b/src/assets/stylesheets/ui-root.scss @@ -28,18 +28,21 @@ .ui-dialog-box { grid-column: 3; - grid-row : 3; + grid-row: 3; position: relative; } .ui-dialog-box-contents { background-color: $darker-transparent; border-radius: 8px; - pointer-events: auto; width: 100%; height: 100%; } +.ui-interactive { + pointer-events: auto; +} + .ui-dialog-box--backgrounded { } @@ -48,4 +51,3 @@ opacity: 0.7; pointer-events: none; } - diff --git a/src/components/2d-mute-state-indicator.css b/src/components/2d-mute-state-indicator.css deleted file mode 100644 index 9713edb2ee92e8b0b8e0eafb5a7e8ac41719dcac..0000000000000000000000000000000000000000 --- a/src/components/2d-mute-state-indicator.css +++ /dev/null @@ -1,12 +0,0 @@ -:local(.indicator) { - position: absolute; - top: 10px; - left: calc(50vw - 16px); - width: 32px; - height: 32px; - background-size: 100%; -} - -:local(.indicator.muted) { - background-image: url(../assets/hud/muted.png); -} diff --git a/src/components/2d-mute-state-indicator.js b/src/components/2d-mute-state-indicator.js deleted file mode 100644 index 95df937d8ded1e13e5d125bfb3295b118649a2be..0000000000000000000000000000000000000000 --- a/src/components/2d-mute-state-indicator.js +++ /dev/null @@ -1,38 +0,0 @@ -import styles from "./2d-mute-state-indicator.css"; -/** - * Shows a 2d incicator on screen reflecting mute state - * @TODO this probably shouldnt be an aframe component but baring any other 2d UI handling it feels cleaner here than jsut free-flaoting - */ -AFRAME.registerComponent("2d-mute-state-indicator", { - schema: {}, - - init() { - this.onStateToggled = this.onStateToggled.bind(this); - - this.muteIcon = document.createElement("div"); - this.muteIcon.classList.add(styles.indicator); - document.body.appendChild(this.muteIcon); - - this.updateMuteState(); - }, - - play() { - this.el.sceneEl.addEventListener("stateadded", this.onStateToggled); - this.el.sceneEl.addEventListener("stateremoved", this.onStateToggled); - }, - - pause() { - this.el.sceneEl.removeEventListener("stateadded", this.onStateToggled); - this.el.sceneEl.removeEventListener("stateremoved", this.onStateToggled); - }, - - onStateToggled(e) { - if (!e.detail.state === "muted") return; - this.updateMuteState(); - }, - - updateMuteState() { - const muted = this.el.sceneEl.is("muted"); - this.muteIcon.classList.toggle(styles.muted, muted); - } -}); diff --git a/src/components/character-controller.js b/src/components/character-controller.js index 8aaa2240a24bf169bd72f6e957f54031f436e7eb..20553d36ce9c06ca84806fd5b6adc7350ddf2aaa 100644 --- a/src/components/character-controller.js +++ b/src/components/character-controller.js @@ -41,6 +41,14 @@ AFRAME.registerComponent("character-controller", { eventSrc.removeEventListener("rotateY", this.setAngularVelocity); eventSrc.removeEventListener("snap_rotate_left", this.snapRotateLeft); eventSrc.removeEventListener("snap_rotate_right", this.snapRotateRight); + this.reset(); + }, + + reset() { + this.accelerationInput.set(0, 0, 0); + this.velocity.set(0, 0, 0); + this.angularVelocity = 0; + this.pendingSnapRotationMatrix.identity(); }, setAccelerationInput: function(event) { @@ -82,6 +90,9 @@ AFRAME.registerComponent("character-controller", { const distance = this.data.groundAcc * deltaSeconds; const rotationDelta = this.data.rotationSpeed * this.angularVelocity * deltaSeconds; + // Other aframe components like teleport-controls set position/rotation/scale, not the matrix, so we need to make sure to compose them back into the matrix + root.updateMatrix(); + pivotPos.copy(pivot.position); pivotPos.applyMatrix4(root.matrix); trans.setPosition(pivotPos); diff --git a/src/components/in-world-hud.js b/src/components/in-world-hud.js new file mode 100644 index 0000000000000000000000000000000000000000..e69cfd7374eaa1ca1972b3cb00784d837dc987b8 --- /dev/null +++ b/src/components/in-world-hud.js @@ -0,0 +1,121 @@ +AFRAME.registerComponent("in-world-hud", { + schema: { + haptic: { type: "selector" }, + raycaster: { type: "selector" } + }, + init() { + this.bg = this.el.querySelector(".bg"); + this.mic = this.el.querySelector(".mic"); + this.nametag = this.el.querySelector(".username"); + this.nametag.object3DMap.text.material.depthTest = false; + this.data.raycaster.components.line.material.depthTest = false; + + const muted = this.el.sceneEl.is("muted"); + this.mic.setAttribute("src", muted ? "#muted" : "#unmuted"); + + const scene = this.el.sceneEl; + this.onUsernameChanged = this.onUsernameChanged.bind(this); + scene.addEventListener("username-changed", this.onUsernameChanged); + + this.showCorrectMuteState = () => { + const muted = this.el.sceneEl.is("muted"); + this.mic.setAttribute("src", muted ? "#muted" : "#unmuted"); + }; + + this.onStateChange = evt => { + if (evt.detail !== "muted") return; + this.showCorrectMuteState(); + }; + + this.onMicHover = () => { + this.hoveredOnMic = true; + this.data.haptic.emit("haptic_pulse", { intensity: "low" }); + this.mic.setAttribute("material", "color", "#1DD"); + }; + + this.onMicHoverExit = () => { + this.hoveredOnMic = false; + this.mic.setAttribute("material", "color", "#FFF"); + this.showCorrectMuteState(); + }; + + this.onMicDown = () => { + this.data.haptic.emit("haptic_pulse", { intensity: "medium" }); + this.el.sceneEl.removeEventListener("micAudio", this.onAudioFrequencyChange); + this.mic.setAttribute("material", "color", this.el.sceneEl.is("muted") ? "#0FA" : "#F33"); + this.el.emit("action_mute"); + window.setTimeout(() => { + this.mic.setAttribute("material", "color", "#FFF"); + this.el.sceneEl.addEventListener("micAudio", this.onAudioFrequencyChange); + }, 150); + }; + + this.onClick = () => { + if (this.hoveredOnMic) { + this.onMicDown(); + } + }; + + this.onAudioFrequencyChange = e => { + if (this.hoveredOnMic) return; + const red = 1.0 - e.detail.volume / 10.0; + this.mic.object3DMap.mesh.material.color = { r: red, g: 9, b: 9 }; + }; + + this.el.sceneEl.addEventListener("mediaStream", evt => { + this.ms = evt.detail.ms; + const ctx = THREE.AudioContext.getContext(); + const source = ctx.createMediaStreamSource(this.ms); + this.analyser = ctx.createAnalyser(); + this.levels = new Uint8Array(this.analyser.frequencyBinCount); + source.connect(this.analyser); + }); + }, + + play() { + this.mic.addEventListener("raycaster-intersected", this.onMicHover); + this.mic.addEventListener("raycaster-intersected-cleared", this.onMicHoverExit); + + this.el.sceneEl.addEventListener("stateadded", this.onStateChange); + this.el.sceneEl.addEventListener("stateremoved", this.onStateChange); + + this.el.addEventListener("click", this.onClick); + + this.el.sceneEl.addEventListener("micAudio", this.onAudioFrequencyChange); + }, + + pause() { + this.nametag.removeEventListener("raycaster-intersected", this.onNametagHovered); + this.nametag.removeEventListener("raycaster-intersected-cleared", this.onNametagUnhovered); + + this.el.sceneEl.removeEventListener("stateadded", this.onStateChange); + this.el.sceneEl.removeEventListener("stateremoved", this.onStateChange); + + this.el.removeEventListener("click", this.onClick); + + this.el.sceneEl.removeEventListener("micAudio", this.onAudioFrequencyChange); + }, + + tick: function(t, dt) { + if (!this.analyser) return; + + this.analyser.getByteFrequencyData(this.levels); + + let sum = 0; + for (let i = 0; i < this.levels.length; i++) { + sum += this.levels[i]; + } + this.volume = sum / this.levels.length; + this.el.emit("micAudio", { + volume: this.volume, + levels: this.levels + }); + }, + + onUsernameChanged(evt) { + const { username } = evt.detail; + const width = evt.detail.username.length == 0 ? 1 : 40 / username.length; + this.nametag.setAttribute("text", "width", Math.min(width, 6)); + this.nametag.setAttribute("text", "value", username); + } +}); diff --git a/src/components/wasd-to-analog2d.js b/src/components/wasd-to-analog2d.js index 1b83f8f63a2ef969300ef1c91a0814f04a1f0ed8..a424cb893ab10c29575667f7c68539dd65e90689 100644 --- a/src/components/wasd-to-analog2d.js +++ b/src/components/wasd-to-analog2d.js @@ -17,17 +17,8 @@ AFRAME.registerComponent("wasd-to-analog2d", { }, play: function() { - const eventNames = [ - "w_down", - "w_up", - "a_down", - "a_up", - "s_down", - "s_up", - "d_down", - "d_up" - ]; - for (var name of eventNames) { + const eventNames = ["w_down", "w_up", "a_down", "a_up", "s_down", "s_up", "d_down", "d_up"]; + for (const name of eventNames) { this.el.sceneEl.addEventListener(name, this.onWasd); } // I listen to events that this component generates instead of emitting "move" @@ -42,10 +33,8 @@ AFRAME.registerComponent("wasd-to-analog2d", { pause: function() { this.el.sceneEl.removeEventListener("wasd", this.onWasd); - this.el.sceneEl.removeEventListener( - this.data.analog2dOutputAction, - this.move - ); + this.el.sceneEl.removeEventListener(this.data.analog2dOutputAction, this.move); + this.keys = {}; }, onWasd: function(event) { @@ -58,18 +47,13 @@ AFRAME.registerComponent("wasd-to-analog2d", { tick: function(t, dt) { this.target = [0, 0]; - for (var key in this.keys) { + for (const key in this.keys) { if (this.keys[key] && this.vectors[key]) { - this.target = [ - this.target[0] + this.vectors[key][0], - this.target[1] + this.vectors[key][1] - ]; + this.target = [this.target[0] + this.vectors[key][0], this.target[1] + this.vectors[key][1]]; } } - const targetMagnitude = Math.sqrt( - this.target[0] * this.target[0] + this.target[1] * this.target[1] - ); + const targetMagnitude = Math.sqrt(this.target[0] * this.target[0] + this.target[1] * this.target[1]); if (targetMagnitude !== 0) { this.target[0] = this.target[0] / targetMagnitude; this.target[1] = this.target[1] / targetMagnitude; diff --git a/src/hub.html b/src/hub.html index ad23c9c83950d0afd78e25bf27f9cdf38bdc10c0..5aeaf6c0f5afd9ebbba17c7bf26fb8cee15f479b 100644 --- a/src/hub.html +++ b/src/hub.html @@ -19,10 +19,15 @@ <a-scene physics mute-mic="eventSrc: a-scene; toggleEvents: action_mute" - 2d-mute-state-indicator - light="defaultLightsEnabled: false"> + + app-mode-input-mappings="modes: default, hud; actionSets: default, hud;" + > <a-assets> + <img id="unmuted" src="./assets/hud/unmuted.png" > + <img id="muted" src="./assets/hud/muted.png" > + <img id="avatar" src="./assets/hud/avatar.jpg" > + <a-progressive-asset id="bot-skinned-mesh" response-type="arraybuffer" @@ -133,6 +138,8 @@ radius=0.02 static-body="shape: sphere;" mixin="super-hands" + segments-height="9" + segments-width="9" ></a-sphere> </a-entity> @@ -144,8 +151,25 @@ wasd-to-analog2d character-controller="pivot: #player-camera" ik-root + app-mode-toggle-playing__character-controller="mode: hud; invert: true;" + app-mode-toggle-playing__wasd-to-analog2d="mode: hud; invert: true;" player-info > + + <a-entity + id="player-hud" + hud-controller="head: #player-camera;" + vr-mode-toggle-visibility + vr-mode-toggle-playing__hud-controller + > + <a-entity in-world-hud="haptic:#player-right-controller;raycaster:#player-right-controller;" rotation="-39 0 0"> + <a-box geometry="height:0.13;width:0.6;depth:0.001" material="depthTest:false; color:#000000;opacity:0.35" class="hud bg"></a-box> + <a-image src="#unmuted" scale="-0.1 0.1 0.1" position="-0.2 0 0.001" class="hud mic" material="alphaTest:0.1;depthTest:false;"></a-image> + <a-text scale="0.3 0.3 0.3" position="0 0 0.001" class="hud username" text="width:6;alphaTest:0.1;align:center;"></a-text> + <a-image src="#avatar" scale="0.1 0.1 0.1" position="0.2 0 0.001" class="hud avatar" material="depthTest:false;"></a-image> + </a-entity> + </a-entity> + <a-entity id="player-camera" class="camera" @@ -161,9 +185,12 @@ hand-controls2="left" tracked-controls teleport-controls="cameraRig: #player-rig; teleportOrigin: #player-camera; button: action_teleport_" + app-mode-toggle-playing__teleport-controls="mode: hud; invert: true;" haptic-feedback ></a-entity> + + <a-entity id="player-right-controller" class="right-controller" @@ -171,6 +198,13 @@ tracked-controls teleport-controls="cameraRig: #player-rig; teleportOrigin: #player-camera; button: action_teleport_" haptic-feedback + raycaster="objects:.hud; showLine: true;" + cursor="fuse: false; downEvents: action_ui_select_down; upEvents: action_ui_select_up;" + + app-mode-toggle-playing__teleport-controls="mode: hud; invert: true;" + app-mode-toggle-playing__raycaster="mode: hud;" + app-mode-toggle-playing__cursor="mode: hud;" + app-mode-toggle-attribute__line="mode: hud; property: visible;" ></a-entity> <a-gltf-entity class="model" inflate="true"> @@ -271,7 +305,7 @@ ></a-plane> </a-scene> - <div id="ui-root" class="ui"></div> + <div id="ui-root"></div> </body> </html> diff --git a/src/hub.js b/src/hub.js index c81d90fb0e937a220f38179a1b20f8690ce65a6b..b0899d5dda41acbe5aa2a88582f91556dba4648b 100644 --- a/src/hub.js +++ b/src/hub.js @@ -23,7 +23,7 @@ import "./components/wasd-to-analog2d"; //Might be a behaviour or activator in t import "./components/mute-mic"; import "./components/audio-feedback"; import "./components/bone-mute-state-indicator"; -import "./components/2d-mute-state-indicator"; +import "./components/in-world-hud"; import "./components/virtual-gamepad-controls"; import "./components/ik-controller"; import "./components/hand-controls2"; @@ -48,6 +48,9 @@ import React from "react"; import UIRoot from "./react-components/ui-root"; import "./systems/personal-space-bubble"; +import "./systems/app-mode"; + +import "./elements/a-gltf-entity"; import "./gltf-component-mappings"; @@ -133,11 +136,13 @@ async function exitScene() { } function updatePlayerInfoFromStore() { + const displayName = store.state.profile.display_name; const playerRig = document.querySelector("#player-rig"); playerRig.setAttribute("player-info", { - displayName: store.state.profile.display_name, + displayName, avatar: qs.avatar || "#bot-skinned-mesh" }); + document.querySelector("a-scene").emit("username-changed", { username: displayName }); } async function enterScene(mediaStream, enterInVR, janusRoomId) { diff --git a/src/input-mappings.js b/src/input-mappings.js index 6449f41c43cbda7a44fd3ec3e5edf51b2376f63e..a5338250079c08c94d79edb84457ff4e3b4e7b65 100644 --- a/src/input-mappings.js +++ b/src/input-mappings.js @@ -10,6 +10,10 @@ const inGameActions = { action_teleport_down: { label: "Teleport Aim" }, action_teleport_up: { label: "Teleport" }, action_share_screen: { label: "Share Screen" } + }, + hud: { + action_ui_select_down: { label: "Select UI item" }, + action_ui_select_up: { label: "Select UI item" } } }; @@ -79,6 +83,7 @@ const config = { q_press: "snap_rotate_left", e_press: "snap_rotate_right", v_press: "action_share_screen", + b_press: "action_select_hud_item", // We can't create a keyboard behaviour with AFIM yet, // so these will get captured by wasd-to-analog2d @@ -99,6 +104,40 @@ const config = { D_down: "d_down", D_up: "d_up" } + }, + hud: { + "vive-controls": { + triggerdown: { right: "action_ui_select_down" }, + triggerup: { right: "action_ui_select_up" } + }, + "oculus-touch-controls": { + triggerdown: { right: "action_ui_select_down" }, + triggerup: { right: "action_ui_select_up" }, + gripdown: "middle_ring_pinky_down", + gripup: "middle_ring_pinky_up", + abuttontouchstart: "thumb_down", + abuttontouchend: "thumb_up", + bbuttontouchstart: "thumb_down", + bbuttontouchend: "thumb_up", + xbuttontouchstart: "thumb_down", + xbuttontouchend: "thumb_up", + ybuttontouchstart: "thumb_down", + ybuttontouchend: "thumb_up", + surfacetouchstart: "thumb_down", + surfacetouchend: "thumb_up", + thumbsticktouchstart: "thumb_down", + thumbsticktouchend: "thumb_up", + triggertouchstart: "index_down", + triggertouchend: "index_up" + }, + "daydream-controls": { + trackpaddown: { right: "action_ui_select_down" }, + trackpadup: { right: "action_ui_select_up" } + }, + "gearvr-controls": { + trackpaddown: { right: "action_ui_select_down" }, + trackpadup: { right: "action_ui_select_up" } + } } } }; diff --git a/src/react-components/2d-hud.css b/src/react-components/2d-hud.css new file mode 100644 index 0000000000000000000000000000000000000000..556431ac6ca824e0bbcf4cf4d5644e05be4dc309 --- /dev/null +++ b/src/react-components/2d-hud.css @@ -0,0 +1,65 @@ +:local(.container) { + position: absolute; + top: 10px; + display: flex; + justify-content: center; + height: 60px; + width: 100%; +} + +:local(.bg) { + position: absolute; + display: flex; + justify-content: space-around; + align-items: center; + padding: 10px; + background-color: rgba(0, 0, 0, 0.35); +} + +:local(.nametag) { + display: flex; + justify-content: center; + font-size: 32px; + font-family: sans-serif; + color: white; + margin: 0 10px 0 0; +} + +:local(.avatar) { + display: flex; + width: 48px; + height: 48px; + background-size: 100%; + background-image: url(../assets/hud/avatar.jpg); +} + +:local(.mic) { + display: flex; + width: 48px; + height: 48px; + mask: url(../assets/hud/unmuted.png); + mask-size: 48px; + background-color: white; + cursor: pointer; +} + +:local(.mic:hover) { + background-color: cyan; +} + +:local(.mic.muted:hover) { + background-color: cyan; +} + +:local(.mic:active) { + background-color: red; +} + +:local(.mic.muted) { + mask: url(../assets/hud/muted.png); + mask-size: 48px; +} + +:local(.mic.muted:active) { + background-color: green; +} diff --git a/src/react-components/2d-hud.js b/src/react-components/2d-hud.js new file mode 100644 index 0000000000000000000000000000000000000000..fda3ae06ad0ce63b7b773d5a69f4141b52389c9f --- /dev/null +++ b/src/react-components/2d-hud.js @@ -0,0 +1,23 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import cx from "classnames"; + +import styles from "./2d-hud.css"; + +const TwoDHUD = ({ name, muted, onToggleMute }) => ( + <div className={styles.container}> + <div className={cx("ui-interactive", styles.bg)}> + <div className={cx(styles.mic, { [styles.muted]: muted })} onClick={onToggleMute} /> + <div className={styles.nametag}>{name}</div> + <div className={styles.avatar} /> + </div> + </div> +); + +TwoDHUD.propTypes = { + name: PropTypes.string, + muted: PropTypes.bool, + onToggleMute: PropTypes.func +}; + +export default TwoDHUD; diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js index e951f62db575cf96a9c59a92dc3254e8ba0722ea..f2d6f9102d23ccb25375abf75bf1733bb2b28375 100644 --- a/src/react-components/ui-root.js +++ b/src/react-components/ui-root.js @@ -1,25 +1,27 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; import { VR_DEVICE_AVAILABILITY } from "../utils/vr-caps-detect"; import queryString from "query-string"; import { SCHEMA } from "../storage/store"; -import MobileDetect from 'mobile-detect'; -import { IntlProvider, FormattedMessage, addLocaleData } from 'react-intl'; -import en from 'react-intl/locale-data/en'; -import MovingAverage from 'moving-average'; +import MobileDetect from "mobile-detect"; +import { IntlProvider, FormattedMessage, addLocaleData } from "react-intl"; +import en from "react-intl/locale-data/en"; +import MovingAverage from "moving-average"; -import AutoExitWarning from './auto-exit-warning'; -import { TwoDEntryButton, GenericEntryButton, GearVREntryButton, DaydreamEntryButton } from './entry-buttons.js'; -import { ProfileInfoHeader } from './profile-info-header.js'; -import ProfileEntryPanel from './profile-entry-panel'; +import AutoExitWarning from "./auto-exit-warning"; +import { TwoDEntryButton, GenericEntryButton, GearVREntryButton, DaydreamEntryButton } from "./entry-buttons.js"; +import { ProfileInfoHeader } from "./profile-info-header.js"; +import ProfileEntryPanel from "./profile-entry-panel"; +import TwoDHUD from "./2d-hud"; const mobiledetect = new MobileDetect(navigator.userAgent); -const lang = ((navigator.languages && navigator.languages[0]) || - navigator.language || navigator.userLanguage).toLowerCase().split(/[_-]+/)[0]; +const lang = ((navigator.languages && navigator.languages[0]) || navigator.language || navigator.userLanguage) + .toLowerCase() + .split(/[_-]+/)[0]; -import localeData from '../assets/translations.data.json'; +import localeData from "../assets/translations.data.json"; addLocaleData([...en]); const messages = localeData[lang] || localeData.en; @@ -30,7 +32,7 @@ const ENTRY_STEPS = { mic_granted: "mic_granted", audio_setup: "audio_setup", finished: "finished" -} +}; const HMD_MIC_REGEXES = [/\Wvive\W/i, /\Wrift\W/i]; @@ -64,7 +66,7 @@ class UIRoot extends Component { forcedVREntryType: PropTypes.string, store: PropTypes.object, scene: PropTypes.object - } + }; state = { availableVREntryTypes: null, @@ -99,8 +101,31 @@ class UIRoot extends Component { this.setupTestTone(); this.props.concurrentLoadDetector.addEventListener("concurrentload", this.onConcurrentLoad); this.micLevelMovingAverage = MovingAverage(100); + this.props.scene.addEventListener("loaded", this.onSceneLoaded); + this.props.scene.addEventListener("stateadded", this.onAframeStateChanged); + this.props.scene.addEventListener("stateremoved", this.onAframeStateChanged); + } + + componentWillUnmount() { + this.props.scene.removeEventListener("loaded", this.onSceneLoaded); } + onSceneLoaded = () => { + this.setState({ sceneLoaded: true }); + }; + + // TODO: mute state should probably actually just live in react land + onAframeStateChanged = e => { + if (e.detail !== "muted") return; + this.setState({ + muted: this.props.scene.is("muted") + }); + }; + + toggleMute = () => { + this.props.scene.emit("action_mute"); + }; + handleForcedVREntryType = () => { if (!this.props.forcedVREntryType) return; @@ -109,7 +134,7 @@ class UIRoot extends Component { } else if (this.props.forcedVREntryType === "gearvr") { this.enterGearVR(); } - } + }; setupTestTone = () => { const toneClip = document.querySelector("#test-tone"); @@ -121,27 +146,29 @@ class UIRoot extends Component { setTimeout(() => { this.setState({ tonePlaying: true }); - setTimeout(() => { this.setState({ tonePlaying: false }); }, toneLength) + setTimeout(() => { + this.setState({ tonePlaying: false }); + }, toneLength); }, toneDelay); }; toneClip.addEventListener("seeked", toneIndicatorLoop); toneClip.addEventListener("playing", toneIndicatorLoop); - } + }; startTestTone = () => { const toneClip = document.querySelector("#test-tone"); toneClip.loop = true; toneClip.play(); - } + }; stopTestTone = () => { - const toneClip = document.querySelector("#test-tone") + const toneClip = document.querySelector("#test-tone"); toneClip.pause(); toneClip.currentTime = 0; - this.setState({ tonePlaying: false }) - } + this.setState({ tonePlaying: false }); + }; onConcurrentLoad = () => { if (this.props.disableAutoExitOnConcurrentLoad) return; @@ -158,33 +185,37 @@ class UIRoot extends Component { this.checkForAutoExit(); }, 500); - this.setState({ autoExitTimerStartedAt: new Date(), autoExitTimerInterval }) - } + this.setState({ autoExitTimerStartedAt: new Date(), autoExitTimerInterval }); + }; checkForAutoExit = () => { if (this.state.secondsRemainingBeforeAutoExit !== 0) return; this.endAutoExitTimer(); this.exit(); - } + }; exit = () => { this.props.exitScene(); this.setState({ exited: true }); - } + }; isWaitingForAutoExit = () => { return this.state.secondsRemainingBeforeAutoExit <= AUTO_EXIT_TIMER_SECONDS; - } + }; endAutoExitTimer = () => { clearInterval(this.state.autoExitTimerInterval); - this.setState({ autoExitTimerStartedAt: null, autoExitTimerInterval: null, secondsRemainingBeforeAutoExit: Infinity }); - } + this.setState({ + autoExitTimerStartedAt: null, + autoExitTimerInterval: null, + secondsRemainingBeforeAutoExit: Infinity + }); + }; - performDirectEntryFlow = async (enterInVR) => { + performDirectEntryFlow = async enterInVR => { this.startTestTone(); - this.setState({ enterInVR }) + this.setState({ enterInVR }); const hasGrantedMic = await hasGrantedMicPermissions(); @@ -195,15 +226,15 @@ class UIRoot extends Component { this.stopTestTone(); this.setState({ entryStep: ENTRY_STEPS.mic_grant }); } - } + }; enter2D = async () => { await this.performDirectEntryFlow(false); - } + }; enterVR = async () => { await this.performDirectEntryFlow(true); - } + }; enterGearVR = async () => { this.exit(); @@ -212,10 +243,11 @@ class UIRoot extends Component { const qs = queryString.parse(document.location.search); qs.vr_entry_type = "gearvr"; // Auto-choose 'gearvr' after landing in Oculus Browser - const ovrwebUrl = `ovrweb://${document.location.protocol || "http:"}//${document.location.host}${document.location.pathname || ""}?${queryString.stringify(qs)}#{document.location.hash || ""}`; + const ovrwebUrl = `ovrweb://${document.location.protocol || "http:"}//${document.location.host}${document.location + .pathname || ""}?${queryString.stringify(qs)}#{document.location.hash || ""}`; document.location = ovrwebUrl; - } + }; enterDaydream = async () => { const loc = document.location; @@ -227,27 +259,32 @@ class UIRoot extends Component { const qs = queryString.parse(document.location.search); qs.vr_entry_type = "daydream"; // Auto-choose 'daydream' after landing in chrome - const intentUrl = `intent://${document.location.host}${document.location.pathname || ""}?${queryString.stringify(qs)}#Intent;scheme=${(document.location.protocol || "http:").replace(":", "")};action=android.intent.action.VIEW;package=com.android.chrome;end;`; + const intentUrl = `intent://${document.location.host}${document.location.pathname || ""}?${queryString.stringify( + qs + )}#Intent;scheme=${(document.location.protocol || "http:").replace( + ":", + "" + )};action=android.intent.action.VIEW;package=com.android.chrome;end;`; document.location = intentUrl; } else { await this.performDirectEntryFlow(true); } - } + }; mediaVideoConstraint = () => { return this.state.shareScreen ? { mediaSource: "screen", height: 720, frameRate: 30 } : false; - } + }; - micDeviceChanged = async (ev) => { + micDeviceChanged = async ev => { const constraints = { audio: { deviceId: { exact: [ev.target.value] } }, video: this.mediaVideoConstraint() }; await this.setupNewMediaStream(constraints); - } + }; setMediaStreamToDefault = async () => { await this.setupNewMediaStream({ audio: true, video: false }); - } + }; - setupNewMediaStream = async (constraints) => { + setupNewMediaStream = async constraints => { const AudioContext = window.AudioContext || window.webkitAudioContext; const audioContext = new AudioContext(); @@ -280,13 +317,13 @@ class UIRoot extends Component { v = Math.max(levels[x] - 127, v); } - const level = v / 128.0 ; + const level = v / 128.0; this.micLevelMovingAverage.push(Date.now(), level); - this.setState({ micLevel: this.micLevelMovingAverage.movingAverage() }) + this.setState({ micLevel: this.micLevelMovingAverage.movingAverage() }); }, 50); this.setState({ mediaStream, micUpdateInterval }); - } + }; onMicGrantButton = async () => { if (this.state.entryStep == ENTRY_STEPS.mic_grant) { @@ -296,43 +333,48 @@ class UIRoot extends Component { this.startTestTone(); await this.beginAudioSetup(); } - } + }; onProfileFinished = () => { - this.setState({ showProfileEntry: false }) - } + this.setState({ showProfileEntry: false }); + }; beginAudioSetup = async () => { await this.fetchMicDevices(); this.setState({ entryStep: ENTRY_STEPS.audio_setup }); - } + }; fetchMicDevices = async () => { const mediaDevices = await navigator.mediaDevices.enumerateDevices(); - this.setState({ micDevices: mediaDevices.filter(d => d.kind === "audioinput").map(d => ({ deviceId: d.deviceId, label: d.label }))}); - } + this.setState({ + micDevices: mediaDevices.filter(d => d.kind === "audioinput").map(d => ({ deviceId: d.deviceId, label: d.label })) + }); + }; shouldShowHmdMicWarning = () => { if (mobiledetect.mobile()) return false; if (!this.state.enterInVR) return false; if (!this.hasHmdMicrophone()) return false; - return !(HMD_MIC_REGEXES.find(r => this.selectedMicLabel().match(r))); - } + return !HMD_MIC_REGEXES.find(r => this.selectedMicLabel().match(r)); + }; hasHmdMicrophone = () => { - return !!(this.state.micDevices.find(d => HMD_MIC_REGEXES.find(r => d.label.match(r)))); - } + return !!this.state.micDevices.find(d => HMD_MIC_REGEXES.find(r => d.label.match(r))); + }; selectedMicLabel = () => { - return (this.state.mediaStream - && this.state.mediaStream.getAudioTracks().length > 0 - && this.state.mediaStream.getAudioTracks()[0].label) || ""; - } + return ( + (this.state.mediaStream && + this.state.mediaStream.getAudioTracks().length > 0 && + this.state.mediaStream.getAudioTracks()[0].label) || + "" + ); + }; selectedMicDeviceId = () => { return this.state.micDevices.filter(d => d.label === this.selectedMicLabel).map(d => d.deviceId)[0]; - } + }; onAudioReadyButton = () => { this.props.enterScene(this.state.mediaStream, this.state.enterInVR, this.state.janusRoomId); @@ -341,17 +383,17 @@ class UIRoot extends Component { if (mediaStream) { if (mediaStream.getAudioTracks().length > 0) { - console.log(`Using microphone: ${mediaStream.getAudioTracks()[0].label}`) + console.log(`Using microphone: ${mediaStream.getAudioTracks()[0].label}`); } if (mediaStream.getVideoTracks().length > 0) { - console.log('Screen sharing enabled.') + console.log("Screen sharing enabled."); } } this.stopTestTone(); this.setState({ entryStep: ENTRY_STEPS.finished }); - } + }; render() { if (!this.state.initialEnvironmentLoaded || !this.state.availableVREntryTypes || !this.state.janusRoomId) { @@ -360,7 +402,7 @@ class UIRoot extends Component { <div className="loading-panel"> <div className="loader-wrap"> <div className="loader"> - <div className="loader-center"/> + <div className="loader-center" /> </div> </div> <div className="loading-panel__title"> @@ -379,7 +421,7 @@ class UIRoot extends Component { <b>moz://a</b> duck </div> <div className="loading-panel__subtitle"> - <FormattedMessage id="exit.subtitle"/> + <FormattedMessage id="exit.subtitle" /> </div> </div> </IntlProvider> @@ -388,125 +430,195 @@ class UIRoot extends Component { const daydreamMaybeSubtitle = messages["entry.daydream-via-chrome"]; - const entryPanel = this.state.entryStep === ENTRY_STEPS.start - ? ( - <div className="entry-panel"> - <TwoDEntryButton onClick={this.enter2D}/> - { this.state.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.no && <GenericEntryButton onClick={this.enterVR}/> } - { this.state.availableVREntryTypes.gearvr !== VR_DEVICE_AVAILABILITY.no && <GearVREntryButton onClick={this.enterGearVR}/> } - { this.state.availableVREntryTypes.daydream !== VR_DEVICE_AVAILABILITY.no && + const entryPanel = + this.state.entryStep === ENTRY_STEPS.start ? ( + <div className="entry-panel"> + <TwoDEntryButton onClick={this.enter2D} /> + {this.state.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.no && ( + <GenericEntryButton onClick={this.enterVR} /> + )} + {this.state.availableVREntryTypes.gearvr !== VR_DEVICE_AVAILABILITY.no && ( + <GearVREntryButton onClick={this.enterGearVR} /> + )} + {this.state.availableVREntryTypes.daydream !== VR_DEVICE_AVAILABILITY.no && ( <DaydreamEntryButton onClick={this.enterDaydream} - subtitle={this.state.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe ? daydreamMaybeSubtitle : "" }/> } - { this.state.availableVREntryTypes.cardboard !== VR_DEVICE_AVAILABILITY.no && - (<div className="entry-panel__secondary" onClick={this.enterVR}><FormattedMessage id="entry.cardboard"/></div>) } - </div> - ) : null; + subtitle={ + this.state.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe ? daydreamMaybeSubtitle : "" + } + /> + )} + {this.state.availableVREntryTypes.cardboard !== VR_DEVICE_AVAILABILITY.no && ( + <div className="entry-panel__secondary" onClick={this.enterVR}> + <FormattedMessage id="entry.cardboard" /> + </div> + )} + </div> + ) : null; - const micPanel = this.state.entryStep === ENTRY_STEPS.mic_grant || this.state.entryStep == ENTRY_STEPS.mic_granted - ? ( + const micPanel = + this.state.entryStep === ENTRY_STEPS.mic_grant || this.state.entryStep == ENTRY_STEPS.mic_granted ? ( <div className="mic-grant-panel"> <div className="mic-grant-panel__title"> - <FormattedMessage id={ this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-title" : "audio.granted-title" }/> + <FormattedMessage + id={this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-title" : "audio.granted-title"} + /> </div> <div className="mic-grant-panel__subtitle"> - <FormattedMessage id={ this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-subtitle" : "audio.granted-subtitle" }/> + <FormattedMessage + id={this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-subtitle" : "audio.granted-subtitle"} + /> </div> <div className="mic-grant-panel__icon"> - { this.state.entryStep == ENTRY_STEPS.mic_grant ? - (<img onClick={this.onMicGrantButton} src="../assets/images/mic_denied.png" srcSet="../assets/images/mic_denied@2x.png 2x" className="mic-grant-panel__icon"/>) : - (<img onClick={this.onMicGrantButton} src="../assets/images/mic_granted.png" srcSet="../assets/images/mic_granted@2x.png 2x" className="mic-grant-panel__icon"/>)} + {this.state.entryStep == ENTRY_STEPS.mic_grant ? ( + <img + onClick={this.onMicGrantButton} + src="../assets/images/mic_denied.png" + srcSet="../assets/images/mic_denied@2x.png 2x" + className="mic-grant-panel__icon" + /> + ) : ( + <img + onClick={this.onMicGrantButton} + src="../assets/images/mic_granted.png" + srcSet="../assets/images/mic_granted@2x.png 2x" + className="mic-grant-panel__icon" + /> + )} </div> <div className="mic-grant-panel__next" onClick={this.onMicGrantButton}> - <FormattedMessage id={ this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-next" : "audio.granted-next" }/> + <FormattedMessage + id={this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-next" : "audio.granted-next"} + /> </div> </div> ) : null; const maxLevelHeight = 111; - const micClip = { clip: `rect(${maxLevelHeight - Math.floor(this.state.micLevel * maxLevelHeight)}px, 111px, 111px, 0px)` }; + const micClip = { + clip: `rect(${maxLevelHeight - Math.floor(this.state.micLevel * maxLevelHeight)}px, 111px, 111px, 0px)` + }; const speakerClip = { clip: `rect(${this.state.tonePlaying ? 0 : maxLevelHeight}px, 111px, 111px, 0px)` }; - const audioSetupPanel = this.state.entryStep === ENTRY_STEPS.audio_setup - ? ( + const audioSetupPanel = + this.state.entryStep === ENTRY_STEPS.audio_setup ? ( <div className="audio-setup-panel"> <div className="audio-setup-panel__title"> - <FormattedMessage id="audio.title"/> + <FormattedMessage id="audio.title" /> </div> <div className="audio-setup-panel__subtitle"> - { (mobiledetect.mobile() || this.state.enterInVR) && (<FormattedMessage id={ mobiledetect.mobile() ? "audio.subtitle-mobile" : "audio.subtitle-desktop" }/>) } + {(mobiledetect.mobile() || this.state.enterInVR) && ( + <FormattedMessage id={mobiledetect.mobile() ? "audio.subtitle-mobile" : "audio.subtitle-desktop"} /> + )} </div> <div className="audio-setup-panel__levels"> <div className="audio-setup-panel__levels__mic"> - <img src="../assets/images/mic_level.png" srcSet="../assets/images/mic_level@2x.png 2x" className="audio-setup-panel__levels__mic_icon"/> - <img src="../assets/images/level_fill.png" srcSet="../assets/images/level_fill@2x.png 2x" className="audio-setup-panel__levels__level" style={ micClip }/> + <img + src="../assets/images/mic_level.png" + srcSet="../assets/images/mic_level@2x.png 2x" + className="audio-setup-panel__levels__mic_icon" + /> + <img + src="../assets/images/level_fill.png" + srcSet="../assets/images/level_fill@2x.png 2x" + className="audio-setup-panel__levels__level" + style={micClip} + /> </div> <div className="audio-setup-panel__levels__speaker"> - <img src="../assets/images/speaker_level.png" srcSet="../assets/images/speaker_level@2x.png 2x" className="audio-setup-panel__levels__speaker_icon"/> - <img src="../assets/images/level_fill.png" srcSet="../assets/images/level_fill@2x.png 2x" className="audio-setup-panel__levels__level" style={ speakerClip }/> + <img + src="../assets/images/speaker_level.png" + srcSet="../assets/images/speaker_level@2x.png 2x" + className="audio-setup-panel__levels__speaker_icon" + /> + <img + src="../assets/images/level_fill.png" + srcSet="../assets/images/level_fill@2x.png 2x" + className="audio-setup-panel__levels__level" + style={speakerClip} + /> </div> </div> <div className="audio-setup-panel__device-chooser"> - <select className="audio-setup-panel__device-chooser__dropdown" value={this.selectedMicDeviceId()} onChange={this.micDeviceChanged}> - { this.state.micDevices.map(d => (<option key={ d.deviceId } value={ d.deviceId }> {d.label}</option>)) } + <select + className="audio-setup-panel__device-chooser__dropdown" + value={this.selectedMicDeviceId()} + onChange={this.micDeviceChanged} + > + {this.state.micDevices.map(d => ( + <option key={d.deviceId} value={d.deviceId}> + {d.label} + </option> + ))} </select> <div className="audio-setup-panel__device-chooser__mic-icon"> - <img src="../assets/images/mic_small.png" srcSet="../assets/images/mic_small@2x.png 2x"/> + <img src="../assets/images/mic_small.png" srcSet="../assets/images/mic_small@2x.png 2x" /> </div> </div> - { this.shouldShowHmdMicWarning() && - (<div className="audio-setup-panel__hmd-mic-warning"> - <img src="../assets/images/warning_icon.png" srcSet="../assets/images/warning_icon@2x.png 2x" - className="audio-setup-panel__hmd-mic-warning__icon"/> + {this.shouldShowHmdMicWarning() && ( + <div className="audio-setup-panel__hmd-mic-warning"> + <img + src="../assets/images/warning_icon.png" + srcSet="../assets/images/warning_icon@2x.png 2x" + className="audio-setup-panel__hmd-mic-warning__icon" + /> <span className="audio-setup-panel__hmd-mic-warning__label"> - <FormattedMessage id="audio.hmd-mic-warning"/> + <FormattedMessage id="audio.hmd-mic-warning" /> </span> - </div>) } + </div> + )} <div className="audio-setup-panel__enter-button" onClick={this.onAudioReadyButton}> - <FormattedMessage id="audio.enter-now"/> + <FormattedMessage id="audio.enter-now" /> </div> </div> ) : null; - const dialogContents = this.isWaitingForAutoExit() ? - (<AutoExitWarning secondsRemaining={this.state.secondsRemainingBeforeAutoExit} onCancel={this.endAutoExitTimer} />) : - ( - <div className="entry-dialog"> - <ProfileInfoHeader name={this.props.store.state.profile.display_name} onClick={(() => this.setState({showProfileEntry: true })) }/> - {entryPanel} - {micPanel} - {audioSetupPanel} - </div> - ); + const dialogContents = this.isWaitingForAutoExit() ? ( + <AutoExitWarning secondsRemaining={this.state.secondsRemainingBeforeAutoExit} onCancel={this.endAutoExitTimer} /> + ) : ( + <div className="entry-dialog"> + <ProfileInfoHeader + name={this.props.store.state.profile.display_name} + onClick={() => this.setState({ showProfileEntry: true })} + /> + {entryPanel} + {micPanel} + {audioSetupPanel} + </div> + ); - const dialogClassNames = classNames({ - 'ui-dialog': true, - 'ui-dialog--darkened': this.state.entryStep !== ENTRY_STEPS.finished + const dialogClassNames = classNames("ui-dialog", { + "ui-dialog--darkened": this.state.entryStep !== ENTRY_STEPS.finished }); - const dialogBoxClassNames = classNames({ 'ui-dialog-box': true }); + const dialogBoxClassNames = classNames("ui-interactive", "ui-dialog-box"); const dialogBoxContentsClassNames = classNames({ - 'ui-dialog-box-contents': true, - 'ui-dialog-box-contents--backgrounded': this.state.showProfileEntry + "ui-dialog-box-contents": true, + "ui-dialog-box-contents--backgrounded": this.state.showProfileEntry }); return ( <IntlProvider locale={lang} messages={messages}> - <div className={dialogClassNames}> - { - (this.state.entryStep !== ENTRY_STEPS.finished || this.isWaitingForAutoExit()) && - ( + <div className="ui"> + <div className={dialogClassNames}> + {(this.state.entryStep !== ENTRY_STEPS.finished || this.isWaitingForAutoExit()) && ( <div className={dialogBoxClassNames}> - <div className={dialogBoxContentsClassNames}> - {dialogContents} - </div> + <div className={dialogBoxContentsClassNames}>{dialogContents}</div> {this.state.showProfileEntry && ( - <ProfileEntryPanel finished={this.onProfileFinished} store={this.props.store}/>)} + <ProfileEntryPanel finished={this.onProfileFinished} store={this.props.store} /> + )} </div> - ) - } + )} + </div> + {this.state.entryStep === ENTRY_STEPS.finished ? ( + <TwoDHUD + name={this.props.store.state.profile.display_name} + muted={this.state.muted} + onToggleMute={this.toggleMute} + /> + ) : null} </div> </IntlProvider> ); diff --git a/src/systems/app-mode.js b/src/systems/app-mode.js new file mode 100644 index 0000000000000000000000000000000000000000..421bf48abbab344b877f9805803a7dfe30472336 --- /dev/null +++ b/src/systems/app-mode.js @@ -0,0 +1,226 @@ +/* global AFRAME, console, setTimeout, clearTimeout */ + +const AppModes = Object.freeze({ DEFAULT: "default", HUD: "hud" }); + +/** + * Simple system for keeping track of a modal app state + */ +AFRAME.registerSystem("app-mode", { + init() { + this.setMode(AppModes.DEFAULT); + }, + + setMode(newMode) { + if (Object.values(AppModes).includes(newMode) && newMode !== this.mode) { + this.mode = newMode; + this.el.emit("app-mode-change", { mode: this.mode }); + } + } +}); + +/** + * Toggle the isPlaying state of a component based on app mode + */ +AFRAME.registerComponent("app-mode-toggle-playing", { + multiple: true, + schema: { + mode: { type: "string" }, + invert: { type: "boolean", default: false } + }, + + init() { + const AppModeSystem = this.el.sceneEl.systems["app-mode"]; + this.el.sceneEl.addEventListener("app-mode-change", e => { + this.updateComponentState(e.detail.mode === this.data.mode); + }); + this.updateComponentState(AppModeSystem.mode === this.data.mode); + }, + + updateComponentState(isModeActive) { + const componentName = this.id; + this.el.components[componentName][isModeActive !== this.data.invert ? "play" : "pause"](); + } +}); + +/** + * Toggle a boolean property of a component based on app mode + */ +AFRAME.registerComponent("app-mode-toggle-attribute", { + multiple: true, + schema: { + mode: { type: "string" }, + invert: { type: "boolean", default: false }, + property: { type: "string" } + }, + + init() { + const AppModeSystem = this.el.sceneEl.systems["app-mode"]; + this.el.sceneEl.addEventListener("app-mode-change", e => { + this.updateComponentState(e.detail.mode === this.data.mode); + }); + this.updateComponentState(AppModeSystem.mode === this.data.mode); + }, + + updateComponentState(isModeActive) { + const componentName = this.id; + this.el.setAttribute(componentName, this.data.property, isModeActive !== this.data.invert); + } +}); + +/** + * Toggle aframe input mappings action set based on app mode + */ +AFRAME.registerComponent("app-mode-input-mappings", { + schema: { + modes: { default: [] }, + actionSets: { default: [] } + }, + init() { + this.el.sceneEl.addEventListener("app-mode-change", e => { + const { modes, actionSets } = this.data; + const idx = modes.indexOf(e.detail.mode); + if (idx != -1 && modes[idx] && actionSets[idx] && AFRAME.inputActions[actionSets[idx]]) { + // TODO: this assumes full control over current action set reguardless of what else might be manipulating it, this is obviously wrong + AFRAME.currentInputMapping = actionSets[idx]; + } else { + console.error(`no valid action set for ${e.detail.mode}`); + } + }); + } +}); + +const TWOPI = Math.PI * 2; +function deltaAngle(a, b) { + const p = Math.abs(b - a) % TWOPI; + return p > Math.PI ? TWOPI - p : p; +} + +/** + * Positions the HUD and toggles app mode based on where the user is looking + */ +AFRAME.registerComponent("hud-controller", { + schema: { + head: { type: "selector" }, + offset: { default: 1 }, // distance from hud below head, + lookCutoff: { default: -25 }, // angle at which the hud should be "on", + animRange: { default: 30 }, // degrees over which to animate the hud into view + yawCutoff: { default: 100 } // yaw degrees at wich the hud should reoirent even if the user is looking down + }, + init() { + this.isYLocked = false; + this.lockedHeadPositionY = 0; + }, + + pause() { + // TODO: this assumes full control over current app mode reguardless of what else might be manipulating it, this is obviously wrong + const AppModeSystem = this.el.sceneEl.systems["app-mode"]; + AppModeSystem.setMode(AppModes.DEFAULT); + }, + + tick() { + const hud = this.el.object3D; + const head = this.data.head.object3D; + const sceneEl = this.el.sceneEl; + + const { offset, lookCutoff, animRange, yawCutoff } = this.data; + + const pitch = head.rotation.x * THREE.Math.RAD2DEG; + const yawDif = deltaAngle(head.rotation.y, hud.rotation.y) * THREE.Math.RAD2DEG; + + // Reorient the hud only if the user is looking "up", for right now this arbitrarily means the hud is 1/3 way animated away + // TODO: come up with better huristics for this that maybe account for the user turning away from the hud "too far", also animate the position so that it doesnt just snap. + if (yawDif >= yawCutoff || pitch > lookCutoff + animRange / 3) { + const lookDir = new THREE.Vector3(0, 0, -1); + lookDir.applyQuaternion(head.quaternion); + lookDir.add(head.position); + hud.position.x = lookDir.x; + hud.position.z = lookDir.z; + hud.setRotationFromEuler(new THREE.Euler(0, head.rotation.y, 0)); + } + + //animate the hud into place over animRange degrees as the user aproaches the lookCutoff angle + const t = 1 - THREE.Math.clamp(pitch - lookCutoff, 0, animRange) / animRange; + + // Lock the hud in place relative to a known head position so it doesn't bob up and down + // with the user's head + if (!this.isYLocked && t === 1) { + this.lockedHeadPositionY = head.position.y; + } + const EPSILON = 0.001; + this.isYLocked = t > 1 - EPSILON; + + hud.position.y = (this.isYLocked ? this.lockedHeadPositionY : head.position.y) - offset - offset * (1 - t); + hud.rotation.x = (1 - t) * THREE.Math.DEG2RAD * 90; + + // update the app mode when the HUD locks on or off + // TODO: this assumes full control over current app mode reguardless of what else might be manipulating it, this is obviously wrong + const AppModeSystem = sceneEl.systems["app-mode"]; + if (pitch < lookCutoff && AppModeSystem.mode !== AppModes.HUD) { + AppModeSystem.setMode(AppModes.HUD); + sceneEl.renderer.sortObjects = true; + } else if (pitch > lookCutoff && AppModeSystem.mode === AppModes.HUD) { + AppModeSystem.setMode(AppModes.DEFAULT); + sceneEl.renderer.sortObjects = false; + } + } +}); + +/** + * Toggle visibility of an entity based on if the user is in vr mode or not + */ +AFRAME.registerComponent("vr-mode-toggle-visibility", { + schema: { + invert: { type: "boolean", default: false } + }, + + init() { + this.updateComponentState = this.updateComponentState.bind(this); + }, + + play() { + this.updateComponentState(); + this.el.sceneEl.addEventListener("enter-vr", this.updateComponentState); + this.el.sceneEl.addEventListener("exit-vr", this.updateComponentState); + }, + + pause() { + this.el.sceneEl.removeEventListener("enter-vr", this.updateComponentState); + this.el.sceneEl.removeEventListener("exit-vr", this.updateComponentState); + }, + + updateComponentState(i) { + const inVRMode = this.el.sceneEl.is("vr-mode"); + this.el.setAttribute("visible", inVRMode !== this.data.invert); + } +}); + +/** + * Toggle the isPlaying state of a component based on app mode + */ +AFRAME.registerComponent("vr-mode-toggle-playing", { + multiple: true, + schema: { + invert: { type: "boolean", default: false } + }, + + init() { + this.updateComponentState = this.updateComponentState.bind(this); + }, + + play() { + this.updateComponentState(); + this.el.sceneEl.addEventListener("enter-vr", this.updateComponentState); + this.el.sceneEl.addEventListener("exit-vr", this.updateComponentState); + }, + + pause() { + this.el.sceneEl.removeEventListener("enter-vr", this.updateComponentState); + this.el.sceneEl.removeEventListener("exit-vr", this.updateComponentState); + }, + + updateComponentState(i) { + const componentName = this.id; + const inVRMode = this.el.sceneEl.is("vr-mode"); + this.el.components[componentName][inVRMode !== this.data.invert ? "play" : "pause"](); + } +}); diff --git a/yarn.lock b/yarn.lock index 33240630b4fbc8aae1eabc53fb846f7179a5f5db..cce545356bdd3a422811544e84dc33aafcbf63db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -176,9 +176,9 @@ aframe-physics-system@^1.4.3: three-to-cannon "^1.2.0" webworkify "^1.4.0" -"aframe-teleport-controls@https://github.com/netpro2k/aframe-teleport-controls#feature/teleport-origin": - version "0.3.0" - resolved "https://github.com/netpro2k/aframe-teleport-controls#41fe311d3123503ba44761acce69d0f0634139cc" +"aframe-teleport-controls@https://github.com/netpro2k/aframe-teleport-controls#feature/pauseable": + version "0.3.2" + resolved "https://github.com/netpro2k/aframe-teleport-controls#7f67003dd3bd1348357fbf89aaeed916ef2d4016" "aframe-xr@github:brianpeiris/aframe-xr#3162aed": version "0.0.9" @@ -2604,14 +2604,14 @@ dom-walk@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018" -domain-browser@^1.1.1, domain-browser@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" - -domain-browser@~1.1.0: +domain-browser@^1.1.1, domain-browser@~1.1.0: version "1.1.7" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.1.7.tgz#867aa4b093faa05f1de08c06f4d7b21fdf8698bc" +domain-browser@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" + domelementtype@1: version "1.3.0" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.0.tgz#b17aed82e8ab59e52dd9c19b1756e0fc187204c2"