diff --git a/README.md b/README.md index a3e7026c4e4d15a72fbdef4629ddc756f579f112..d42dacae191ad8c4902202e5ca08fbb7b8a72404 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ To bundle javascript and generate the html templates, run: yarn build ``` +## hubs.local Host Entry + +When running the full stack for Hubs (which includes [Reticulum](https://github.com/mozilla/reticulum)) +locally it is necessary to add a `hosts` entry pointing `hubs.local` to your local server's IP. +This will allow the CSP checks to pass that are served up by Reticulum so you can test the whole app. + ## Query Params - `room` - Id of the room (an integer) that you want to join @@ -31,7 +37,7 @@ yarn build - `quality` - Either "low" or "high". Force assets to a certain quality level - `mobile` - Force mobile mode - `no_stats` - Disable performance stats -- `vr_entry_type` - Either "gearvr" or "daydream". Used internally to force a VR entry type +- `vr_entry_type` - Either "2d", "vr", or "daydream". Used internally to force a VR entry type. Add "_now" to the end of the value to skip the audio check. - `disable_telemetry` - If `true` disables Sentry telemetry. - `log_filter` - A `debug` style filter for setting the logging level. - `debug` - If `true` performs verbose logging of Janus and NAF traffic. diff --git a/scripts/bot/run-bot.js b/scripts/bot/run-bot.js index 5deb08208319005b57777f201287b247e6ef3eb3..dc3cb0160099bb1e437af9c8ea3065e7eb84c0aa 100755 --- a/scripts/bot/run-bot.js +++ b/scripts/bot/run-bot.js @@ -15,12 +15,20 @@ const options = docopt(doc); const puppeteer = require("puppeteer"); const querystring = require("query-string"); +function log(...objs) { + console.log.call(null, [new Date().toISOString()].concat(objs).join(" ")); +} + +function error(...objs) { + console.error.call(null, [new Date().toISOString()].concat(objs).join(" ")); +} + (async () => { const browser = await puppeteer.launch({ ignoreHTTPSErrors: true }); const page = await browser.newPage(); - page.on("console", msg => console.log("PAGE: ", msg.text())); - page.on("error", err => console.error("ERROR: ", err.toString().split("\n")[0])); - page.on("pageerror", err => console.error("PAGE ERROR: ", err.toString().split("\n")[0])); + page.on("console", msg => log("PAGE: ", msg.text())); + page.on("error", err => error("ERROR: ", err.toString().split("\n")[0])); + page.on("pageerror", err => error("PAGE ERROR: ", err.toString().split("\n")[0])); const baseUrl = options["--url"] || `https://${options["--host"]}/hub.html`; @@ -34,11 +42,11 @@ const querystring = require("query-string"); } const url = `${baseUrl}?${querystring.stringify(params)}`; - console.log(url); + log(url); const navigate = async () => { try { - console.log("Spawning bot..."); + log("Spawning bot..."); await page.goto(url); await page.evaluate(() => console.log(navigator.userAgent)); let retryCount = 5; @@ -49,14 +57,14 @@ const querystring = require("query-string"); await page.mouse.click(100, 100); // Signal that the page has been interacted with. await page.evaluate(() => window.interacted()); - console.log("Interacted."); + log("Interacted."); } catch (e) { - console.log("Interaction error", e.message); + log("Interaction error", e.message); if (retryCount-- < 0) { // If retries failed, throw and restart navigation. throw new Error("Retries failed"); } - console.log("Retrying..."); + log("Retrying..."); backoff *= 2; // Retry interaction to start audio playback setTimeout(interact, backoff); @@ -64,7 +72,7 @@ const querystring = require("query-string"); }; await interact(); } catch (e) { - console.log("Navigation error", e.message); + log("Navigation error", e.message); setTimeout(navigate, 1000); } }; diff --git a/scripts/build_local_reticulum.sh b/scripts/build_local_reticulum.sh index 3f5e3a00136a84adf99b68926ee0dd1b17a09af3..9a19f9f202b688b27213b0478f9e1a14e67d2620 100755 --- a/scripts/build_local_reticulum.sh +++ b/scripts/build_local_reticulum.sh @@ -4,4 +4,4 @@ if [ ! -e ../reticulum ]; then echo "This script assumes reticulum is checked out in a sibling to this folder." fi -rm -rf ../reticulum/priv/static ; GENERATE_SMOKE_TESTS=true BASE_ASSETS_PATH=https://localhost:4000/ yarn build -- --output-path ../reticulum/priv/static +rm -rf ../reticulum/priv/static ; GENERATE_SMOKE_TESTS=true BASE_ASSETS_PATH=https://hubs.local:4000/ yarn build -- --output-path ../reticulum/priv/static diff --git a/src/assets/stylesheets/hub.scss b/src/assets/stylesheets/hub.scss index d9519c92fcc0b05dae6916bcf1b06f087c0e14d2..c7ea6d87c861f86ecda0c747e239467b2be32537 100644 --- a/src/assets/stylesheets/hub.scss +++ b/src/assets/stylesheets/hub.scss @@ -12,13 +12,10 @@ display: none; } -.a-canvas.a-grab-cursor:hover { +.no-cursor { cursor: none; } -.a-canvas.a-grab-cursor:active { - cursor: none; -} .webxr-realities, .webxr-sessions { user-select: none; diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json index 0201cf0fca4a81f60217ab97679f6f2e7316fc43..79d11599544849a83f9982617dafddd3bb6c268a 100644 --- a/src/assets/translations.data.json +++ b/src/assets/translations.data.json @@ -3,6 +3,7 @@ "entry.screen-prefix": "Enter on ", "entry.desktop-screen": "Screen", "entry.mobile-screen": "Phone", + "entry.mobile-safari": "Safari", "entry.generic-prefix": "Enter in ", "entry.generic-medium": "VR", "entry.generic-subtitle-desktop": "Oculus or SteamVR", diff --git a/src/avatar-selector.html b/src/avatar-selector.html index 96dd9c67463ec3ddda58956463b2b136b005ed27..d125ca51622c0ed77dec9b1285e2a1b0380c8a27 100644 --- a/src/avatar-selector.html +++ b/src/avatar-selector.html @@ -6,9 +6,9 @@ <title>avatar selector</title> <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"> <% if(NODE_ENV === "production") { %> - <script src="https://cdn.rawgit.com/aframevr/aframe/3e7a4b3/dist/aframe-master.min.js" integrity="sha384-LQXa4VjhYucs9sVd5yQ3OhBXRea0jrvbHJA8CYLgTnvzxF5uvyhabSo1mX4tT2c6" crossorigin="anonymous"></script> + <script src="https://cdn.rawgit.com/aframevr/aframe/1be48d9/dist/aframe-master.min.js" integrity="sha384-SXrfoMHbXpA5RZhIyhgaR6tQ764dDZsbFk3PiokC/tc0+NnW1yaYQMUzWtL06hnq" crossorigin="anonymous"></script> <% } else { %> - <script src="https://cdn.rawgit.com/aframevr/aframe/3e7a4b3/dist/aframe-master.js" integrity="sha384-EaMOuyBOi9ERV/lVDwQgz/yFWBDWPsIju5Co6oCZZHXuvbLBO81yPWn80q0BbBn3" crossorigin="anonymous"></script> + <script src="https://cdn.rawgit.com/aframevr/aframe/1be48d9/dist/aframe-master.js" integrity="sha384-AmjDGOMbvTrrUFdeVWcBIlXRINIWnO8iwj/4VS21OWbYDsa/7nheOIyPAPJSkR6J" crossorigin="anonymous"></script> <% } %> </head> diff --git a/src/behaviours/trackpad-scrolling.js b/src/behaviours/trackpad-scrolling.js new file mode 100644 index 0000000000000000000000000000000000000000..8cb5baf9502b697141b434499525b5c40e63e555 --- /dev/null +++ b/src/behaviours/trackpad-scrolling.js @@ -0,0 +1,56 @@ +function trackpad_scrolling(el) { + this.el = el; + this.start = "trackpadtouchstart"; + this.move = "axismove"; + this.end = "trackpadtouchend"; + this.isScrolling = false; + this.x = -10; + this.y = -10; + this.axis = [0, 0]; + this.emittedEventDetail = { detail: { axis: this.axis } }; + + this.onStart = this.onStart.bind(this); + this.onMove = this.onMove.bind(this); + this.onEnd = this.onEnd.bind(this); +} + +trackpad_scrolling.prototype = { + addEventListeners: function() { + this.el.addEventListener(this.start, this.onStart); + this.el.addEventListener(this.move, this.onMove); + this.el.addEventListener(this.end, this.onEnd); + }, + removeEventListeners: function() { + this.el.removeEventListener(this.start, this.onStart); + this.el.removeEventListener(this.move, this.onMove); + this.el.removeEventListener(this.end, this.onEnd); + }, + onStart: function() { + this.isScrolling = true; + }, + onMove: function(e) { + if (!this.isScrolling) return; + const x = e.detail.axis[0]; + const y = e.detail.axis[1]; + if (this.x === -10) { + this.x = x; + this.y = y; + return; + } + + const scrollSpeed = 8; + this.axis[0] = (x - this.x) * scrollSpeed; + this.axis[1] = (y - this.y) * scrollSpeed; + this.emittedEventDetail.axis = this.axis; + e.target.emit("scroll", this.emittedEventDetail); + this.x = x; + this.y = y; + }, + onEnd: function() { + this.isScrolling = false; + this.x = -10; + this.y = -10; + } +}; + +export default trackpad_scrolling; diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js index ad741dd74faf6cad869799749555c8287e751d50..e0dda6f0bce57127c09ef5580bf84a497ad2caf8 100644 --- a/src/components/cursor-controller.js +++ b/src/components/cursor-controller.js @@ -2,457 +2,183 @@ const TARGET_TYPE_NONE = 1; const TARGET_TYPE_INTERACTABLE = 2; const TARGET_TYPE_UI = 4; const TARGET_TYPE_INTERACTABLE_OR_UI = TARGET_TYPE_INTERACTABLE | TARGET_TYPE_UI; -const virtualJoystickCutoff = 0.8; -/** - * Controls virtual cursor behavior in various modalities to affect teleportation, interatables and UI. - * @namespace user-input - * @component cursor-controller - */ AFRAME.registerComponent("cursor-controller", { dependencies: ["raycaster", "line"], schema: { cursor: { type: "selector" }, camera: { type: "selector" }, - playerRig: { type: "selector" }, - gazeTeleportControls: { type: "selector" }, - physicalHandSelector: { type: "string" }, - handedness: { default: "right", oneOf: ["right", "left"] }, maxDistance: { default: 3 }, - minDistance: { default: 0.5 }, + minDistance: { default: 0 }, cursorColorHovered: { default: "#2F80ED" }, cursorColorUnhovered: { default: "#FFFFFF" }, - primaryDown: { default: "action_primary_down" }, - primaryUp: { default: "action_primary_up" }, - grabEvent: { default: "action_grab" }, - releaseEvent: { default: "action_release" } + rayObject: { type: "selector" }, + useMousePos: { default: true }, + drawLine: { default: false } }, init: function() { + this.enabled = true; this.inVR = false; this.isMobile = AFRAME.utils.device.isMobile(); - this.hasPointingDevice = false; this.currentTargetType = TARGET_TYPE_NONE; - this.grabStarting = false; this.currentDistance = this.data.maxDistance; this.currentDistanceMod = 0; this.mousePos = new THREE.Vector2(); - this.controller = null; - this.controllerQueue = []; this.wasCursorHovered = false; - this.wasPhysicalHandGrabbing = false; this.origin = new THREE.Vector3(); this.direction = new THREE.Vector3(); + this.raycasterAttr = this.el.getAttribute("raycaster"); this.controllerQuaternion = new THREE.Quaternion(); - this.activeTouch = null; - this.data.cursor.setAttribute("material", { color: this.data.cursorColorUnhovered }); - this._handleTouchStart = this._handleTouchStart.bind(this); - this._handleTouchMove = this._handleTouchMove.bind(this); - this._handleTouchEnd = this._handleTouchEnd.bind(this); - this._handleMouseDown = this._handleMouseDown.bind(this); - this._handleMouseMove = this._handleMouseMove.bind(this); - this._handleMouseUp = this._handleMouseUp.bind(this); - this._handleWheel = this._handleWheel.bind(this); - this._handleEnterVR = this._handleEnterVR.bind(this); - this._handleExitVR = this._handleExitVR.bind(this); - this._handlePrimaryDown = this._handlePrimaryDown.bind(this); - this._handlePrimaryUp = this._handlePrimaryUp.bind(this); - this._handleModelLoaded = this._handleModelLoaded.bind(this); this._handleCursorLoaded = this._handleCursorLoaded.bind(this); - this._handleControllerConnected = this._handleControllerConnected.bind(this); - this._handleControllerDisconnected = this._handleControllerDisconnected.bind(this); - this.data.cursor.addEventListener("loaded", this._handleCursorLoaded); }, - remove: function() { - this.data.cursor.removeEventListener("loaded", this._handleCursorLoaded); + enable: function() { + this.enabled = true; }, - update: function(oldData) { - if (oldData.physicalHandSelector !== this.data.physicalHandSelector) { - this._handleModelLoaded(); - } - - if (oldData.handedness !== this.data.handedness) { - //TODO - } + disable: function() { + this.enabled = false; + this.setCursorVisibility(false); }, - play: function() { - document.addEventListener("touchstart", this._handleTouchStart); - document.addEventListener("touchmove", this._handleTouchMove); - document.addEventListener("touchend", this._handleTouchEnd); - document.addEventListener("touchcancel", this._handleTouchEnd); - document.addEventListener("mousedown", this._handleMouseDown); - document.addEventListener("mousemove", this._handleMouseMove); - document.addEventListener("mouseup", this._handleMouseUp); - document.addEventListener("wheel", this._handleWheel); - - window.addEventListener("enter-vr", this._handleEnterVR); - window.addEventListener("exit-vr", this._handleExitVR); - - this.data.playerRig.addEventListener(this.data.primaryDown, this._handlePrimaryDown); - this.data.playerRig.addEventListener(this.data.primaryUp, this._handlePrimaryUp); - this.data.playerRig.addEventListener(this.data.grabEvent, this._handlePrimaryDown); - this.data.playerRig.addEventListener(this.data.releaseEvent, this._handlePrimaryUp); - this.data.playerRig.addEventListener("cardboardbuttondown", this._handlePrimaryDown); - this.data.playerRig.addEventListener("cardboardbuttonup", this._handlePrimaryUp); - - this.data.playerRig.addEventListener("model-loaded", this._handleModelLoaded); - - this.el.sceneEl.addEventListener("controllerconnected", this._handleControllerConnected); - this.el.sceneEl.addEventListener("controllerdisconnected", this._handleControllerDisconnected); + updateRay: function() { + this.raycasterAttr.origin = this.origin; + this.raycasterAttr.direction = this.direction; + this.el.setAttribute("raycaster", this.raycasterAttr, true); }, - pause: function() { - document.removeEventListener("touchstart", this._handleTouchStart); - document.removeEventListener("touchmove", this._handleTouchMove); - document.removeEventListener("touchend", this._handleTouchEnd); - document.removeEventListener("touchcancel", this._handleTouchEnd); - document.removeEventListener("mousedown", this._handleMouseDown); - document.removeEventListener("mousemove", this._handleMouseMove); - document.removeEventListener("mouseup", this._handleMouseUp); - document.removeEventListener("wheel", this._handleWheel); + tick: (() => { + const rayObjectRotation = new THREE.Quaternion(); - window.removeEventListener("enter-vr", this._handleEnterVR); - window.removeEventListener("exit-vr", this._handleExitVR); - - this.data.playerRig.removeEventListener(this.data.primaryDown, this._handlePrimaryDown); - this.data.playerRig.removeEventListener(this.data.primaryUp, this._handlePrimaryUp); - this.data.playerRig.removeEventListener(this.data.grabEvent, this._handlePrimaryDown); - this.data.playerRig.removeEventListener(this.data.releaseEvent, this._handlePrimaryUp); - this.data.playerRig.removeEventListener("cardboardbuttondown", this._handlePrimaryDown); - this.data.playerRig.removeEventListener("cardboardbuttonup", this._handlePrimaryUp); + return function() { + if (!this.enabled) { + return; + } - this.data.playerRig.removeEventListener("model-loaded", this._handleModelLoaded); + if (this.data.useMousePos) { + this.setRaycasterWithMousePos(); + } else { + const rayObject = this.data.rayObject.object3D; + rayObjectRotation.setFromRotationMatrix(rayObject.matrixWorld); + this.direction + .set(0, 0, 1) + .applyQuaternion(rayObjectRotation) + .normalize(); + this.origin.setFromMatrixPosition(rayObject.matrixWorld); + this.updateRay(); + } - this.el.sceneEl.removeEventListener("controllerconnected", this._handleControllerConnected); - this.el.sceneEl.removeEventListener("controllerdisconnected", this._handleControllerDisconnected); - }, + const isGrabbing = this.data.cursor.components["super-hands"].state.has("grab-start"); + if (isGrabbing) { + const distance = Math.min( + this.data.maxDistance, + Math.max(this.data.minDistance, this.currentDistance - this.currentDistanceMod) + ); + this.direction.multiplyScalar(distance); + this.data.cursor.object3D.position.addVectors(this.origin, this.direction); + } else { + this.currentDistanceMod = 0; + this.updateDistanceAndTargetType(); + + const isTarget = this._isTargetOfType(TARGET_TYPE_INTERACTABLE_OR_UI); + if (isTarget && !this.wasCursorHovered) { + this.wasCursorHovered = true; + this.data.cursor.setAttribute("material", { color: this.data.cursorColorHovered }); + } else if (!isTarget && this.wasCursorHovered) { + this.wasCursorHovered = false; + this.data.cursor.setAttribute("material", { color: this.data.cursorColorUnhovered }); + } + } - tick: function() { - //handle physical hand - if (this.physicalHand) { - const state = this.physicalHand.components["super-hands"].state; - const isPhysicalHandGrabbing = state.has("grab-start") || state.has("hover-start"); - if (this.wasPhysicalHandGrabbing != isPhysicalHandGrabbing) { - this._setCursorVisibility(!isPhysicalHandGrabbing); - this.currentTargetType = TARGET_TYPE_NONE; + if (this.data.drawLine) { + this.el.setAttribute("line", { start: this.origin.clone(), end: this.data.cursor.object3D.position.clone() }); } - this.wasPhysicalHandGrabbing = isPhysicalHandGrabbing; - if (isPhysicalHandGrabbing) return; - } + }; + })(), - //set raycaster origin/direction + setRaycasterWithMousePos: function() { const camera = this.data.camera.components.camera.camera; - if (!this.inVR) { - //mouse cursor mode - const raycaster = this.el.components.raycaster.raycaster; - raycaster.setFromCamera(this.mousePos, camera); - this.origin.copy(raycaster.ray.origin); - this.direction.copy(raycaster.ray.direction); - } else if ((this.inVR || this.isMobile) && !this.hasPointingDevice) { - //gaze cursor mode - camera.updateMatrixWorld(true); - this.origin.setFromMatrixPosition(camera.matrixWorld); - this.controllerQuaternion.setFromRotationMatrix(camera.matrixWorld); - this.direction - .set(0, 0, 1) - .applyQuaternion(this.controllerQuaternion) - .normalize(); - } else if (this.controller != null) { - //3d cursor mode - this.controller.object3D.updateMatrixWorld(true); - this.origin.setFromMatrixPosition(this.controller.object3D.matrixWorld); - this.controllerQuaternion.setFromRotationMatrix(this.controller.object3D.matrixWorld); - this.direction - .set(0, 0, -1) - .applyQuaternion(this.controllerQuaternion) - .normalize(); - } - - this.el.setAttribute("raycaster", { origin: this.origin, direction: this.direction }); + const raycaster = this.el.components.raycaster.raycaster; + raycaster.setFromCamera(this.mousePos, camera); + this.origin.copy(raycaster.ray.origin); + this.direction.copy(raycaster.ray.direction); + this.updateRay(); + }, + updateDistanceAndTargetType: function() { let intersection = null; - - //update cursor position - if (!this._isGrabbing()) { - const intersections = this.el.components.raycaster.intersections; - if (intersections.length > 0 && intersections[0].distance <= this.data.maxDistance) { - intersection = intersections[0]; - this.data.cursor.object3D.position.copy(intersection.point); - this.currentDistance = intersections[0].distance; - } else { - this.currentDistance = this.data.maxDistance; - } - this.currentDistanceMod = 0; - } - - if (this._isGrabbing() || !intersection) { - const max = Math.max(this.data.minDistance, this.currentDistance - this.currentDistanceMod); - const distance = Math.min(max, this.data.maxDistance); - this.currentDistanceMod = this.currentDistance - distance; - this.direction.multiplyScalar(distance); + const intersections = this.el.components.raycaster.intersections; + if (intersections.length > 0 && intersections[0].distance <= this.data.maxDistance) { + intersection = intersections[0]; + this.data.cursor.object3D.position.copy(intersection.point); + this.currentDistance = intersections[0].distance; + } else { + this.currentDistance = this.data.maxDistance; + this.direction.multiplyScalar(this.currentDistance); this.data.cursor.object3D.position.addVectors(this.origin, this.direction); } - //update currentTargetType - if (this._isGrabbing() && !intersection) { - this.currentTargetType = TARGET_TYPE_INTERACTABLE; - } else if (intersection) { - if (intersection.object.el.matches(".interactable, .interactable *")) { - this.currentTargetType = TARGET_TYPE_INTERACTABLE; - } else if (intersection.object.el.matches(".ui, .ui *")) { - this.currentTargetType = TARGET_TYPE_UI; - } - } else { + if (!intersection) { this.currentTargetType = TARGET_TYPE_NONE; - } - - //update cursor material - const isTarget = this._isTargetOfType(TARGET_TYPE_INTERACTABLE_OR_UI); - if ((this._isGrabbing() || isTarget) && !this.wasCursorHovered) { - this.wasCursorHovered = true; - this.data.cursor.setAttribute("material", { color: this.data.cursorColorHovered }); - } else if (!this._isGrabbing() && !isTarget && this.wasCursorHovered) { - this.wasCursorHovered = false; - this.data.cursor.setAttribute("material", { color: this.data.cursorColorUnhovered }); - } - - //update line - if (this.hasPointingDevice) { - this.el.setAttribute("line", { start: this.origin.clone(), end: this.data.cursor.object3D.position.clone() }); + } else if (intersection.object.el.matches(".interactable, .interactable *")) { + this.currentTargetType = TARGET_TYPE_INTERACTABLE; + } else if (intersection.object.el.matches(".ui, .ui *")) { + this.currentTargetType = TARGET_TYPE_UI; } }, - _isGrabbing() { - return this.data.cursor.components["super-hands"].state.has("grab-start"); - }, - _isTargetOfType: function(mask) { return (this.currentTargetType & mask) === this.currentTargetType; }, - _setCursorVisibility(visible) { + setCursorVisibility: function(visible) { this.data.cursor.setAttribute("visible", visible); - this.el.setAttribute("line", { visible: visible && this.hasPointingDevice }); - }, - - _setLookControlsEnabled(enabled) { - const lookControls = this.data.camera.components["look-controls"]; - if (lookControls) { - if (enabled) { - lookControls.play(); - } else { - lookControls.pause(); - } - } - }, - - _startTeleport: function() { - if (this.controller != null) { - this.controller.emit("cursor-teleport_down", {}); - } else if (this.inVR) { - this.data.gazeTeleportControls.emit("cursor-teleport_down", {}); - } - this._setCursorVisibility(false); + this.el.setAttribute("line", { visible: visible && this.data.drawLine }); }, - _endTeleport: function() { - if (this.controller != null) { - this.controller.emit("cursor-teleport_up", {}); - } else if (this.inVR) { - this.data.gazeTeleportControls.emit("cursor-teleport_up", {}); - } - this._setCursorVisibility(true); + forceCursorUpdate: function() { + this.setRaycasterWithMousePos(); + this.el.components.raycaster.checkIntersections(); + this.updateDistanceAndTargetType(); + this.data.cursor.components["static-body"].syncToPhysics(); }, - _handleTouchStart: function(e) { - if (!this.isMobile || this.hasPointingDevice || this.activeTouch) return; - - for (let i = e.touches.length - 1; i >= 0; i--) { - const touch = e.touches[i]; - if (touch.clientY / window.innerHeight < virtualJoystickCutoff) { - this.activeTouch = touch; - break; - } - } - if (!this.activeTouch) return; - - // Update the ray and cursor positions - const raycasterComp = this.el.components.raycaster; - const raycaster = raycasterComp.raycaster; - const camera = this.data.camera.components.camera.camera; - const cursor = this.data.cursor; - this.mousePos.set( - this.activeTouch.clientX / window.innerWidth * 2 - 1, - -(this.activeTouch.clientY / window.innerHeight) * 2 + 1 - ); - raycaster.setFromCamera(this.mousePos, camera); - this.el.setAttribute("raycaster", { origin: raycaster.ray.origin, direction: raycaster.ray.direction }); - raycasterComp.checkIntersections(); - const intersections = raycasterComp.intersections; - if (intersections.length === 0 || intersections[0].distance >= this.data.maxDistance) { - this.activeTouch = null; - return; - } - cursor.object3D.position.copy(intersections[0].point); - // Cursor position must be synced to physics before constraint is created - cursor.components["static-body"].syncToPhysics(); - cursor.emit("cursor-grab", {}); - }, - - _handleTouchMove: function(e) { - if (!this.isMobile || this.hasPointingDevice) return; - - for (let i = 0; i < e.touches.length; i++) { - const touch = e.touches[i]; - if ( - (!this.activeTouch && touch.clientY / window.innerHeight < virtualJoystickCutoff) || - (this.activeTouch && touch.identifier === this.activeTouch.identifier) - ) { - this.mousePos.set(touch.clientX / window.innerWidth * 2 - 1, -(touch.clientY / window.innerHeight) * 2 + 1); - return; - } - } - }, - - _handleTouchEnd: function(e) { - if ( - !this.isMobile || - this.hasPointingDevice || - !this.activeTouch || - Array.prototype.some.call(e.touches, touch => touch.identifier === this.activeTouch.identifier) - ) { - return; - } - - this.data.cursor.emit("cursor-release", {}); - this.activeTouch = null; - }, - - _handleMouseDown: function() { - if (this.isMobile && !this.inVR && !this.hasPointingDevice) return; - + startInteraction: function() { if (this._isTargetOfType(TARGET_TYPE_INTERACTABLE_OR_UI)) { - this._setLookControlsEnabled(false); this.data.cursor.emit("cursor-grab", {}); - } else if (this.inVR || this.isMobile) { - this._startTeleport(); + return true; } + return false; }, - _handleMouseMove: function(e) { - if (this.isMobile && !this.inVR && !this.hasPointingDevice) return; - - this.mousePos.set(e.clientX / window.innerWidth * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1); + moveCursor: function(x, y) { + this.mousePos.set(x, y); }, - _handleMouseUp: function() { - if (this.isMobile && !this.inVR && !this.hasPointingDevice) return; - - this._setLookControlsEnabled(true); + endInteraction: function() { this.data.cursor.emit("cursor-release", {}); - this._endTeleport(); - }, - - _handleWheel: function(e) { - if (this._isGrabbing()) { - switch (e.deltaMode) { - case e.DOM_DELTA_PIXEL: - this.currentDistanceMod += e.deltaY / 500; - break; - case e.DOM_DELTA_LINE: - this.currentDistanceMod += e.deltaY / 10; - break; - case e.DOM_DELTA_PAGE: - this.currentDistanceMod += e.deltaY / 2; - break; - } - } - }, - - _handleEnterVR: function() { - this.inVR = true; - this._updateController(); - }, - - _handleExitVR: function() { - this.inVR = false; - this._updateController(); }, - _handlePrimaryDown: function(e) { - if (e.target === this.controller || e.target === this.data.playerRig) { - const isInteractable = this._isTargetOfType(TARGET_TYPE_INTERACTABLE) && !this.grabStarting; - if (isInteractable || this._isTargetOfType(TARGET_TYPE_UI)) { - this.grabStarting = true; - this.data.cursor.emit("cursor-grab", e.detail); - } else if (e.type !== this.data.grabEvent) { - this._startTeleport(); - } - } - }, - - _handlePrimaryUp: function(e) { - if (e.target === this.controller || e.target === this.data.playerRig) { - this.grabStarting = false; - if (this._isGrabbing() || this._isTargetOfType(TARGET_TYPE_UI)) { - this.data.cursor.emit("cursor-release", e.detail); - } else if (e.type !== this.data.releaseEvent) { - this._endTeleport(); - } + changeDistanceMod: function(delta) { + const { minDistance, maxDistance } = this.data; + const targetDistanceMod = this.currentDistanceMod + delta; + const moddedDistance = this.currentDistance - targetDistanceMod; + if (moddedDistance > maxDistance || moddedDistance < minDistance) { + return; } - }, - - _handleModelLoaded: function() { - this.physicalHand = this.data.playerRig.querySelector(this.data.physicalHandSelector); + this.currentDistanceMod = targetDistanceMod; }, _handleCursorLoaded: function() { this.data.cursor.object3DMap.mesh.renderOrder = window.APP.RENDER_ORDER.CURSOR; + this.data.cursor.removeEventListener("loaded", this._handleCursorLoaded); }, - _handleControllerConnected: function(e) { - const data = { - controller: e.target, - handedness: e.detail.component.data.hand - }; - - if (data.handedness === this.data.handedness) { - this.controllerQueue.unshift(data); - } else { - this.controllerQueue.push(data); - } - - this._updateController(); - }, - - _handleControllerDisconnected: function(e) { - for (let i = 0; i < this.controllerQueue.length; i++) { - if (e.target === this.controllerQueue[i].controller) { - this.controllerQueue.splice(i, 1); - this._updateController(); - return; - } - } - }, - - _updateController: function() { - this.hasPointingDevice = this.controllerQueue.length > 0 && this.inVR; - - this._setCursorVisibility(this.hasPointingDevice || this.isMobile); - - if (this.hasPointingDevice) { - const controllerData = this.controllerQueue[0]; - const hand = controllerData.handedness; - this.el.setAttribute("cursor-controller", { physicalHandSelector: `#player-${hand}-controller` }); - this.controller = controllerData.controller; - } else { - this.controller = null; - } + remove: function() { + this.data.cursor.removeEventListener("loaded", this._handleCursorLoaded); } }); diff --git a/src/components/input-configurator.js b/src/components/input-configurator.js new file mode 100644 index 0000000000000000000000000000000000000000..1609b20c7b163504d3dcc46390767bf14c91a09e --- /dev/null +++ b/src/components/input-configurator.js @@ -0,0 +1,169 @@ +import TouchEventsHandler from "../utils/touch-events-handler.js"; +import MouseEventsHandler from "../utils/mouse-events-handler.js"; +import GearVRMouseEventsHandler from "../utils/gearvr-mouse-events-handler.js"; +import ActionEventHandler from "../utils/action-event-handler.js"; + +AFRAME.registerComponent("input-configurator", { + schema: { + cursorController: { type: "selector" }, + gazeTeleporter: { type: "selector" }, + camera: { type: "selector" }, + playerRig: { type: "selector" }, + leftController: { type: "selector" }, + rightController: { type: "selector" }, + leftControllerRayObject: { type: "string" }, + rightControllerRayObject: { type: "string" }, + gazeCursorRayObject: { type: "string" } + }, + + init() { + this.inVR = this.el.sceneEl.is("vr-mode"); + this.isMobile = AFRAME.utils.device.isMobile(); + this.eventHandlers = []; + this.controllerQueue = []; + this.hasPointingDevice = false; + this.cursor = this.data.cursorController.components["cursor-controller"]; + this.gazeTeleporter = this.data.gazeTeleporter.components["teleport-controls"]; + this.cameraController = this.data.camera.components["pitch-yaw-rotator"]; + this.playerRig = this.data.playerRig; + this.handedness = "right"; + + this.onEnterVR = this.onEnterVR.bind(this); + this.onExitVR = this.onExitVR.bind(this); + this.handleControllerConnected = this.handleControllerConnected.bind(this); + this.handleControllerDisconnected = this.handleControllerDisconnected.bind(this); + + this.configureInput(); + }, + + play() { + this.el.sceneEl.addEventListener("controllerconnected", this.handleControllerConnected); + this.el.sceneEl.addEventListener("controllerdisconnected", this.handleControllerDisconnected); + this.el.sceneEl.addEventListener("enter-vr", this.onEnterVR); + this.el.sceneEl.addEventListener("exit-vr", this.onExitVR); + }, + + pause() { + this.el.sceneEl.removeEventListener("controllerconnected", this.handleControllerConnected); + this.el.sceneEl.removeEventListener("controllerdisconnected", this.handleControllerDisconnected); + this.el.sceneEl.removeEventListener("enter-vr", this.onEnterVR); + this.el.sceneEl.removeEventListener("exit-vr", this.onExitVR); + }, + + onEnterVR() { + this.inVR = true; + this.tearDown(); + this.configureInput(); + this.updateController(); + }, + + onExitVR() { + this.inVR = false; + this.tearDown(); + this.configureInput(); + this.updateController(); + }, + + tearDown() { + this.eventHandlers.forEach(h => h.tearDown()); + this.eventHandlers = []; + this.actionEventHandler = null; + if (this.lookOnMobile) { + this.lookOnMobile.el.removeComponent("look-on-mobile"); + this.lookOnMobile = null; + } + this.cursorRequiresManagement = false; + }, + + addLookOnMobile() { + const onAdded = e => { + if (e.detail.name !== "look-on-mobile") return; + this.lookOnMobile = this.el.sceneEl.components["look-on-mobile"]; + }; + this.el.sceneEl.addEventListener("componentinitialized", onAdded); + // This adds look-on-mobile to the scene + this.el.sceneEl.setAttribute("look-on-mobile", "camera", this.data.camera); + }, + + configureInput() { + this.actionEventHandler = new ActionEventHandler(this.el.sceneEl, this.cursor); + this.eventHandlers.push(this.actionEventHandler); + + this.cursor.el.setAttribute("cursor-controller", "useMousePos", !this.inVR); + + if (this.inVR) { + this.cameraController.pause(); + this.cursorRequiresManagement = true; + this.cursor.el.setAttribute("cursor-controller", "minDistance", 0); + if (this.isMobile) { + this.eventHandlers.push(new GearVRMouseEventsHandler(this.cursor, this.gazeTeleporter)); + } else { + this.eventHandlers.push(new MouseEventsHandler(this.cursor, this.cameraController)); + } + } else { + this.cameraController.play(); + if (this.isMobile) { + this.cursor.setCursorVisibility(false); + this.eventHandlers.push(new TouchEventsHandler(this.cursor, this.cameraController, this.cursor.el)); + this.addLookOnMobile(); + } else { + this.eventHandlers.push(new MouseEventsHandler(this.cursor, this.cameraController)); + this.cursor.el.setAttribute("cursor-controller", "minDistance", 0.3); + } + } + }, + + tick() { + if (this.cursorRequiresManagement && this.controller) { + this.actionEventHandler.manageCursorEnabled(); + } + }, + + handleControllerConnected: function(e) { + const data = { + controller: e.target, + handedness: e.detail.component.data.hand + }; + + if (data.handedness === this.handedness) { + this.controllerQueue.unshift(data); + } else { + this.controllerQueue.push(data); + } + + this.updateController(); + }, + + handleControllerDisconnected: function(e) { + for (let i = 0; i < this.controllerQueue.length; i++) { + if (e.target === this.controllerQueue[i].controller) { + this.controllerQueue.splice(i, 1); + this.updateController(); + return; + } + } + }, + + updateController: function() { + this.hasPointingDevice = this.controllerQueue.length > 0 && this.inVR; + this.cursor.el.setAttribute("cursor-controller", "drawLine", this.hasPointingDevice); + + this.cursor.setCursorVisibility(true); + + if (this.hasPointingDevice) { + const controllerData = this.controllerQueue[0]; + const hand = controllerData.handedness; + this.controller = controllerData.controller; + this.cursor.el.setAttribute("cursor-controller", { + rayObject: hand === "left" ? this.data.leftControllerRayObject : this.data.rightControllerRayObject + }); + } else { + this.controller = null; + this.cursor.el.setAttribute("cursor-controller", { rayObject: this.data.gazeCursorRayObject }); + } + + if (this.actionEventHandler) { + this.actionEventHandler.setHandThatAlsoDrivesCursor(this.controller); + } + } +}); diff --git a/src/components/look-on-mobile.js b/src/components/look-on-mobile.js new file mode 100644 index 0000000000000000000000000000000000000000..7cde5ba136933c7d658693f00e3a3ea3a3316307 --- /dev/null +++ b/src/components/look-on-mobile.js @@ -0,0 +1,103 @@ +const TWOPI = Math.PI * 2; + +class CircularBuffer { + constructor(length) { + this.items = new Array(length).fill(0); + this.writePtr = 0; + } + + push(item) { + this.items[this.writePtr] = item; + this.writePtr = (this.writePtr + 1) % this.items.length; + } +} + +const abs = Math.abs; +// Input: two numbers between [-Math.PI, Math.PI] +// Output: difference between them, where -Math.PI === Math.PI +const difference = (curr, prev) => { + const a = curr - prev; + const b = curr + TWOPI - prev; + const c = curr - (prev + TWOPI); + if (abs(a) < abs(b)) { + if (abs(a) < abs(c)) { + return a; + } + } + if (abs(b) < abs(c)) { + return b; + } + + return c; +}; + +const average = a => { + let sum = 0; + for (let i = 0; i < a.length; i++) { + const n = a[i]; + sum += n; + } + return sum / a.length; +}; + +AFRAME.registerComponent("look-on-mobile", { + schema: { + horizontalLookSpeedRatio: { default: 1.0 }, // motion applied to camera / motion of polyfill object + verticalLookSpeedRatio: { default: 1.0 }, // motion applied to camera / motion of polyfill object + camera: { type: "selector" } + }, + + init() { + this.hmdEuler = new THREE.Euler(); + this.hmdQuaternion = new THREE.Quaternion(); + this.prevX = this.hmdEuler.x; + this.prevY = this.hmdEuler.y; + this.pendingLookX = 0; + this.onRotateX = this.onRotateX.bind(this); + this.dXBuffer = new CircularBuffer(6); + this.dYBuffer = new CircularBuffer(6); + this.vrDisplay = window.webvrpolyfill.getPolyfillDisplays()[0]; + this.frameData = new window.webvrpolyfill.constructor.VRFrameData(); + }, + + play() { + this.el.addEventListener("rotateX", this.onRotateX); + }, + + pause() { + this.el.removeEventListener("rotateX", this.onRotateX); + }, + + update() { + this.cameraController = this.data.camera.components["pitch-yaw-rotator"]; + }, + + onRotateX(e) { + this.pendingLookX = e.detail.value; + }, + + tick() { + const hmdEuler = this.hmdEuler; + const { horizontalLookSpeedRatio, verticalLookSpeedRatio } = this.data; + this.vrDisplay.getFrameData(this.frameData); + if (this.frameData.pose.orientation !== null) { + this.hmdQuaternion.fromArray(this.frameData.pose.orientation); + hmdEuler.setFromQuaternion(this.hmdQuaternion, "YXZ"); + } + + const dX = THREE.Math.RAD2DEG * difference(hmdEuler.x, this.prevX); + const dY = THREE.Math.RAD2DEG * difference(hmdEuler.y, this.prevY); + + this.dXBuffer.push(Math.abs(dX) < 0.001 ? 0 : dX); + this.dYBuffer.push(Math.abs(dY) < 0.001 ? 0 : dY); + + const deltaYaw = average(this.dYBuffer.items) * horizontalLookSpeedRatio; + const deltaPitch = average(this.dXBuffer.items) * verticalLookSpeedRatio + this.pendingLookX; + + this.cameraController.look(deltaPitch, deltaYaw); + + this.prevX = hmdEuler.x; + this.prevY = hmdEuler.y; + this.pendingLookX = 0; + } +}); diff --git a/src/components/pinch-to-move.js b/src/components/pinch-to-move.js new file mode 100644 index 0000000000000000000000000000000000000000..79e51f0dab313dc36facf3a37ac2a326053ac0e7 --- /dev/null +++ b/src/components/pinch-to-move.js @@ -0,0 +1,36 @@ +AFRAME.registerComponent("pinch-to-move", { + schema: { + speed: { default: 0.25 } + }, + init() { + this.onPinch = this.onPinch.bind(this); + this.axis = [0, 0]; + this.pinch = 0; + this.prevPinch = 0; + this.needsMove = false; + }, + play() { + this.el.addEventListener("pinch", this.onPinch); + }, + pause() { + this.el.removeEventListener("pinch", this.onPinch); + }, + tick() { + if (this.needsMove) { + const diff = this.pinch - this.prevPinch; + this.axis[1] = diff * this.data.speed; + this.el.emit("move", { axis: this.axis }); + this.prevPinch = this.pinch; + } + this.needsMove = false; + }, + onPinch(e) { + const { isNewPinch, distance } = e.detail; + if (isNewPinch) { + this.prevPinch = distance; + return; + } + this.pinch = distance; + this.needsMove = true; + } +}); diff --git a/src/components/pitch-yaw-rotator.js b/src/components/pitch-yaw-rotator.js new file mode 100644 index 0000000000000000000000000000000000000000..7af5799e72077977f81abd5ddff3a4686c46fe1f --- /dev/null +++ b/src/components/pitch-yaw-rotator.js @@ -0,0 +1,24 @@ +const degToRad = THREE.Math.degToRad; +AFRAME.registerComponent("pitch-yaw-rotator", { + schema: { + minPitch: { default: -50 }, + maxPitch: { default: 50 } + }, + + init() { + this.pitch = 0; + this.yaw = 0; + }, + + look(deltaPitch, deltaYaw) { + const { minPitch, maxPitch } = this.data; + this.pitch += deltaPitch; + this.pitch = Math.max(minPitch, Math.min(maxPitch, this.pitch)); + this.yaw += deltaYaw; + }, + + tick() { + this.el.object3D.rotation.set(degToRad(this.pitch), degToRad(this.yaw), 0); + this.el.object3D.rotation.order = "YXZ"; + } +}); diff --git a/src/components/super-spawner.js b/src/components/super-spawner.js index 083f81db766a810876b563a77b8de18544daf912..08491ec0ea05f253c720b50e078c5895bd3f188f 100644 --- a/src/components/super-spawner.js +++ b/src/components/super-spawner.js @@ -10,7 +10,7 @@ AFRAME.registerComponent("super-spawner", { spawnPosition: { type: "vec3" }, useCustomSpawnRotation: { default: false }, spawnRotation: { type: "vec4" }, - events: { default: ["cursor-grab", "action_grab"] }, + events: { default: ["cursor-grab", "hand_grab"] }, spawnCooldown: { default: 1 } }, diff --git a/src/components/virtual-gamepad-controls.js b/src/components/virtual-gamepad-controls.js index 5da2c3c8ebd0c27530d5eb45b546b6dc0558bc2e..309b2210bfa34be8daed0df28d91d31b8770ca38 100644 --- a/src/components/virtual-gamepad-controls.js +++ b/src/components/virtual-gamepad-controls.js @@ -77,6 +77,9 @@ AFRAME.registerComponent("virtual-gamepad-controls", { this.rotateYEvent = { value: 0 }; + this.rotateXEvent = { + value: 0 + }; this.el.sceneEl.addEventListener("enter-vr", this.onEnterVr); this.el.sceneEl.addEventListener("exit-vr", this.onExitVr); @@ -91,8 +94,9 @@ AFRAME.registerComponent("virtual-gamepad-controls", { onMoveJoystickChanged(event, joystick) { const angle = joystick.angle.radian; const force = joystick.force < 1 ? joystick.force : 1; - const x = Math.cos(angle) * force; - const z = Math.sin(angle) * force; + const moveStrength = 0.85; + const x = Math.cos(angle) * force * moveStrength; + const z = Math.sin(angle) * force * moveStrength; this.moving = true; this.moveEvent.axis[0] = x; this.moveEvent.axis[1] = z; @@ -109,14 +113,18 @@ AFRAME.registerComponent("virtual-gamepad-controls", { // Set pitch and yaw angles on right stick move const angle = joystick.angle.radian; const force = joystick.force < 1 ? joystick.force : 1; + const turnStrength = 0.5; this.rotating = true; - this.rotateYEvent.value = Math.cos(angle) * force; + this.rotateYEvent.value = Math.cos(angle) * force * turnStrength; + this.rotateXEvent.value = Math.sin(angle) * force * turnStrength; }, onLookJoystickEnd() { this.rotating = false; this.rotateYEvent.value = 0; + this.rotateXEvent.value = 0; this.el.sceneEl.emit("rotateY", this.rotateYEvent); + this.el.sceneEl.emit("rotateX", this.rotateXEvent); }, tick() { @@ -127,6 +135,7 @@ AFRAME.registerComponent("virtual-gamepad-controls", { if (this.rotating) { this.el.sceneEl.emit("rotateY", this.rotateYEvent); + this.el.sceneEl.emit("rotateX", this.rotateXEvent); } } }, diff --git a/src/hub.html b/src/hub.html index a7f615b375d4bd348c84968571c3c1b7a387fbe9..d54fac0939d964a1f6fe9ceca816eebb4ba8b7cc 100644 --- a/src/hub.html +++ b/src/hub.html @@ -13,12 +13,11 @@ <link href="https://fonts.googleapis.com/css?family=Zilla+Slab:300,300i,400,400i,700" rel="stylesheet"> <% if(NODE_ENV === "production") { %> - <script src="https://cdn.rawgit.com/aframevr/aframe/3e7a4b3/dist/aframe-master.min.js" integrity="sha384-LQXa4VjhYucs9sVd5yQ3OhBXRea0jrvbHJA8CYLgTnvzxF5uvyhabSo1mX4tT2c6" crossorigin="anonymous"></script> + <script src="https://cdn.rawgit.com/aframevr/aframe/1be48d9/dist/aframe-master.min.js" integrity="sha384-SXrfoMHbXpA5RZhIyhgaR6tQ764dDZsbFk3PiokC/tc0+NnW1yaYQMUzWtL06hnq" crossorigin="anonymous"></script> <% } else { %> - <script src="https://cdn.rawgit.com/aframevr/aframe/3e7a4b3/dist/aframe-master.js" integrity="sha384-EaMOuyBOi9ERV/lVDwQgz/yFWBDWPsIju5Co6oCZZHXuvbLBO81yPWn80q0BbBn3" crossorigin="anonymous"></script> + <script src="https://cdn.rawgit.com/aframevr/aframe/1be48d9/dist/aframe-master.js" integrity="sha384-AmjDGOMbvTrrUFdeVWcBIlXRINIWnO8iwj/4VS21OWbYDsa/7nheOIyPAPJSkR6J" crossorigin="anonymous"></script> <% } %> - <!-- HACK: this has to run after A-Frame but before our bundle, since A-Frame blows away the local storage setting --> <script src="https://cdn.rawgit.com/gfodor/ba8f88d9f34fe9cbe59a01ce3c48420d/raw/03e31f0ef7b9eac5e947bd39e440f34df0701f75/naf-janus-adapter-logging.js" integrity="sha384-4q1V8Q88oeCFriFefFo5uEUtMzbw6K116tFyC9cwbiPr6wEe7050l5HoJUxMvnzj" crossorigin="anonymous"></script> </head> @@ -41,6 +40,17 @@ freeze-controller="toggleEvent: action_freeze" personal-space-bubble="debug: false;" vr-mode-ui="enabled: false" + pinch-to-move + input-configurator=" + gazeCursorRayObject: #player-camera-reverse-z; + cursorController: #cursor-controller; + gazeTeleporter: #gaze-teleport; + camera: #player-camera; + playerRig: #player-rig; + leftController: #player-left-controller; + leftControllerRayObject: #player-left-controller-reverse-z; + rightController: #player-right-controller; + rightControllerRayObject: #player-right-controller-reverse-z;" > <a-assets> @@ -182,13 +192,13 @@ ></a-entity> </template> - <a-mixin id="super-hands" + <a-mixin id="controller-super-hands" super-hands=" colliderEvent: collisions; colliderEventProperty: els; colliderEndEvent: collisions; colliderEndEventProperty: clearedEls; - grabStartButtons: action_grab; grabEndButtons: action_release; - stretchStartButtons: action_grab; stretchEndButtons: action_release; - dragDropStartButtons: action_grab; dragDropEndButtons: action_release;" + grabStartButtons: hand_grab; grabEndButtons: hand_release; + stretchStartButtons: hand_grab; stretchEndButtons: hand_release; + dragDropStartButtons: hand_grab; dragDropEndButtons: hand_release;" collision-filter="collisionForces: false" physics-collider ></a-mixin> @@ -201,10 +211,7 @@ id="cursor-controller" cursor-controller=" cursor: #cursor; - camera: #player-camera; - playerRig: #player-rig; - physicalHandSelector: #player-right-controller; - gazeTeleportControls: #gaze-teleport;" + camera: #player-camera; " raycaster="objects: .collidable, .interactable, .ui; far: 3;" line="visible: false; color: white; opacity: 0.2;" ></a-entity> @@ -261,6 +268,7 @@ camera position="0 1.6 0" personal-space-bubble="radius: 0.4" + pitch-yaw-rotator > <a-entity id="gaze-teleport" @@ -268,13 +276,14 @@ teleport-controls=" cameraRig: #player-rig; teleportOrigin: #player-camera; - button: cursor-teleport_; + button: gaze-teleport_; collisionEntities: [nav-mesh]; drawIncrementally: true; incrementalDrawMs: 600; hitOpacity: 0.3; missOpacity: 0.2;" ></a-entity> + <a-entity id="player-camera-reverse-z" rotation="0 180 0"></a-entity> </a-entity> <a-entity @@ -293,9 +302,10 @@ missOpacity: 0.2;" haptic-feedback body="type: static; shape: none;" - mixin="super-hands" + mixin="controller-super-hands" controls-shape-offset > + <a-entity id="player-left-controller-reverse-z" rotation="0 180 0"></a-entity> </a-entity> <a-entity @@ -314,9 +324,11 @@ missOpacity: 0.2;" haptic-feedback body="type: static; shape: none;" - mixin="super-hands" + mixin="controller-super-hands" controls-shape-offset - ></a-entity> + > + <a-entity id="player-right-controller-reverse-z" rotation="0 180 0"></a-entity> + </a-entity> <a-entity gltf-model-plus="inflate: true;" class="model"> diff --git a/src/hub.js b/src/hub.js index a9f63cc3c7f72d02f38ca2fd7b424a1759a14508..aeb047d7a74e4325aebf1d113d4c2df866923432 100644 --- a/src/hub.js +++ b/src/hub.js @@ -17,10 +17,10 @@ import "aframe-rounded"; import "webrtc-adapter"; import "aframe-slice9-component"; import "aframe-motion-capture-components"; - import "./utils/audio-context-fix"; import trackpad_dpad4 from "./behaviours/trackpad-dpad4"; +import trackpad_scrolling from "./behaviours/trackpad-scrolling"; import joystick_dpad4 from "./behaviours/joystick-dpad4"; import msft_mr_axis_with_deadzone from "./behaviours/msft-mr-axis-with-deadzone"; import { PressedMove } from "./activators/pressedmove"; @@ -63,6 +63,10 @@ import "./components/networked-avatar"; import "./components/css-class"; import "./components/scene-shadow"; import "./components/avatar-replay"; +import "./components/pinch-to-move"; +import "./components/look-on-mobile"; +import "./components/pitch-yaw-rotator"; +import "./components/input-configurator"; import ReactDOM from "react-dom"; import React from "react"; @@ -140,6 +144,7 @@ if (!isBotMode && !isTelemetryDisabled) { disableiOSZoom(); AFRAME.registerInputBehaviour("trackpad_dpad4", trackpad_dpad4); +AFRAME.registerInputBehaviour("trackpad_scrolling", trackpad_scrolling); AFRAME.registerInputBehaviour("joystick_dpad4", joystick_dpad4); AFRAME.registerInputBehaviour("msft_mr_axis_with_deadzone", msft_mr_axis_with_deadzone); AFRAME.registerInputActivator("pressedmove", PressedMove); @@ -217,7 +222,7 @@ const onReady = async () => { const scene = document.querySelector("a-scene"); if (scene) { if (scene.renderer) { - scene.renderer.animate(null); // Stop animation loop, TODO A-Frame should do this + scene.renderer.setAnimationLoop(null); // Stop animation loop, TODO A-Frame should do this } document.body.removeChild(scene); } @@ -225,6 +230,7 @@ const onReady = async () => { const enterScene = async (mediaStream, enterInVR, hubId) => { const scene = document.querySelector("a-scene"); + scene.classList.add("no-cursor"); scene.renderer.sortObjects = true; const playerRig = document.querySelector("#player-rig"); document.querySelector("canvas").classList.remove("blurred"); @@ -236,8 +242,6 @@ const onReady = async () => { AFRAME.registerInputActions(inGameActions, "default"); - document.querySelector("#player-camera").setAttribute("look-controls", ""); - scene.setAttribute("networked-scene", { room: hubId, serverURL: process.env.JANUS_SERVER @@ -399,11 +403,11 @@ const onReady = async () => { if (!isBotMode) { // Stop rendering while the UI is up. We restart the render loop in enterScene. // Wait a tick plus some margin so that the environments actually render. - setTimeout(() => scene.renderer.animate(null), 100); + setTimeout(() => scene.renderer.setAnimationLoop(null), 100); } else { const noop = () => {}; // Replace renderer with a noop renderer to reduce bot resource usage. - scene.renderer = { animate: noop, render: noop }; + scene.renderer = { setAnimationLoop: noop, render: noop }; document.body.style.display = "none"; } }); diff --git a/src/input-mappings.js b/src/input-mappings.js index a1c4c06a2d7cf1f8442b81fee5cfa25821af90b5..c6ce52501b479fa5749437db3da0882659129cb5 100644 --- a/src/input-mappings.js +++ b/src/input-mappings.js @@ -24,20 +24,24 @@ const config = { joystick: "joystick_dpad4" }, "vive-controls": { - trackpad: "trackpad_dpad4" + trackpad: "trackpad_dpad4", + trackpad_scrolling: "trackpad_scrolling" }, "windows-motion-controls": { joystick: "joystick_dpad4", axisMoveWithDeadzone: "msft_mr_axis_with_deadzone" }, "daydream-controls": { - trackpad: "trackpad_dpad4" + trackpad: "trackpad_dpad4", + axisMoveWithDeadzone: "msft_mr_axis_with_deadzone" }, "gearvr-controls": { - trackpad: "trackpad_dpad4" + trackpad: "trackpad_dpad4", + trackpad_scrolling: "trackpad_scrolling" }, "oculus-go-controls": { - trackpad: "trackpad_dpad4" + trackpad: "trackpad_dpad4", + trackpad_scrolling: "trackpad_scrolling" } } }, @@ -58,7 +62,8 @@ const config = { trackpadtouchstart: "thumb_down", trackpadtouchend: "thumb_up", triggerdown: ["action_grab", "index_down"], - triggerup: ["action_release", "index_up"] + triggerup: ["action_release", "index_up"], + scroll: { right: "move_duck" } }, "oculus-touch-controls": { joystick_dpad4_west: { @@ -83,7 +88,7 @@ const config = { thumbsticktouchend: "thumb_up", triggerdown: ["action_grab", "index_down"], triggerup: ["action_release", "index_up"], - "axismove.reverseY": { left: "move" }, + "axismove.reverseY": { left: "move", right: "move_duck" }, abuttondown: "action_primary_down", abuttonup: "action_primary_up" }, @@ -107,7 +112,7 @@ const config = { trackpadtouchend: "thumb_up", triggerdown: ["action_grab", "index_down"], triggerup: ["action_release", "index_up"], - axisMoveWithDeadzone: { left: "move" } + axisMoveWithDeadzone: { left: "move", right: "move_duck" } }, "daydream-controls": { trackpad_dpad4_pressed_west_down: "snap_rotate_left", @@ -115,7 +120,8 @@ const config = { trackpad_dpad4_pressed_center_down: ["action_primary_down"], trackpad_dpad4_pressed_north_down: ["action_primary_down"], trackpad_dpad4_pressed_south_down: ["action_primary_down"], - trackpadup: ["action_primary_up"] + trackpadup: ["action_primary_up"], + axisMoveWithDeadzone: "move_duck" }, "gearvr-controls": { trackpad_dpad4_pressed_west_down: "snap_rotate_left", @@ -125,7 +131,8 @@ const config = { trackpad_dpad4_pressed_south_down: ["action_primary_down"], trackpadup: ["action_primary_up"], triggerdown: ["action_primary_down"], - triggerup: ["action_primary_up"] + triggerup: ["action_primary_up"], + scroll: "move_duck" }, "oculus-go-controls": { trackpad_dpad4_pressed_west_down: "snap_rotate_left", @@ -135,7 +142,8 @@ const config = { trackpad_dpad4_pressed_south_down: ["action_primary_down"], trackpadup: ["action_primary_up"], triggerdown: ["action_primary_down"], - triggerup: ["action_primary_up"] + triggerup: ["action_primary_up"], + scroll: "move_duck" }, keyboard: { m_press: "action_mute", diff --git a/src/network-schemas.js b/src/network-schemas.js index 9e20a17ca68bf2d669fbeb68d78f665c94de2ed9..04275d491d43f9221626cd1060614e0f5c7a2d93 100644 --- a/src/network-schemas.js +++ b/src/network-schemas.js @@ -16,7 +16,6 @@ function registerNetworkSchemas() { }, { component: "rotation", - lerp: false, requiresNetworkUpdate: rotationRequiresUpdate }, "scale", diff --git a/src/react-components/entry-buttons.js b/src/react-components/entry-buttons.js index 9e8302b3c7bbf9bd44edbaf6538100f11abf3fb8..f2551df70f99813c5e74be91e7e627de3374feab 100644 --- a/src/react-components/entry-buttons.js +++ b/src/react-components/entry-buttons.js @@ -87,6 +87,17 @@ export const DaydreamEntryButton = props => { return <EntryButton {...entryButtonProps} />; }; +export const SafariEntryButton = props => { + const entryButtonProps = { + ...props, + iconSrc: MobileScreenEntryImg, + prefixMessageId: "entry.screen-prefix", + mediumMessageId: "entry.mobile-safari" + }; + + return <EntryButton {...entryButtonProps} />; +}; + export const DeviceEntryButton = props => { const entryButtonProps = { ...props, diff --git a/src/react-components/info-dialog.js b/src/react-components/info-dialog.js index 7a48099b400203abcadced08ac5c5df6cb41552d..a04127ff74ff2b4722d0a2fbe1a99dd07568efc0 100644 --- a/src/react-components/info-dialog.js +++ b/src/react-components/info-dialog.js @@ -13,6 +13,7 @@ class InfoDialog extends Component { slack: Symbol("slack"), email_submitted: Symbol("email_submitted"), invite: Symbol("invite"), + safari: Symbol("safari"), updates: Symbol("updates"), report: Symbol("report"), help: Symbol("help"), @@ -62,8 +63,8 @@ class InfoDialog extends Component { }); }; - copyLinkClicked = () => { - copy(this.shareLink); + copyLinkClicked = link => { + copy(link); this.setState({ copyLinkButtonText: "Copied!" }); }; @@ -147,7 +148,35 @@ class InfoDialog extends Component { <span>Share</span> </button> )} - <button className="invite-form__action-button" onClick={this.copyLinkClicked}> + <button + className="invite-form__action-button" + onClick={this.copyLinkClicked.bind(this, this.shareLink)} + > + <span>{this.state.copyLinkButtonText}</span> + </button> + </div> + </div> + </div> + ); + break; + case InfoDialog.dialogTypes.safari: + dialogTitle = "Open in Safari"; + dialogBody = ( + <div> + <div>Hubs does not support your current browser on iOS. Copy and paste this link directly in Safari.</div> + <div className="invite-form"> + <input + type="text" + readOnly + onFocus={e => e.target.select()} + value={document.location} + className="invite-form__link_field" + /> + <div className="invite-form__buttons"> + <button + className="invite-form__action-button" + onClick={this.copyLinkClicked.bind(this, document.location)} + > <span>{this.state.copyLinkButtonText}</span> </button> </div> diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js index cb69c63436f8d9d72479fc10e9ce541d5fde2ceb..9b6c10ea2149fd02899fe217a2ba89b2c33d2187 100644 --- a/src/react-components/ui-root.js +++ b/src/react-components/ui-root.js @@ -11,7 +11,13 @@ import screenfull from "screenfull"; import { lang, messages } from "../utils/i18n"; import AutoExitWarning from "./auto-exit-warning"; -import { TwoDEntryButton, DeviceEntryButton, GenericEntryButton, DaydreamEntryButton } from "./entry-buttons.js"; +import { + TwoDEntryButton, + DeviceEntryButton, + GenericEntryButton, + DaydreamEntryButton, + SafariEntryButton +} from "./entry-buttons.js"; import { ProfileInfoHeader } from "./profile-info-header.js"; import ProfileEntryPanel from "./profile-entry-panel"; import InfoDialog from "./info-dialog.js"; @@ -159,11 +165,11 @@ class UIRoot extends Component { handleForcedVREntryType = () => { if (!this.props.forcedVREntryType) return; - if (this.props.forcedVREntryType === "daydream") { + if (this.props.forcedVREntryType.startsWith("daydream")) { this.enterDaydream(); - } else if (this.props.forcedVREntryType === "vr") { + } else if (this.props.forcedVREntryType.startsWith("vr")) { this.enterVR(); - } else if (this.props.forcedVREntryType === "2d") { + } else if (this.props.forcedVREntryType.startsWith("2d")) { this.enter2D(); } }; @@ -250,7 +256,7 @@ class UIRoot extends Component { if (hasGrantedMic) { await this.setMediaStreamToDefault(); - this.beginAudioSetup(); + this.beginOrSkipAudioSetup(); } else { this.setState({ entryStep: ENTRY_STEPS.mic_grant }); } @@ -260,6 +266,10 @@ class UIRoot extends Component { await this.performDirectEntryFlow(false); }; + linkSafari = async () => { + this.setState({ infoDialogType: InfoDialog.dialogTypes.safari }); + }; + enterVR = async () => { if (this.props.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.maybe) { await this.performDirectEntryFlow(true); @@ -411,10 +421,10 @@ class UIRoot extends Component { if (hasAudio) { this.setState({ entryStep: ENTRY_STEPS.mic_granted }); } else { - this.beginAudioSetup(); + this.beginOrSkipAudioSetup(); } } else { - this.beginAudioSetup(); + this.beginOrSkipAudioSetup(); } }; @@ -422,8 +432,12 @@ class UIRoot extends Component { this.setState({ showProfileEntry: false }); }; - beginAudioSetup = () => { - this.setState({ entryStep: ENTRY_STEPS.audio_setup }); + beginOrSkipAudioSetup = () => { + if (!this.props.forcedVREntryType || !this.props.forcedVREntryType.endsWith("_now")) { + this.setState({ entryStep: ENTRY_STEPS.audio_setup }); + } else { + setTimeout(this.onAudioReadyButton, 3000); // Need to wait otherwise input doesn't work :/ + } }; fetchMicDevices = () => { @@ -612,9 +626,12 @@ class UIRoot extends Component { this.state.entryStep === ENTRY_STEPS.start ? ( <div className="entry-panel"> <div className="entry-panel__button-container"> - {this.props.availableVREntryTypes.screen !== VR_DEVICE_AVAILABILITY.no && ( + {this.props.availableVREntryTypes.screen === VR_DEVICE_AVAILABILITY.yes && ( <TwoDEntryButton onClick={this.enter2D} /> )} + {this.props.availableVREntryTypes.safari === VR_DEVICE_AVAILABILITY.maybe && ( + <SafariEntryButton onClick={this.linkSafari} /> + )} {this.props.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.no && ( <GenericEntryButton onClick={this.enterVR} /> )} diff --git a/src/utils/action-event-handler.js b/src/utils/action-event-handler.js new file mode 100644 index 0000000000000000000000000000000000000000..6288c34e002af618390ab63185a47830067f39f1 --- /dev/null +++ b/src/utils/action-event-handler.js @@ -0,0 +1,170 @@ +export default class ActionEventHandler { + constructor(scene, cursor) { + this.scene = scene; + this.cursor = cursor; + this.isCursorInteracting = false; + this.isCursorInteractingOnGrab = false; + this.isTeleporting = false; + this.handThatAlsoDrivesCursor = null; + this.hovered = false; + + this.onPrimaryDown = this.onPrimaryDown.bind(this); + this.onPrimaryUp = this.onPrimaryUp.bind(this); + this.onGrab = this.onGrab.bind(this); + this.onRelease = this.onRelease.bind(this); + this.onCardboardButtonDown = this.onCardboardButtonDown.bind(this); + this.onCardboardButtonUp = this.onCardboardButtonUp.bind(this); + this.onMoveDuck = this.onMoveDuck.bind(this); + this.addEventListeners(); + } + + addEventListeners() { + this.scene.addEventListener("action_primary_down", this.onPrimaryDown); + this.scene.addEventListener("action_primary_up", this.onPrimaryUp); + this.scene.addEventListener("action_grab", this.onGrab); + this.scene.addEventListener("action_release", this.onRelease); + this.scene.addEventListener("move_duck", this.onMoveDuck); + this.scene.addEventListener("cardboardbuttondown", this.onCardboardButtonDown); // TODO: These should be actions + this.scene.addEventListener("cardboardbuttonup", this.onCardboardButtonUp); + } + + tearDown() { + this.scene.removeEventListener("action_primary_down", this.onPrimaryDown); + this.scene.removeEventListener("action_primary_up", this.onPrimaryUp); + this.scene.removeEventListener("action_grab", this.onGrab); + this.scene.removeEventListener("action_release", this.onRelease); + this.scene.removeEventListener("move_duck", this.onMoveDuck); + this.scene.removeEventListener("cardboardbuttondown", this.onCardboardButtonDown); + this.scene.removeEventListener("cardboardbuttonup", this.onCardboardButtonUp); + } + + onMoveDuck(e) { + this.cursor.changeDistanceMod(-e.detail.axis[1] / 8); + } + + setHandThatAlsoDrivesCursor(handThatAlsoDrivesCursor) { + this.handThatAlsoDrivesCursor = handThatAlsoDrivesCursor; + } + + onGrab(e) { + if (this.handThatAlsoDrivesCursor && this.handThatAlsoDrivesCursor === e.target) { + if (this.isCursorInteracting) { + return; + } else if (e.target.components["super-hands"].state.has("hover-start")) { + e.target.emit("hand_grab"); + return; + } else { + this.isCursorInteracting = this.cursor.startInteraction(); + if (this.isCursorInteracting) { + this.isCursorInteractingOnGrab = true; + } + return; + } + } else { + e.target.emit("hand_grab"); + return; + } + } + + onRelease(e) { + if ( + this.isCursorInteracting && + this.isCursorInteractingOnGrab && + this.handThatAlsoDrivesCursor && + this.handThatAlsoDrivesCursor === e.target + ) { + this.isCursorInteracting = false; + this.isCursorInteractingOnGrab = false; + this.cursor.endInteraction(); + } else { + e.target.emit("hand_release"); + } + } + + onPrimaryDown(e) { + if (this.isCursorInteractingOnGrab) return; + if (this.handThatAlsoDrivesCursor && this.handThatAlsoDrivesCursor === e.target) { + if (this.isCursorInteracting) { + return; + } else if (e.target.components["super-hands"].state.has("hover-start")) { + e.target.emit("hand_grab"); + return; + } else { + this.isCursorInteracting = this.cursor.startInteraction(); + if (this.isCursorInteracting) return; + } + } + + this.cursor.setCursorVisibility(false); + const button = e.target.components["teleport-controls"].data.button; + e.target.emit(button + "down"); + this.isTeleporting = true; + } + + onPrimaryUp(e) { + if (this.isCursorInteractingOnGrab) return; + const isCursorHand = this.handThatAlsoDrivesCursor && this.handThatAlsoDrivesCursor === e.target; + if (this.isCursorInteracting && isCursorHand) { + this.isCursorInteracting = false; + this.cursor.endInteraction(); + return; + } + + const state = e.target.components["super-hands"].state; + if (state.has("grab-start")) { + e.target.emit("hand_release"); + return; + } + + if (isCursorHand) { + this.cursor.setCursorVisibility(!state.has("hover-start")); + } + const button = e.target.components["teleport-controls"].data.button; + e.target.emit(button + "up"); + this.isTeleporting = false; + } + + onCardboardButtonDown(e) { + this.isCursorInteracting = this.cursor.startInteraction(); + if (this.isCursorInteracting) { + return; + } + + this.cursor.setCursorVisibility(false); + + const gazeTeleport = e.target.querySelector("#gaze-teleport"); + const button = gazeTeleport.components["teleport-controls"].data.button; + gazeTeleport.emit(button + "down"); + this.isTeleporting = true; + } + + onCardboardButtonUp(e) { + if (this.isCursorInteracting) { + this.isCursorInteracting = false; + this.cursor.endInteraction(); + return; + } + + this.cursor.setCursorVisibility(true); + + const gazeTeleport = e.target.querySelector("#gaze-teleport"); + const button = gazeTeleport.components["teleport-controls"].data.button; + gazeTeleport.emit(button + "up"); + this.isTeleporting = false; + } + + manageCursorEnabled() { + const handState = this.handThatAlsoDrivesCursor.components["super-hands"].state; + const handHoveredThisFrame = !this.hovered && handState.has("hover-start") && !this.isCursorInteracting; + const handStoppedHoveringThisFrame = + this.hovered === true && !handState.has("hover-start") && !handState.has("grab-start"); + if (handHoveredThisFrame) { + this.hovered = true; + this.cursor.disable(); + } else if (handStoppedHoveringThisFrame) { + this.hovered = false; + this.cursor.enable(); + this.cursor.setCursorVisibility(!this.isTeleporting); + } + } +} diff --git a/src/utils/gearvr-mouse-events-handler.js b/src/utils/gearvr-mouse-events-handler.js new file mode 100644 index 0000000000000000000000000000000000000000..e26495b96a53980f98c58c6351267927a7853fb4 --- /dev/null +++ b/src/utils/gearvr-mouse-events-handler.js @@ -0,0 +1,50 @@ +export default class GearVRMouseEventsHandler { + constructor(cursor, gazeTeleporter) { + this.cursor = cursor; + this.gazeTeleporter = gazeTeleporter; + this.isMouseDownHandledByCursor = false; + this.isMouseDownHandledByGazeTeleporter = false; + + this.onMouseDown = this.onMouseDown.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); + this.addEventListeners(); + } + + addEventListeners() { + document.addEventListener("mousedown", this.onMouseDown); + document.addEventListener("mouseup", this.onMouseUp); + } + + tearDown() { + document.removeEventListener("mousedown", this.onMouseDown); + document.removeEventListener("mouseup", this.onMouseUp); + } + + onMouseDown() { + this.isMouseDownHandledByCursor = this.cursor.startInteraction(); + if (this.isMouseDownHandledByCursor) { + return; + } + + this.cursor.setCursorVisibility(false); + + const button = this.gazeTeleporter.data.button; + this.gazeTeleporter.el.emit(button + "down"); + this.isMouseDownHandledByGazeTeleporter = true; + } + + onMouseUp() { + if (this.isMouseDownHandledByCursor) { + this.cursor.endInteraction(); + this.isMouseDownHandledByCursor = false; + } + + this.cursor.setCursorVisibility(true); + + if (this.isMouseDownHandledByGazeTeleporter) { + const button = this.gazeTeleporter.data.button; + this.gazeTeleporter.el.emit(button + "up"); + this.isMouseDownHandledByGazeTeleporter = false; + } + } +} diff --git a/src/utils/mouse-events-handler.js b/src/utils/mouse-events-handler.js new file mode 100644 index 0000000000000000000000000000000000000000..b5bdfc33ccd20dca44fe0e98850891895afffa87 --- /dev/null +++ b/src/utils/mouse-events-handler.js @@ -0,0 +1,110 @@ +// TODO: Make look speed adjustable by the user +const HORIZONTAL_LOOK_SPEED = 0.1; +const VERTICAL_LOOK_SPEED = 0.06; + +export default class MouseEventsHandler { + constructor(cursor, cameraController) { + this.cursor = cursor; + this.cameraController = cameraController; + this.isLeftButtonDown = false; + this.isLeftButtonHandledByCursor = false; + this.isPointerLocked = false; + + this.onMouseDown = this.onMouseDown.bind(this); + this.onMouseMove = this.onMouseMove.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); + this.onMouseWheel = this.onMouseWheel.bind(this); + + this.addEventListeners(); + } + + tearDown() { + document.removeEventListener("mousedown", this.onMouseDown); + document.removeEventListener("mousemove", this.onMouseMove); + document.removeEventListener("mouseup", this.onMouseUp); + document.removeEventListener("wheel", this.onMouseWheel); + document.removeEventListener("contextmenu", this.onContextMenu); + } + + setInverseMouseLook(invert) { + this.invertMouseLook = invert; + } + + addEventListeners() { + document.addEventListener("mousedown", this.onMouseDown); + document.addEventListener("mousemove", this.onMouseMove); + document.addEventListener("mouseup", this.onMouseUp); + document.addEventListener("wheel", this.onMouseWheel); + document.addEventListener("contextmenu", this.onContextMenu); + } + + onContextMenu(e) { + e.preventDefault(); + } + + onMouseDown(e) { + const isLeftButton = e.button === 0; + const isRightButton = e.button === 2; + if (isLeftButton) { + this.onLeftButtonDown(); + } else if (isRightButton) { + this.onRightButtonDown(); + } + } + + onLeftButtonDown() { + this.isLeftButtonDown = true; + this.isLeftButtonHandledByCursor = this.cursor.startInteraction(); + } + + onRightButtonDown() { + if (this.isPointerLocked) { + document.exitPointerLock(); + this.isPointerLocked = false; + } else { + document.body.requestPointerLock(); + this.isPointerLocked = true; + } + } + + onMouseWheel(e) { + switch (e.deltaMode) { + case e.DOM_DELTA_PIXEL: + this.cursor.changeDistanceMod(e.deltaY / 500); + break; + case e.DOM_DELTA_LINE: + this.cursor.changeDistanceMod(e.deltaY / 10); + break; + case e.DOM_DELTA_PAGE: + this.cursor.changeDistanceMod(e.deltaY / 2); + break; + } + } + + onMouseMove(e) { + const shouldLook = this.isPointerLocked || (this.isLeftButtonDown && !this.isLeftButtonHandledByCursor); + if (shouldLook) { + this.look(e); + } + + this.cursor.moveCursor(e.clientX / window.innerWidth * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1); + } + + onMouseUp(e) { + const isLeftButton = e.button === 0; + if (!isLeftButton) return; + + if (this.isLeftButtonHandledByCursor) { + this.cursor.endInteraction(); + } + this.isLeftButtonHandledByCursor = false; + this.isLeftButtonDown = false; + } + + look(e) { + const sign = this.invertMouseLook ? 1 : -1; + const deltaPitch = e.movementY * VERTICAL_LOOK_SPEED * sign; + const deltaYaw = e.movementX * HORIZONTAL_LOOK_SPEED * sign; + this.cameraController.look(deltaPitch, deltaYaw); + } +} diff --git a/src/utils/touch-events-handler.js b/src/utils/touch-events-handler.js new file mode 100644 index 0000000000000000000000000000000000000000..5b6341cfc57f85bc7520c578f3182c2fa83c11bf --- /dev/null +++ b/src/utils/touch-events-handler.js @@ -0,0 +1,165 @@ +const VIRTUAL_JOYSTICK_HEIGHT = 0.8; +const HORIZONTAL_LOOK_SPEED = 0.35; +const VERTICAL_LOOK_SPEED = 0.18; + +export default class TouchEventsHandler { + constructor(cursor, cameraController, pinchEmitter) { + this.cursor = cursor; + this.cameraController = cameraController; + this.pinchEmitter = pinchEmitter; + this.touches = []; + this.touchReservedForCursor = null; + this.touchesReservedForPinch = []; + this.touchReservedForLookControls = null; + this.needsPinch = false; + this.pinchTouchId1 = -1; + this.pinchTouchId2 = -1; + + this.handleTouchStart = this.handleTouchStart.bind(this); + this.singleTouchStart = this.singleTouchStart.bind(this); + this.handleTouchMove = this.handleTouchMove.bind(this); + this.singleTouchMove = this.singleTouchMove.bind(this); + this.handleTouchEnd = this.handleTouchEnd.bind(this); + this.singleTouchEnd = this.singleTouchEnd.bind(this); + + this.addEventListeners(); + } + + addEventListeners() { + document.addEventListener("touchstart", this.handleTouchStart); + document.addEventListener("touchmove", this.handleTouchMove); + document.addEventListener("touchend", this.handleTouchEnd); + document.addEventListener("touchcancel", this.handleTouchEnd); + } + + tearDown() { + document.removeEventListener("touchstart", this.handleTouchStart); + document.removeEventListener("touchmove", this.handleTouchMove); + document.removeEventListener("touchend", this.handleTouchEnd); + document.removeEventListener("touchcancel", this.handleTouchEnd); + } + + handleTouchStart(e) { + for (let i = 0; i < e.changedTouches.length; i++) { + this.singleTouchStart(e.changedTouches[i]); + } + } + + singleTouchStart(touch) { + if (touch.clientY / window.innerHeight >= VIRTUAL_JOYSTICK_HEIGHT) { + return; + } + if (!this.touchReservedForCursor) { + this.cursor.moveCursor(touch.clientX / window.innerWidth * 2 - 1, -(touch.clientY / window.innerHeight) * 2 + 1); + this.cursor.forceCursorUpdate(); + if (this.cursor.startInteraction()) { + this.touchReservedForCursor = touch; + } + } + this.touches.push(touch); + } + + handleTouchMove(e) { + for (let i = 0; i < e.touches.length; i++) { + this.singleTouchMove(e.touches[i]); + } + if (this.needsPinch) { + this.pinch(); + this.needsPinch = false; + } + } + + singleTouchMove(touch) { + if (this.touchReservedForCursor && touch.identifier === this.touchReservedForCursor.identifier) { + this.cursor.moveCursor(touch.clientX / window.innerWidth * 2 - 1, -(touch.clientY / window.innerHeight) * 2 + 1); + return; + } + if (touch.clientY / window.innerHeight >= VIRTUAL_JOYSTICK_HEIGHT) return; + if (!this.touches.some(t => touch.identifier === t.identifier)) { + return; + } + + let pinchIndex = this.touchesReservedForPinch.findIndex(t => touch.identifier === t.identifier); + if (pinchIndex !== -1) { + this.touchesReservedForPinch[pinchIndex] = touch; + } else if (this.touchesReservedForPinch.length < 2) { + this.touchesReservedForPinch.push(touch); + pinchIndex = this.touchesReservedForPinch.length - 1; + } + if (this.touchesReservedForPinch.length == 2 && pinchIndex !== -1) { + if (this.touchReservedForLookControls && touch.identifier === this.touchReservedForLookControls.identifier) { + this.touchReservedForLookControls = null; + } + this.needsPinch = true; + return; + } + + if (!this.touchReservedForLookControls) { + this.touchReservedForLookControls = touch; + } + if (touch.identifier === this.touchReservedForLookControls.identifier) { + if (!this.touchReservedForCursor) { + this.cursor.moveCursor( + touch.clientX / window.innerWidth * 2 - 1, + -(touch.clientY / window.innerHeight) * 2 + 1 + ); + } + this.look(this.touchReservedForLookControls, touch); + this.touchReservedForLookControls = touch; + return; + } + } + + pinch() { + const t1 = this.touchesReservedForPinch[0]; + const t2 = this.touchesReservedForPinch[1]; + const isNewPinch = t1.identifier !== this.pinchTouchId1 || t2.identifier !== this.pinchTouchId2; + const pinchDistance = TouchEventsHandler.distance(t1.clientX, t1.clientY, t2.clientX, t2.clientY); + this.pinchEmitter.emit("pinch", { isNewPinch: isNewPinch, distance: pinchDistance }); + this.pinchTouchId1 = t1.identifier; + this.pinchTouchId2 = t2.identifier; + } + + look(prevTouch, touch) { + const deltaPitch = (touch.clientY - prevTouch.clientY) * VERTICAL_LOOK_SPEED; + const deltaYaw = (touch.clientX - prevTouch.clientX) * HORIZONTAL_LOOK_SPEED; + this.cameraController.look(deltaPitch, deltaYaw); + } + + handleTouchEnd(e) { + for (let i = 0; i < e.changedTouches.length; i++) { + this.singleTouchEnd(e.changedTouches[i]); + } + } + + singleTouchEnd(touch) { + const touchIndex = this.touches.findIndex(t => touch.identifier === t.identifier); + if (touchIndex === -1) { + return; + } + this.touches.splice(touchIndex, 1); + + if (this.touchReservedForCursor && touch.identifier === this.touchReservedForCursor.identifier) { + this.cursor.endInteraction(touch); + this.touchReservedForCursor = null; + return; + } + + const pinchIndex = this.touchesReservedForPinch.findIndex(t => touch.identifier === t.identifier); + if (pinchIndex !== -1) { + this.touchesReservedForPinch.splice(pinchIndex, 1); + this.pinchTouchId1 = -1; + this.pinchTouchId2 = -1; + } + + if (this.touchReservedForLookControls && touch.identifier === this.touchReservedForLookControls.identifier) { + this.touchReservedForLookControls = null; + } + } + + static distance = (x1, y1, x2, y2) => { + const x = x1 - x2; + const y = y1 - y2; + return Math.sqrt(x * x + y * y); + }; +} diff --git a/src/utils/vr-caps-detect.js b/src/utils/vr-caps-detect.js index 224f1bb49ba60a0627808a73dc6c3139d7df8aac..8953f12a86f24e9848c08e3619d718a2b0eda615 100644 --- a/src/utils/vr-caps-detect.js +++ b/src/utils/vr-caps-detect.js @@ -57,8 +57,16 @@ export async function getAvailableVREntryTypes() { const isDaydreamCapableBrowser = !!(isWebVRCapableBrowser && browser.name === "chrome" && !isSamsungBrowser); const isIDevice = ["iPhone", "iPad", "iPod"].indexOf(deviceDetect.device) > -1; const isFirefoxBrowser = browser.name === "firefox"; + const isUIWebView = typeof navigator.mediaDevices === "undefined"; + + const safari = isIDevice + ? !isUIWebView ? VR_DEVICE_AVAILABILITY.yes : VR_DEVICE_AVAILABILITY.maybe + : VR_DEVICE_AVAILABILITY.no; + + const screen = isInHMD + ? VR_DEVICE_AVAILABILITY.no + : isIDevice && isUIWebView ? VR_DEVICE_AVAILABILITY.maybe : VR_DEVICE_AVAILABILITY.yes; - const screen = isInHMD ? VR_DEVICE_AVAILABILITY.no : VR_DEVICE_AVAILABILITY.yes; let generic = mobiledetect.mobile() ? VR_DEVICE_AVAILABILITY.no : VR_DEVICE_AVAILABILITY.maybe; let cardboard = VR_DEVICE_AVAILABILITY.no; @@ -107,5 +115,5 @@ export async function getAvailableVREntryTypes() { } } - return { screen, generic, gearvr, daydream, cardboard, isInHMD }; + return { screen, generic, gearvr, daydream, cardboard, isInHMD, safari }; } diff --git a/webpack.config.js b/webpack.config.js index 17fbb4f8b666a03a7d1577f07d011040b6125f40..6f66a85c9c59fb1cbd5a339519af6eec23987eb7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -41,6 +41,10 @@ function createHTTPSConfig() { { type: 2, value: "localhost" + }, + { + type: 2, + value: "hubs.local" } ] } @@ -93,6 +97,7 @@ const config = { https: createHTTPSConfig(), host: "0.0.0.0", useLocalIp: true, + public: "hubs.local:8080", port: 8080, before: function(app) { // networked-aframe makes HEAD requests to the server for time syncing. Respond with an empty body. diff --git a/yarn.lock b/yarn.lock index 906c4615fdbd1f5f97e331043fbf27350f45799d..589589dc64406716bd1084f40581f5ad2b410af0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1643,6 +1643,10 @@ buffer@^5.0.2: base64-js "^1.0.2" ieee754 "^1.1.4" +buffered-interpolation@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/buffered-interpolation/-/buffered-interpolation-0.2.3.tgz#6e723d44c4f4aa76704fc470654174e279591c31" + builtin-modules@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" @@ -2036,8 +2040,8 @@ colormin@^1.0.5: has "^1.0.1" colors@*: - version "1.2.5" - resolved "https://registry.yarnpkg.com/colors/-/colors-1.2.5.tgz#89c7ad9a374bc030df8013241f68136ed8835afc" + version "1.3.0" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.3.0.tgz#5f20c9fef6945cb1134260aab33bfbdc8295e04e" colors@1.0.3: version "1.0.3" @@ -5511,8 +5515,9 @@ neo-async@^2.5.0: "networked-aframe@https://github.com/mozillareality/networked-aframe#mr-social-client/master": version "0.6.1" - resolved "https://github.com/mozillareality/networked-aframe#424b41cfdf53db64033885da411c33685644db97" + resolved "https://github.com/mozillareality/networked-aframe#7b88e49e855b60e376886abe23ea311b27acdffe" dependencies: + buffered-interpolation "^0.2.3" easyrtc "1.1.0" express "^4.10.7" serve-static "^1.8.0"