diff --git a/dist/xrfragment.aframe.all.js b/dist/xrfragment.aframe.all.js index 152e177..4b03086 100644 --- a/dist/xrfragment.aframe.all.js +++ b/dist/xrfragment.aframe.all.js @@ -1,5 +1,5 @@ /* - * v0.5.1 generated at Thu Feb 29 01:43:17 PM UTC 2024 + * v0.5.1 generated at Fri Mar 1 01:33:23 PM UTC 2024 * https://xrfragment.org * SPDX-License-Identifier: MPL-2.0 */ @@ -2284,7 +2284,7 @@ xrf.frag.src.enableSourcePortation = (opts) => { let frag = {} xrf.Parser.parse("href", url, frag) sphere.userData = scene.userData // allow rich href notifications/hovers - sphere.userData.href = url.replace(/(&)?[-][\w-+\.]+(&)?/g,'&') // remove negative selectors to refer to original scene + sphere.userData.href = url.replace(/#.*/,'') // remove fragments to refer to original scene sphere.userData.XRF = frag xrf.hashbus.pub.fragment("href", {...opts, mesh:sphere, frag, skipXRWG:true, renderer:xrf.renderer, camera:xrf.camera }) } @@ -3791,18 +3791,30 @@ window.AFRAME.registerComponent('xrf', { aScene.renderer.toneMapping = THREE.ACESFilmicToneMapping; aScene.renderer.toneMappingExposure = 1.25; if( !XRF.camera ) throw 'xrfragment: no camera detected, please declare ABOVE entities with xrf-attributes' + + if( AFRAME.utils.device.isMobile() ){ + // aScene.setAttribute('webxr',"requiredFeatures: dom-overlay; overlayElement: canvas; referenceSpaceType: local") + } - // this is just for convenience (not part of spec): hide/show stuff based on VR/AR tags in 3D model - ARbutton = document.querySelector('.a-enter-ar-button') - VRbutton = document.querySelector('.a-enter-vr-button') - if( ARbutton ) ARbutton.addEventListener('click', () => AFRAME.XRF.hashbus.pub( '#AR' ) ) - if( VRbutton ) VRbutton.addEventListener('click', () => AFRAME.XRF.hashbus.pub( '#VR' ) ) + aScene.addEventListener('loaded', () => { + // this is just for convenience (not part of spec): enforce AR + hide/show stuff based on VR tags in 3D model + ARbutton = document.querySelector('.a-enter-ar-button') + VRbutton = document.querySelector('.a-enter-vr-button') + if( ARbutton ) ARbutton.addEventListener('click', () => AFRAME.XRF.hashbus.pub( '#-VR' ) ) + if( VRbutton ) VRbutton.addEventListener('click', () => AFRAME.XRF.hashbus.pub( '#VR' ) ) + //if( AFRAME.utils.device.checkARSupport() && VRbutton ){ + // VRbutton.style.display = 'none' + // ARbutton.parentNode.style.right = '20px' + //} + }) - aScene.addEventListener('enter-vr', () => { + let repositionUser = (scale) => () => { // sometimes AFRAME resets the user position to 0,0,0 when entering VR (not sure why) let pos = xrf.frag.pos.last - if( pos ){ AFRAME.XRF.camera.position.set(pos.x, pos.y, pos.z) } - }) + if( pos ){ AFRAME.XRF.camera.position.set(pos.x, pos.y*scale, pos.z) } + } + aScene.addEventListener('enter-vr', repositionUser(1) ) + aScene.addEventListener('enter-ar', repositionUser(2) ) xrf.addEventListener('navigateLoaded', (opts) => { setTimeout( () => AFRAME.fade.out(),500) @@ -3917,6 +3929,227 @@ window.AFRAME.registerComponent('xrf', { }, }) +/** + * Movement Controls + * + * @author Don McCurdy + */ + +const COMPONENT_SUFFIX = '-controls'; +const MAX_DELTA = 0.2; // ms +const EPS = 10e-6; +const MOVED = 'moved'; + +AFRAME.registerComponent('movement-controls', { + + /******************************************************************* + * Schema + */ + + dependencies: ['rotation'], + + schema: { + enabled: { default: true }, + controls: { default: ['gamepad', 'trackpad', 'keyboard', 'touch'] }, + speed: { default: 0.3, min: 0 }, + fly: { default: false }, + constrainToNavMesh: { default: false }, + camera: { default: '[movement-controls] [camera]', type: 'selector' } + }, + + /******************************************************************* + * Lifecycle + */ + + init: function () { + const el = this.el; + if (!this.data.camera) { + this.data.camera = el.querySelector('[camera]'); + } + this.velocityCtrl = null; + + this.velocity = new THREE.Vector3(); + this.heading = new THREE.Quaternion(); + this.eventDetail = {}; + + // Navigation + this.navGroup = null; + this.navNode = null; + + if (el.sceneEl.hasLoaded) { + this.injectControls(); + } else { + el.sceneEl.addEventListener('loaded', this.injectControls.bind(this)); + } + }, + + update: function (prevData) { + const el = this.el; + const data = this.data; + const nav = el.sceneEl.systems.nav; + if (el.sceneEl.hasLoaded) { + this.injectControls(); + } + if (nav && data.constrainToNavMesh !== prevData.constrainToNavMesh) { + data.constrainToNavMesh + ? nav.addAgent(this) + : nav.removeAgent(this); + } + if (data.enabled !== prevData.enabled) { + // Propagate the enabled change to all controls + for (let i = 0; i < data.controls.length; i++) { + const name = data.controls[i] + COMPONENT_SUFFIX; + this.el.setAttribute(name, { enabled: this.data.enabled }); + } + } + }, + + injectControls: function () { + const data = this.data; + + for (let i = 0; i < data.controls.length; i++) { + const name = data.controls[i] + COMPONENT_SUFFIX; + this.el.setAttribute(name, { enabled: this.data.enabled }); + } + }, + + updateNavLocation: function () { + this.navGroup = null; + this.navNode = null; + }, + + /******************************************************************* + * Tick + */ + + tick: (function () { + const start = new THREE.Vector3(); + const end = new THREE.Vector3(); + const clampedEnd = new THREE.Vector3(); + + return function (t, dt) { + if (!dt) return; + + const el = this.el; + const data = this.data; + + if (!data.enabled) return; + + this.updateVelocityCtrl(); + const velocityCtrl = this.velocityCtrl; + const velocity = this.velocity; + + if (!velocityCtrl) return; + + // Update velocity. If FPS is too low, reset. + if (dt / 1000 > MAX_DELTA) { + velocity.set(0, 0, 0); + } else { + this.updateVelocity(dt); + } + + if (data.constrainToNavMesh + && velocityCtrl.isNavMeshConstrained !== false) { + + if (velocity.lengthSq() < EPS) return; + + start.copy(el.object3D.position); + end + .copy(velocity) + .multiplyScalar(dt / 1000) + .add(start); + + const nav = el.sceneEl.systems.nav; + this.navGroup = this.navGroup === null ? nav.getGroup(start) : this.navGroup; + this.navNode = this.navNode || nav.getNode(start, this.navGroup); + this.navNode = nav.clampStep(start, end, this.navGroup, this.navNode, clampedEnd); + el.object3D.position.copy(clampedEnd); + } else if (el.hasAttribute('velocity')) { + el.setAttribute('velocity', velocity); + } else { + el.object3D.position.x += velocity.x * dt / 1000; + el.object3D.position.y += velocity.y * dt / 1000; + el.object3D.position.z += velocity.z * dt / 1000; + } + + }; + }()), + + /******************************************************************* + * Movement + */ + + updateVelocityCtrl: function () { + const data = this.data; + if (data.enabled) { + for (let i = 0, l = data.controls.length; i < l; i++) { + const control = this.el.components[data.controls[i] + COMPONENT_SUFFIX]; + if (control && control.isVelocityActive()) { + this.velocityCtrl = control; + return; + } + } + this.velocityCtrl = null; + } + }, + + updateVelocity: (function () { + const vector2 = new THREE.Vector2(); + const quaternion = new THREE.Quaternion(); + + return function (dt) { + let dVelocity; + const el = this.el; + const control = this.velocityCtrl; + const velocity = this.velocity; + const data = this.data; + + if (control) { + if (control.getVelocityDelta) { + dVelocity = control.getVelocityDelta(dt); + } else if (control.getVelocity) { + velocity.copy(control.getVelocity()); + return; + } else if (control.getPositionDelta) { + velocity.copy(control.getPositionDelta(dt).multiplyScalar(1000 / dt)); + return; + } else { + throw new Error('Incompatible movement controls: ', control); + } + } + + if (el.hasAttribute('velocity') && !data.constrainToNavMesh) { + velocity.copy(this.el.getAttribute('velocity')); + } + + if (dVelocity && data.enabled) { + const cameraEl = data.camera; + + // Rotate to heading + quaternion.copy(cameraEl.object3D.quaternion); + quaternion.premultiply(el.object3D.quaternion); + dVelocity.applyQuaternion(quaternion); + + const factor = dVelocity.length(); + if (data.fly) { + velocity.copy(dVelocity); + velocity.multiplyScalar(this.data.speed * 16.66667); + } else { + vector2.set(dVelocity.x, dVelocity.z); + vector2.setLength(factor * this.data.speed * 16.66667); + velocity.x = vector2.x; + velocity.y = 0; + velocity.z = vector2.y; + } + if (velocity.x !== 0 || velocity.y !== 0 || velocity.z !== 0) { + this.eventDetail.velocity = velocity; + this.el.emit(MOVED, this.eventDetail); + } + } + }; + + }()) +}); // look-controls turns off autoUpdateMatrix (of player) which // will break teleporting and other stuff // overriding this is easier then adding updateMatrixWorld() everywhere else @@ -3970,6 +4203,97 @@ AFRAME.components['look-controls'].Component.prototype.updateOrientation = funct object3D.rotation.z = this.magicWindowDeltaEuler.z; object3D.matrixAutoUpdate = true } +/** + * Touch-to-move-forward controls for mobile. + */ +AFRAME.registerComponent('touch-controls', { + schema: { + enabled: { default: true }, + reverseEnabled: { default: true } + }, + + init: function () { + this.dVelocity = new THREE.Vector3(); + this.bindMethods(); + this.direction = 0; + }, + + play: function () { + this.addEventListeners(); + }, + + pause: function () { + this.removeEventListeners(); + this.dVelocity.set(0, 0, 0); + }, + + remove: function () { + this.pause(); + }, + + addEventListeners: function () { + const sceneEl = this.el.sceneEl; + const canvasEl = sceneEl.canvas; + + if (!canvasEl) { + sceneEl.addEventListener('render-target-loaded', this.addEventListeners.bind(this)); + return; + } + + canvasEl.addEventListener('touchstart', this.onTouchStart); + canvasEl.addEventListener('touchend', this.onTouchEnd); + const vrModeUI = sceneEl.getAttribute('vr-mode-ui'); + if (vrModeUI && vrModeUI.cardboardModeEnabled) { + sceneEl.addEventListener('enter-vr', this.onEnterVR); + } + }, + + removeEventListeners: function () { + const canvasEl = this.el.sceneEl && this.el.sceneEl.canvas; + if (!canvasEl) { return; } + + canvasEl.removeEventListener('touchstart', this.onTouchStart); + canvasEl.removeEventListener('touchend', this.onTouchEnd); + this.el.sceneEl.removeEventListener('enter-vr', this.onEnterVR) + }, + + isVelocityActive: function () { + return this.data.enabled && !!this.direction; + }, + + getVelocityDelta: function () { + this.dVelocity.z = this.direction; + return this.dVelocity.clone(); + }, + + bindMethods: function () { + this.onTouchStart = this.onTouchStart.bind(this); + this.onTouchEnd = this.onTouchEnd.bind(this); + this.onEnterVR = this.onEnterVR.bind(this); + }, + + onTouchStart: function (e) { + this.direction = 0; + if (this.data.reverseEnabled && e.touches ){ + if( e.touches.length === 3) this.direction = 1; + if( e.touches.length === 2) this.direction = -1; + } + //e.preventDefault(); + }, + + onTouchEnd: function (e) { + this.direction = 0; + //e.preventDefault(); + }, + + onEnterVR: function () { + // This is to make the Cardboard button on Chrome Android working + //const xrSession = this.el.sceneEl.xrSession; + //if (!xrSession) { return; } + //xrSession.addEventListener('selectstart', this.onTouchStart); + //xrSession.addEventListener('selectend', this.onTouchEnd); + } +}) window.AFRAME.registerComponent('xrf-button', { schema: { label: { @@ -4081,8 +4405,9 @@ window.AFRAME.registerComponent('xrf-button', { AFRAME.registerComponent('vconsole', { init: function () { //AFRAME.XRF.navigator.to("https://coderofsalvation.github.io/xrsh-media/assets/background.glb") - return + let aScene = AFRAME.scenes[0] + return document.head.innerHTML += ` + - - + + diff --git a/src/3rd/js/aframe/index.js b/src/3rd/js/aframe/index.js index fc6b015..3120309 100644 --- a/src/3rd/js/aframe/index.js +++ b/src/3rd/js/aframe/index.js @@ -42,18 +42,30 @@ window.AFRAME.registerComponent('xrf', { aScene.renderer.toneMapping = THREE.ACESFilmicToneMapping; aScene.renderer.toneMappingExposure = 1.25; if( !XRF.camera ) throw 'xrfragment: no camera detected, please declare ABOVE entities with xrf-attributes' + + if( AFRAME.utils.device.isMobile() ){ + // aScene.setAttribute('webxr',"requiredFeatures: dom-overlay; overlayElement: canvas; referenceSpaceType: local") + } - // this is just for convenience (not part of spec): hide/show stuff based on VR/AR tags in 3D model - ARbutton = document.querySelector('.a-enter-ar-button') - VRbutton = document.querySelector('.a-enter-vr-button') - if( ARbutton ) ARbutton.addEventListener('click', () => AFRAME.XRF.hashbus.pub( '#AR' ) ) - if( VRbutton ) VRbutton.addEventListener('click', () => AFRAME.XRF.hashbus.pub( '#VR' ) ) + aScene.addEventListener('loaded', () => { + // this is just for convenience (not part of spec): enforce AR + hide/show stuff based on VR tags in 3D model + ARbutton = document.querySelector('.a-enter-ar-button') + VRbutton = document.querySelector('.a-enter-vr-button') + if( ARbutton ) ARbutton.addEventListener('click', () => AFRAME.XRF.hashbus.pub( '#-VR' ) ) + if( VRbutton ) VRbutton.addEventListener('click', () => AFRAME.XRF.hashbus.pub( '#VR' ) ) + //if( AFRAME.utils.device.checkARSupport() && VRbutton ){ + // VRbutton.style.display = 'none' + // ARbutton.parentNode.style.right = '20px' + //} + }) - aScene.addEventListener('enter-vr', () => { + let repositionUser = (scale) => () => { // sometimes AFRAME resets the user position to 0,0,0 when entering VR (not sure why) let pos = xrf.frag.pos.last - if( pos ){ AFRAME.XRF.camera.position.set(pos.x, pos.y, pos.z) } - }) + if( pos ){ AFRAME.XRF.camera.position.set(pos.x, pos.y*scale, pos.z) } + } + aScene.addEventListener('enter-vr', repositionUser(1) ) + aScene.addEventListener('enter-ar', repositionUser(2) ) xrf.addEventListener('navigateLoaded', (opts) => { setTimeout( () => AFRAME.fade.out(),500) diff --git a/src/3rd/js/aframe/movement-controls.js b/src/3rd/js/aframe/movement-controls.js new file mode 100644 index 0000000..fd647be --- /dev/null +++ b/src/3rd/js/aframe/movement-controls.js @@ -0,0 +1,221 @@ +/** + * Movement Controls + * + * @author Don McCurdy + */ + +const COMPONENT_SUFFIX = '-controls'; +const MAX_DELTA = 0.2; // ms +const EPS = 10e-6; +const MOVED = 'moved'; + +AFRAME.registerComponent('movement-controls', { + + /******************************************************************* + * Schema + */ + + dependencies: ['rotation'], + + schema: { + enabled: { default: true }, + controls: { default: ['gamepad', 'trackpad', 'keyboard', 'touch'] }, + speed: { default: 0.3, min: 0 }, + fly: { default: false }, + constrainToNavMesh: { default: false }, + camera: { default: '[movement-controls] [camera]', type: 'selector' } + }, + + /******************************************************************* + * Lifecycle + */ + + init: function () { + const el = this.el; + if (!this.data.camera) { + this.data.camera = el.querySelector('[camera]'); + } + this.velocityCtrl = null; + + this.velocity = new THREE.Vector3(); + this.heading = new THREE.Quaternion(); + this.eventDetail = {}; + + // Navigation + this.navGroup = null; + this.navNode = null; + + if (el.sceneEl.hasLoaded) { + this.injectControls(); + } else { + el.sceneEl.addEventListener('loaded', this.injectControls.bind(this)); + } + }, + + update: function (prevData) { + const el = this.el; + const data = this.data; + const nav = el.sceneEl.systems.nav; + if (el.sceneEl.hasLoaded) { + this.injectControls(); + } + if (nav && data.constrainToNavMesh !== prevData.constrainToNavMesh) { + data.constrainToNavMesh + ? nav.addAgent(this) + : nav.removeAgent(this); + } + if (data.enabled !== prevData.enabled) { + // Propagate the enabled change to all controls + for (let i = 0; i < data.controls.length; i++) { + const name = data.controls[i] + COMPONENT_SUFFIX; + this.el.setAttribute(name, { enabled: this.data.enabled }); + } + } + }, + + injectControls: function () { + const data = this.data; + + for (let i = 0; i < data.controls.length; i++) { + const name = data.controls[i] + COMPONENT_SUFFIX; + this.el.setAttribute(name, { enabled: this.data.enabled }); + } + }, + + updateNavLocation: function () { + this.navGroup = null; + this.navNode = null; + }, + + /******************************************************************* + * Tick + */ + + tick: (function () { + const start = new THREE.Vector3(); + const end = new THREE.Vector3(); + const clampedEnd = new THREE.Vector3(); + + return function (t, dt) { + if (!dt) return; + + const el = this.el; + const data = this.data; + + if (!data.enabled) return; + + this.updateVelocityCtrl(); + const velocityCtrl = this.velocityCtrl; + const velocity = this.velocity; + + if (!velocityCtrl) return; + + // Update velocity. If FPS is too low, reset. + if (dt / 1000 > MAX_DELTA) { + velocity.set(0, 0, 0); + } else { + this.updateVelocity(dt); + } + + if (data.constrainToNavMesh + && velocityCtrl.isNavMeshConstrained !== false) { + + if (velocity.lengthSq() < EPS) return; + + start.copy(el.object3D.position); + end + .copy(velocity) + .multiplyScalar(dt / 1000) + .add(start); + + const nav = el.sceneEl.systems.nav; + this.navGroup = this.navGroup === null ? nav.getGroup(start) : this.navGroup; + this.navNode = this.navNode || nav.getNode(start, this.navGroup); + this.navNode = nav.clampStep(start, end, this.navGroup, this.navNode, clampedEnd); + el.object3D.position.copy(clampedEnd); + } else if (el.hasAttribute('velocity')) { + el.setAttribute('velocity', velocity); + } else { + el.object3D.position.x += velocity.x * dt / 1000; + el.object3D.position.y += velocity.y * dt / 1000; + el.object3D.position.z += velocity.z * dt / 1000; + } + + }; + }()), + + /******************************************************************* + * Movement + */ + + updateVelocityCtrl: function () { + const data = this.data; + if (data.enabled) { + for (let i = 0, l = data.controls.length; i < l; i++) { + const control = this.el.components[data.controls[i] + COMPONENT_SUFFIX]; + if (control && control.isVelocityActive()) { + this.velocityCtrl = control; + return; + } + } + this.velocityCtrl = null; + } + }, + + updateVelocity: (function () { + const vector2 = new THREE.Vector2(); + const quaternion = new THREE.Quaternion(); + + return function (dt) { + let dVelocity; + const el = this.el; + const control = this.velocityCtrl; + const velocity = this.velocity; + const data = this.data; + + if (control) { + if (control.getVelocityDelta) { + dVelocity = control.getVelocityDelta(dt); + } else if (control.getVelocity) { + velocity.copy(control.getVelocity()); + return; + } else if (control.getPositionDelta) { + velocity.copy(control.getPositionDelta(dt).multiplyScalar(1000 / dt)); + return; + } else { + throw new Error('Incompatible movement controls: ', control); + } + } + + if (el.hasAttribute('velocity') && !data.constrainToNavMesh) { + velocity.copy(this.el.getAttribute('velocity')); + } + + if (dVelocity && data.enabled) { + const cameraEl = data.camera; + + // Rotate to heading + quaternion.copy(cameraEl.object3D.quaternion); + quaternion.premultiply(el.object3D.quaternion); + dVelocity.applyQuaternion(quaternion); + + const factor = dVelocity.length(); + if (data.fly) { + velocity.copy(dVelocity); + velocity.multiplyScalar(this.data.speed * 16.66667); + } else { + vector2.set(dVelocity.x, dVelocity.z); + vector2.setLength(factor * this.data.speed * 16.66667); + velocity.x = vector2.x; + velocity.y = 0; + velocity.z = vector2.y; + } + if (velocity.x !== 0 || velocity.y !== 0 || velocity.z !== 0) { + this.eventDetail.velocity = velocity; + this.el.emit(MOVED, this.eventDetail); + } + } + }; + + }()) +}); diff --git a/src/3rd/js/aframe/touch-controls.js b/src/3rd/js/aframe/touch-controls.js new file mode 100644 index 0000000..537179f --- /dev/null +++ b/src/3rd/js/aframe/touch-controls.js @@ -0,0 +1,91 @@ +/** + * Touch-to-move-forward controls for mobile. + */ +AFRAME.registerComponent('touch-controls', { + schema: { + enabled: { default: true }, + reverseEnabled: { default: true } + }, + + init: function () { + this.dVelocity = new THREE.Vector3(); + this.bindMethods(); + this.direction = 0; + }, + + play: function () { + this.addEventListeners(); + }, + + pause: function () { + this.removeEventListeners(); + this.dVelocity.set(0, 0, 0); + }, + + remove: function () { + this.pause(); + }, + + addEventListeners: function () { + const sceneEl = this.el.sceneEl; + const canvasEl = sceneEl.canvas; + + if (!canvasEl) { + sceneEl.addEventListener('render-target-loaded', this.addEventListeners.bind(this)); + return; + } + + canvasEl.addEventListener('touchstart', this.onTouchStart); + canvasEl.addEventListener('touchend', this.onTouchEnd); + const vrModeUI = sceneEl.getAttribute('vr-mode-ui'); + if (vrModeUI && vrModeUI.cardboardModeEnabled) { + sceneEl.addEventListener('enter-vr', this.onEnterVR); + } + }, + + removeEventListeners: function () { + const canvasEl = this.el.sceneEl && this.el.sceneEl.canvas; + if (!canvasEl) { return; } + + canvasEl.removeEventListener('touchstart', this.onTouchStart); + canvasEl.removeEventListener('touchend', this.onTouchEnd); + this.el.sceneEl.removeEventListener('enter-vr', this.onEnterVR) + }, + + isVelocityActive: function () { + return this.data.enabled && !!this.direction; + }, + + getVelocityDelta: function () { + this.dVelocity.z = this.direction; + return this.dVelocity.clone(); + }, + + bindMethods: function () { + this.onTouchStart = this.onTouchStart.bind(this); + this.onTouchEnd = this.onTouchEnd.bind(this); + this.onEnterVR = this.onEnterVR.bind(this); + }, + + onTouchStart: function (e) { + this.direction = 0; + if (this.data.reverseEnabled && e.touches ){ + if( e.touches.length === 3) this.direction = 1; + if( e.touches.length === 2) this.direction = -1; + } + //e.preventDefault(); + }, + + onTouchEnd: function (e) { + this.direction = 0; + //e.preventDefault(); + }, + + onEnterVR: function () { + // This is to make the Cardboard button on Chrome Android working + //const xrSession = this.el.sceneEl.xrSession; + //if (!xrSession) { return; } + //xrSession.addEventListener('selectstart', this.onTouchStart); + //xrSession.addEventListener('selectend', this.onTouchEnd); + } +}) diff --git a/src/3rd/js/aframe/xrf-console.js b/src/3rd/js/aframe/xrf-console.js index 78e133a..09d52ab 100644 --- a/src/3rd/js/aframe/xrf-console.js +++ b/src/3rd/js/aframe/xrf-console.js @@ -1,8 +1,9 @@ AFRAME.registerComponent('vconsole', { init: function () { //AFRAME.XRF.navigator.to("https://coderofsalvation.github.io/xrsh-media/assets/background.glb") - return + let aScene = AFRAME.scenes[0] + return document.head.innerHTML += `