diff --git a/src/assets/images/giphy_logo.png b/src/assets/images/giphy_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f979e8bed55a6eda4f7dba98eeb4ce6297f4f837 Binary files /dev/null and b/src/assets/images/giphy_logo.png differ diff --git a/src/components/auto-box-collider.js b/src/components/auto-box-collider.js deleted file mode 100644 index 220ad92ed83ba506aa63f70dc2ee24e087ce61db..0000000000000000000000000000000000000000 --- a/src/components/auto-box-collider.js +++ /dev/null @@ -1,49 +0,0 @@ -AFRAME.registerComponent("auto-box-collider", { - schema: { - resize: { default: false }, - resizeLength: { default: 0.5 } - }, - - init() { - this.onLoaded = this.onLoaded.bind(this); - this.el.addEventListener("model-loaded", this.onLoaded); - }, - - remove() { - this.el.removeEventListener("model-loaded", this.onLoaded); - }, - - onLoaded() { - const rotation = this.el.object3D.rotation.clone(); - this.el.object3D.rotation.set(0, 0, 0); - const { min, max } = new THREE.Box3().setFromObject(this.el.object3DMap.mesh); - const halfExtents = new THREE.Vector3() - .addVectors(min.clone().negate(), max) - .multiplyScalar(0.5 / this.el.object3D.scale.x); - this.el.setAttribute("shape", { - shape: "box", - halfExtents: halfExtents, - offset: new THREE.Vector3(0, halfExtents.y, 0) - }); - if (this.data.resize) { - this.resize(min, max); - } - this.el.object3D.rotation.copy(rotation); - this.el.removeAttribute("auto-box-collider"); - }, - - // Adjust the scale such that the object fits within a box of a specified size. - resize(min, max) { - const dX = Math.abs(max.x - min.x); - const dY = Math.abs(max.y - min.y); - const dZ = Math.abs(max.z - min.z); - const lengthOfLongestComponent = Math.max(dX, dY, dZ); - const correctiveFactor = this.data.resizeLength / lengthOfLongestComponent; - const scale = this.el.object3D.scale; - this.el.setAttribute("scale", { - x: scale.x * correctiveFactor, - y: scale.y * correctiveFactor, - z: scale.z * correctiveFactor - }); - } -}); diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js index 38be846de6e7f68fbf937be2937459a445099555..c801f76188260c695d7121ef242a00e1d5db4ea9 100644 --- a/src/components/cursor-controller.js +++ b/src/components/cursor-controller.js @@ -53,6 +53,7 @@ AFRAME.registerComponent("cursor-controller", { tick: (() => { const rayObjectRotation = new THREE.Quaternion(); + const cameraPos = new THREE.Vector3(); return function() { if (!this.enabled) { @@ -97,6 +98,11 @@ AFRAME.registerComponent("cursor-controller", { if (this.data.drawLine) { this.el.setAttribute("line", { start: this.origin.clone(), end: this.data.cursor.object3D.position.clone() }); } + + // The cursor will always be oriented towards the player about its Y axis, so objects held by the cursor will rotate towards the player. + this.data.camera.object3D.getWorldPosition(cameraPos); + cameraPos.y = this.data.cursor.object3D.position.y; + this.data.cursor.object3D.lookAt(cameraPos); }; })(), diff --git a/src/components/destroy-at-extreme-distances.js b/src/components/destroy-at-extreme-distances.js new file mode 100644 index 0000000000000000000000000000000000000000..af878249afc37c25785ecae5d8c3bf072e0d6c9f --- /dev/null +++ b/src/components/destroy-at-extreme-distances.js @@ -0,0 +1,24 @@ +AFRAME.registerComponent("destroy-at-extreme-distances", { + schema: { + xMin: { default: -1000 }, + xMax: { default: 1000 }, + yMin: { default: -1000 }, + yMax: { default: 1000 }, + zMin: { default: -1000 }, + zMax: { default: 1000 } + }, + + tick: (function() { + const pos = new THREE.Vector3(); + return function() { + const { xMin, xMax, yMin, yMax, zMin, zMax } = this.data; + this.el.object3D.getWorldPosition(pos); + this.el.parentNode === this.el.sceneEl + ? pos.copy(this.el.object3D.position) + : this.el.object3D.getWorldPosition(pos); + if (pos.x < xMin || pos.x > xMax || pos.y < yMin || pos.y > yMax || pos.z < zMin || pos.z > zMax) { + this.el.parentNode.removeChild(this.el); + } + }; + })() +}); diff --git a/src/components/gltf-model-plus.js b/src/components/gltf-model-plus.js index b1195922467eb57dd6b536e688b147c164e502ea..0b9755a4a8a449b2001a4d44180df73c3b9f7545 100644 --- a/src/components/gltf-model-plus.js +++ b/src/components/gltf-model-plus.js @@ -181,11 +181,12 @@ function nextTick() { }); } -function cachedLoadGLTF(src, preferredTechnique, onProgress) { +function cachedLoadGLTF(src, basePath, preferredTechnique, onProgress) { // Load the gltf model from the cache if it exists. if (!GLTFCache[src]) { GLTFCache[src] = new Promise((resolve, reject) => { const gltfLoader = new THREE.GLTFLoader(); + gltfLoader.path = basePath; gltfLoader.preferredTechnique = preferredTechnique; gltfLoader.load(src, resolve, onProgress, reject); }); @@ -202,6 +203,7 @@ function cachedLoadGLTF(src, preferredTechnique, onProgress) { AFRAME.registerComponent("gltf-model-plus", { schema: { src: { type: "string" }, + basePath: { type: "string", default: undefined }, inflate: { default: false } }, @@ -242,7 +244,7 @@ AFRAME.registerComponent("gltf-model-plus", { } const gltfPath = THREE.LoaderUtils.extractUrlBase(src); - const model = await cachedLoadGLTF(src, this.preferredTechnique); + const model = await cachedLoadGLTF(src, this.data.basePath, this.preferredTechnique); // If we started loading something else already // TODO: there should be a way to cancel loading instead @@ -254,11 +256,10 @@ AFRAME.registerComponent("gltf-model-plus", { this.model = model.scene || model.scenes[0]; this.model.animations = model.animations; - this.el.setObject3D("mesh", this.model); - - if (this.data.inflate) { - this.inflatedEl = inflateEntities(this.model, this.templates, gltfPath); + let object3DToSet = this.model; + if (this.data.inflate && (this.inflatedEl = inflateEntities(this.model, this.templates, gltfPath))) { this.el.appendChild(this.inflatedEl); + object3DToSet = this.inflatedEl.object3D; // TODO: Still don't fully understand the lifecycle here and how it differs between browsers, we should dig in more // Wait one tick for the appended custom elements to be connected before attaching templates await nextTick(); @@ -267,7 +268,7 @@ AFRAME.registerComponent("gltf-model-plus", { attachTemplate(this.el, name, this.templates[name]); } } - + this.el.setObject3D("mesh", object3DToSet); this.el.emit("model-loaded", { format: "gltf", model: this.model }); } catch (e) { console.error("Failed to load glTF model", e, this); diff --git a/src/components/image-plus.js b/src/components/image-plus.js index 744020534a6d9f5f6924ea12084572d4eb325109..5110cebbaca330f9ce53aa32d2d5fbee0963c196 100644 --- a/src/components/image-plus.js +++ b/src/components/image-plus.js @@ -67,45 +67,11 @@ errorImage.onload = () => { }; AFRAME.registerComponent("image-plus", { - dependencies: ["geometry"], - schema: { src: { type: "string" }, - contentType: { type: "string" } - }, - - _fit(w, h) { - const ratio = (h || 1.0) / (w || 1.0); - const geo = this.el.geometry; - let width, height; - if (geo && geo.width) { - if (geo.height && ratio > 1) { - width = geo.width / ratio; - } else { - height = geo.height * ratio; - } - } else if (geo && geo.height) { - width = geo.width / ratio; - } else { - width = Math.min(1.0, 1.0 / ratio); - height = Math.min(1.0, ratio); - } - this.el.setAttribute("geometry", { width, height }); - this.el.setAttribute("shape", { - shape: "box", - halfExtents: { - x: width / 2, - y: height / 2, - z: 0.05 - } - }); - }, + contentType: { type: "string" }, - init() { - const material = new THREE.MeshBasicMaterial(); - material.side = THREE.DoubleSide; - material.transparent = true; - this.el.getObject3D("mesh").material = material; + depth: { default: 0.05 } }, remove() { @@ -216,33 +182,66 @@ AFRAME.registerComponent("image-plus", { return; } + let cacheItem; if (textureCache.has(url)) { - const cacheItem = textureCache.get(url); + cacheItem = textureCache.get(url); texture = cacheItem.texture; cacheItem.count++; } else { + cacheItem = { count: 1 }; if (url === "error") { texture = errorTexture; } else if (contentType === "image/gif") { texture = await this.loadGIF(url); } else if (contentType.startsWith("image/")) { texture = await this.loadImage(url); - } else if (contentType.startsWith("video")) { + } else if (contentType.startsWith("video/") || contentType.startsWith("audio/")) { texture = await this.loadVideo(url); + cacheItem.audioSource = this.el.sceneEl.audioListener.context.createMediaElementSource(texture.image); } else { - throw new Error(`Unknown centent type: ${contentType}`); + throw new Error(`Unknown content type: ${contentType}`); } - textureCache.set(url, { count: 1, texture }); + cacheItem.texture = texture; + textureCache.set(url, cacheItem); + } + + if (cacheItem.audioSource) { + const sound = new THREE.PositionalAudio(this.el.sceneEl.audioListener); + sound.setNodeSource(cacheItem.audioSource); + this.el.setObject3D("sound", sound); } } catch (e) { console.error("Error loading media", this.data.src, e); texture = errorTexture; } - const material = this.el.getObject3D("mesh").material; + const material = new THREE.MeshBasicMaterial(); + material.side = THREE.DoubleSide; + material.transparent = true; material.map = texture; material.needsUpdate = true; - this._fit(texture.image.videoWidth || texture.image.width, texture.image.videoHeight || texture.image.height); + material.map.needsUpdate = true; + + const ratio = + (texture.image.videoHeight || texture.image.height || 1.0) / + (texture.image.videoWidth || texture.image.width || 1.0); + const width = Math.min(1.0, 1.0 / ratio); + const height = Math.min(1.0, ratio); + + const geometry = new THREE.PlaneGeometry(width, height, 1, 1); + this.mesh = new THREE.Mesh(geometry, material); + this.el.setObject3D("mesh", this.mesh); + this.el.setAttribute("shape", { + shape: "box", + halfExtents: { x: width / 2, y: height / 2, z: this.data.depth } + }); + + // TODO: verify if we actually need to do this + if (this.el.components.body && this.el.components.body.body) { + this.el.components.body.syncToPhysics(); + this.el.components.body.updateCannonScale(); + } + this.el.emit("image-loaded"); } }); diff --git a/src/components/media-loader.js b/src/components/media-loader.js new file mode 100644 index 0000000000000000000000000000000000000000..adae71d23a1cd47af037d6f76495ffef31104b91 --- /dev/null +++ b/src/components/media-loader.js @@ -0,0 +1,93 @@ +import { getBox, getScaleCoefficient } from "../utils/auto-box-collider"; +import { resolveFarsparkUrl } from "../utils/media-utils"; + +const fetchContentType = async url => fetch(url, { method: "HEAD" }).then(r => r.headers.get("content-type")); + +AFRAME.registerComponent("media-loader", { + schema: { + src: { type: "string" }, + resize: { default: false } + }, + + init() { + this.onError = this.onError.bind(this); + }, + + setShapeAndScale(resize) { + const mesh = this.el.getObject3D("mesh"); + const box = getBox(this.el, mesh); + const scaleCoefficient = resize ? getScaleCoefficient(0.5, box) : 1; + this.el.object3DMap.mesh.scale.multiplyScalar(scaleCoefficient); + if (this.el.body && this.el.body.shapes.length > 1) { + this.el.removeAttribute("shape"); + } else { + const center = new THREE.Vector3(); + const { min, max } = box; + const halfExtents = { + x: Math.abs(min.x - max.x) / 2 * scaleCoefficient, + y: Math.abs(min.y - max.y) / 2 * scaleCoefficient, + z: Math.abs(min.z - max.z) / 2 * scaleCoefficient + }; + center.addVectors(min, max).multiplyScalar(0.5 * scaleCoefficient); + mesh.position.sub(center); + this.el.setAttribute("shape", { + shape: "box", + halfExtents: halfExtents + }); + } + }, + + onError() { + this.el.setAttribute("image-plus", { src: "error" }); + clearTimeout(this.showLoaderTimeout); + }, + + // TODO: correctly handle case where src changes + async update() { + try { + const url = this.data.src; + + this.showLoaderTimeout = setTimeout(() => { + const loadingObj = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial()); + this.el.setObject3D("mesh", loadingObj); + this.setShapeAndScale(true); + }, 100); + + const { raw, origin, meta } = await resolveFarsparkUrl(url); + console.log("resolved", url, raw, origin, meta); + + const contentType = (meta && meta.expected_content_type) || (await fetchContentType(raw)); + if (contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/")) { + this.el.addEventListener( + "image-loaded", + () => { + clearTimeout(this.showLoaderTimeout); + }, + { once: true } + ); + this.el.setAttribute("image-plus", { src: raw, contentType }); + this.el.setAttribute("position-at-box-shape-border", { target: ".delete-button", dirs: ["forward", "back"] }); + } else if (contentType.startsWith("model/gltf") || url.endsWith(".gltf") || url.endsWith(".glb")) { + this.el.addEventListener( + "model-loaded", + () => { + clearTimeout(this.showLoaderTimeout); + this.setShapeAndScale(this.data.resize); + }, + { once: true } + ); + this.el.addEventListener("model-error", this.onError, { once: true }); + this.el.setAttribute("gltf-model-plus", { + src: raw, + basePath: THREE.LoaderUtils.extractUrlBase(origin), + inflate: true + }); + } else { + throw new Error(`Unsupported content type: ${contentType}`); + } + } catch (e) { + console.error("Error adding media", e); + this.onError(); + } + } +}); diff --git a/src/components/offset-relative-to.js b/src/components/offset-relative-to.js index 23920e099a94fd50373f6804bf8c02d8cae0910f..eff56e8a45e9f201bbabbbf516de4a3625e843dc 100644 --- a/src/components/offset-relative-to.js +++ b/src/components/offset-relative-to.js @@ -37,10 +37,12 @@ AFRAME.registerComponent("offset-relative-to", { obj.parent.worldToLocal(offsetVector); } obj.position.copy(offsetVector); + this.el.body && this.el.body.position.copy(obj.position); // TODO: Hack here to deal with the fact that the rotation component mutates ordering, and we network rotation without sending ordering information // See https://github.com/networked-aframe/networked-aframe/issues/134 obj.rotation.order = "YXZ"; target.getWorldQuaternion(obj.quaternion); + this.el.body && this.el.body.quaternion.copy(obj.quaternion); if (this.data.selfDestruct) { if (this.data.on) { this.el.sceneEl.removeEventListener(this.data.on, this.updateOffset); diff --git a/src/components/position-at-box-shape-border.js b/src/components/position-at-box-shape-border.js index 59776bf6de0c2f12f2b46e40720a4287dee49c9b..561577b5eb1d302f0dfa44d508352e76cad11829 100644 --- a/src/components/position-at-box-shape-border.js +++ b/src/components/position-at-box-shape-border.js @@ -1,6 +1,8 @@ +import { getBox } from "../utils/auto-box-collider.js"; + const PI = Math.PI; const HALF_PI = PI / 2; -const THREE_HALF_PI = 3 * PI / 2; +const THREE_HALF_PI = 3 * HALF_PI; const right = new THREE.Vector3(1, 0, 0); const forward = new THREE.Vector3(0, 0, 1); const left = new THREE.Vector3(-1, 0, 0); @@ -28,6 +30,11 @@ const dirs = { } }; +const inverseHalfExtents = { + x: "z", + z: "x" +}; + AFRAME.registerComponent("position-at-box-shape-border", { schema: { target: { type: "string" }, @@ -36,6 +43,7 @@ AFRAME.registerComponent("position-at-box-shape-border", { init() { this.cam = this.el.sceneEl.camera.el.object3D; + this.halfExtents = new THREE.Vector3(); }, update() { @@ -47,25 +55,39 @@ AFRAME.registerComponent("position-at-box-shape-border", { const targetPosition = new THREE.Vector3(); const pointOnBoxFace = new THREE.Vector3(); return function() { - if (!this.shape) { - this.shape = this.el.components["shape"]; - if (!this.shape) return; - } if (!this.target) { this.target = this.el.querySelector(this.data.target).object3D; if (!this.target) return; } - const halfExtents = this.shape.data.halfExtents; + if (!this.el.getObject3D("mesh")) { + return; + } + if (!this.halfExtents || this.mesh !== this.el.getObject3D("mesh") || this.shape !== this.el.components.shape) { + this.mesh = this.el.getObject3D("mesh"); + if (this.el.components.shape) { + this.shape = this.el.components.shape; + this.halfExtents.copy(this.shape.data.halfExtents); + } else { + const box = getBox(this.el, this.mesh); + this.halfExtents = box.min + .clone() + .negate() + .add(box.max) + .multiplyScalar(0.51 / this.el.object3D.scale.x); + } + } this.cam.getWorldPosition(camWorldPos); let minSquareDistance = Infinity; let targetDir = this.dirs[0].dir; - let targetHalfExtent = halfExtents[this.dirs[0].halfExtent]; + let targetHalfExtentStr = this.dirs[0].halfExtent; + let targetHalfExtent = this.halfExtents[targetHalfExtentStr]; let targetRotation = this.dirs[0].rotation; for (let i = 0; i < this.dirs.length; i++) { const dir = this.dirs[i].dir; - const halfExtent = halfExtents[this.dirs[i].halfExtent]; + const halfExtentStr = this.dirs[i].halfExtent; + const halfExtent = this.halfExtents[halfExtentStr]; pointOnBoxFace.copy(dir).multiplyScalar(halfExtent); this.el.object3D.localToWorld(pointOnBoxFace); const squareDistance = pointOnBoxFace.distanceToSquared(camWorldPos); @@ -74,16 +96,13 @@ AFRAME.registerComponent("position-at-box-shape-border", { targetDir = dir; targetHalfExtent = halfExtent; targetRotation = this.dirs[i].rotation; + targetHalfExtentStr = halfExtentStr; } } - this.target.position.copy( - targetPosition - .copy(targetDir) - .multiplyScalar(targetHalfExtent) - .add(this.shape.data.offset) - ); + this.target.position.copy(targetPosition.copy(targetDir).multiplyScalar(targetHalfExtent)); this.target.rotation.set(0, targetRotation, 0); + this.target.scale.setScalar(this.halfExtents[inverseHalfExtents[targetHalfExtentStr]] * 4); }; })() }); diff --git a/src/components/sticky-object.js b/src/components/sticky-object.js index d415779078f6a0acb2d6975170cde39cbd80cf47..d84c82313100622864b9457161b71b842edf6309 100644 --- a/src/components/sticky-object.js +++ b/src/components/sticky-object.js @@ -1,6 +1,6 @@ /* global THREE, CANNON, AFRAME */ AFRAME.registerComponent("sticky-object", { - dependencies: ["body", "super-networked-interactable"], + dependencies: ["body"], schema: { autoLockOnLoad: { default: false }, @@ -17,24 +17,27 @@ AFRAME.registerComponent("sticky-object", { play() { this.el.addEventListener("grab-start", this._onGrab); this.el.addEventListener("grab-end", this._onRelease); - this.el.addEventListener("body-loaded", this._onBodyLoaded); + + if (this.hasSetupBodyLoaded) return; + this.hasSetupBodyLoaded = true; + + if (this.el.body) { + this._onBodyLoaded(); + } else { + this.el.addEventListener("body-loaded", this._onBodyLoaded, { once: true }); + } }, pause() { this.el.removeEventListener("grab-start", this._onGrab); this.el.removeEventListener("grab-end", this._onRelease); - this.el.removeEventListener("body-loaded", this._onBodyLoaded); }, setLocked(locked) { - if (!NAF.utils.isMine(this.el)) return; + if (this.el.components.networked && !NAF.utils.isMine(this.el)) return; - const mass = this.el.components["super-networked-interactable"].data.mass; this.locked = locked; - this.el.body.type = locked ? window.CANNON.Body.STATIC : window.CANNON.Body.DYNAMIC; - this.el.setAttribute("body", { - mass: locked ? 0 : mass - }); + this.el.setAttribute("body", { type: locked ? "static" : "dynamic" }); }, _onBodyLoaded() { @@ -45,6 +48,7 @@ AFRAME.registerComponent("sticky-object", { _onRelease() { if ( + !this.el.is("grabbed") && this.data.autoLockOnRelease && this.el.body.velocity.lengthSquared() < this.data.autoLockSpeedLimit * this.data.autoLockSpeedLimit ) { @@ -57,6 +61,7 @@ AFRAME.registerComponent("sticky-object", { }, remove() { + this.el.removeEventListener("body-loaded", this._onBodyLoaded); if (this.stuckTo) { const stuckTo = this.stuckTo; delete this.stuckTo; diff --git a/src/components/super-networked-interactable.js b/src/components/super-networked-interactable.js index 3b84bb7ac3b99421024113d527aa8a1ea54da725..844dc1df1706f2a51f55afd531e41898724fcb77 100644 --- a/src/components/super-networked-interactable.js +++ b/src/components/super-networked-interactable.js @@ -5,7 +5,6 @@ */ AFRAME.registerComponent("super-networked-interactable", { schema: { - mass: { default: 1 }, hapticsMassVelocityFactor: { default: 0.1 }, counter: { type: "selector" } }, @@ -18,7 +17,7 @@ AFRAME.registerComponent("super-networked-interactable", { NAF.utils.getNetworkedEntity(this.el).then(networkedEl => { this.networkedEl = networkedEl; if (!NAF.utils.isMine(networkedEl)) { - this.el.setAttribute("body", { type: "static", mass: 0 }); + this.el.setAttribute("body", { type: "static" }); } else { this.counter.register(networkedEl); } @@ -51,7 +50,7 @@ AFRAME.registerComponent("super-networked-interactable", { this.hand = e.detail.hand; if (this.networkedEl && !NAF.utils.isMine(this.networkedEl)) { if (NAF.utils.takeOwnership(this.networkedEl)) { - this.el.setAttribute("body", { type: "dynamic", mass: this.data.mass }); + this.el.setAttribute("body", { type: "dynamic" }); this.counter.register(this.networkedEl); } else { this.el.emit("grab-end", { hand: this.hand }); @@ -61,7 +60,7 @@ AFRAME.registerComponent("super-networked-interactable", { }, _onOwnershipLost: function() { - this.el.setAttribute("body", { type: "static", mass: 0 }); + this.el.setAttribute("body", { type: "static" }); this.el.emit("grab-end", { hand: this.hand }); this.hand = null; this.counter.deregister(this.el); diff --git a/src/components/super-spawner.js b/src/components/super-spawner.js index 08491ec0ea05f253c720b50e078c5895bd3f188f..a3d2df9137fe19999c04d66ba27e55ce90afc97e 100644 --- a/src/components/super-spawner.js +++ b/src/components/super-spawner.js @@ -1,3 +1,7 @@ +import { addMedia } from "../utils/media-utils"; +import { waitForEvent } from "../utils/async-utils"; + +let nextGrabId = 0; /** * Spawns networked objects when grabbed. * @namespace network @@ -5,108 +9,119 @@ */ AFRAME.registerComponent("super-spawner", { schema: { - template: { default: "" }, + /** + * Source of the media asset the spawner will spawn when grabbed. This can be a gltf, video, or image, or a url that the reticiulm media API can resolve to a gltf, video, or image. + */ + src: { default: "https://asset-bundles-prod.reticulum.io/interactables/Ducky/DuckyMesh-438ff8e022.gltf" }, + + /** + * Spawn the object at a custom position, rather than at the center of the spanwer. + */ useCustomSpawnPosition: { default: false }, spawnPosition: { type: "vec3" }, + + /** + * Spawn the object with a custom orientation, rather than copying that of the spawner. + */ useCustomSpawnRotation: { default: false }, spawnRotation: { type: "vec4" }, - events: { default: ["cursor-grab", "hand_grab"] }, - spawnCooldown: { default: 1 } + + /** + * The events to emit for programmatically grabbing and releasing objects + */ + grabEvents: { default: ["cursor-grab", "hand_grab"] }, + releaseEvents: { default: ["cursor-release", "hand_release"] }, + + /** + * The spawner will become invisible and ungrabbable for this ammount of time after being grabbed. This can prevent rapidly spawning objects. + */ + spawnCooldown: { default: 1 }, + + /** + * Center the spawned object on the hand that grabbed it after it finishes loading. By default the object will be grabbed relative to where the spawner was grabbed + */ + centerSpawnedObject: { default: false } }, - init: function() { - this.entities = new Map(); - this.timeout = null; + init() { + this.heldEntities = new Map(); + this.cooldownTimeout = null; + this.onGrabStart = this.onGrabStart.bind(this); + this.onGrabEnd = this.onGrabEnd.bind(this); }, - play: function() { - this.handleGrabStart = this._handleGrabStart.bind(this); - this.el.addEventListener("grab-start", this.handleGrabStart); + play() { + this.el.addEventListener("grab-start", this.onGrabStart); + this.el.addEventListener("grab-end", this.onGrabEnd); }, - pause: function() { - this.el.removeEventListener("grab-start", this.handleGrabStart); + pause() { + this.el.removeEventListener("grab-start", this.onGrabStart); + this.el.removeEventListener("grab-end", this.onGrabEnd); - if (this.timeout) { - clearTimeout(this.timeout); - this.timeout = null; + if (this.cooldownTimeout) { + clearTimeout(this.cooldownTimeout); + this.cooldownTimeout = null; this.el.setAttribute("visible", true); this.el.classList.add("interactable"); } }, - remove: function() { - for (const entity of this.entities.keys()) { - const data = this.entities.get(entity); - entity.removeEventListener("componentinitialized", data.componentinInitializedListener); - entity.removeEventListener("body-loaded", data.bodyLoadedListener); - } - - this.entities.clear(); + remove() { + this.heldEntities.clear(); }, - _handleGrabStart: function(e) { - if (this.timeout) { + async onGrabStart(e) { + if (this.cooldownTimeout) { return; } + + // This tells super-hands we are handling this grab. The user is now "grabbing" the spawner + e.preventDefault(); + const hand = e.detail.hand; - const entity = document.createElement("a-entity"); + const thisGrabId = nextGrabId++; + this.heldEntities.set(hand, thisGrabId); + + const entity = addMedia(this.data.src); + entity.object3D.position.copy( + this.data.useCustomSpawnPosition ? this.data.spawnPosition : this.el.object3D.position + ); + entity.object3D.rotation.copy( + this.data.useCustomSpawnRotation ? this.data.spawnRotation : this.el.object3D.rotation + ); - entity.setAttribute("networked", "template:" + this.data.template); + this.activateCooldown(); - const componentinInitializedListener = this._handleComponentInitialzed.bind(this, entity); - const bodyLoadedListener = this._handleBodyLoaded.bind(this, entity); - this.entities.set(entity, { - hand: hand, - componentInitialized: false, - bodyLoaded: false, - componentinInitializedListener: componentinInitializedListener, - bodyLoadedListener: bodyLoadedListener - }); + await waitForEvent("body-loaded", entity); - entity.addEventListener("componentinitialized", componentinInitializedListener); - entity.addEventListener("body-loaded", bodyLoadedListener); + // If we are still holding the spawner with the hand that grabbed to create this entity, release the spawner and grab the entity + if (this.heldEntities.get(hand) === thisGrabId) { + if (this.data.centerSpawnedObject) { + entity.body.position.copy(hand.object3D.position); + } + for (let i = 0; i < this.data.grabEvents.length; i++) { + hand.emit(this.data.releaseEvents[i]); + hand.emit(this.data.grabEvents[i], { targetEntity: entity }); + } + } + }, - const pos = this.data.useCustomSpawnPosition ? this.data.spawnPosition : this.el.getAttribute("position"); - entity.setAttribute("position", pos); - const rot = this.data.useCustomSpawnRotation ? this.data.spawnRotation : this.el.getAttribute("rotation"); - entity.setAttribute("rotation", rot); - this.el.sceneEl.appendChild(entity); + onGrabEnd(e) { + this.heldEntities.delete(e.detail.hand); + // This tells super-hands we are handling this release + e.preventDefault(); + }, + activateCooldown() { if (this.data.spawnCooldown > 0) { this.el.setAttribute("visible", false); this.el.classList.remove("interactable"); - this.timeout = setTimeout(() => { + this.cooldownTimeout = setTimeout(() => { this.el.setAttribute("visible", true); this.el.classList.add("interactable"); - this.timeout = null; + this.cooldownTimeout = null; }, this.data.spawnCooldown * 1000); } - }, - - _handleComponentInitialzed: function(entity, e) { - if (e.detail.name === "grabbable") { - this.entities.get(entity).componentInitialized = true; - this._emitEvents.call(this, entity); - } - }, - - _handleBodyLoaded: function(entity) { - this.entities.get(entity).bodyLoaded = true; - this._emitEvents.call(this, entity); - }, - - _emitEvents: function(entity) { - const data = this.entities.get(entity); - if (data.componentInitialized && data.bodyLoaded) { - for (let i = 0; i < this.data.events.length; i++) { - data.hand.emit(this.data.events[i], { targetEntity: entity }); - } - - entity.removeEventListener("componentinitialized", data.componentinInitializedListener); - entity.removeEventListener("body-loaded", data.bodyLoadedListener); - - this.entities.delete(entity); - } } }); diff --git a/src/hub.html b/src/hub.html index c37b5800001ee8437b0f5097734ff40e3185ba4e..c4ae4bec6bde9b22694ba9667193b3b2fb6a0f37 100644 --- a/src/hub.html +++ b/src/hub.html @@ -86,7 +86,6 @@ <img id="water-normal-map" crossorigin="anonymous" src="./assets/waternormals.jpg"> <!-- Templates --> - <template id="video-template"> <a-entity class="video" geometry="primitive: plane;" material="side: double; shader: flat;" networked-video-player></a-entity> </template> @@ -153,55 +152,18 @@ </a-entity> </template> - <template id="interactable-template"> + <template id="interactable-media"> <a-entity - gltf-model-plus="src: #interactable-duck; inflate: true;" class="interactable" - super-networked-interactable="counter: #counter; mass: 1;" - body="type: dynamic; shape: none; mass: 1;" - auto-scale-cannon-physics-body - grabbable - stretchable="useWorldPosition: true; usePhysics: never" - hoverable - duck - sticky-object="autoLockOnRelease: true;" - ></a-entity> - </template> - - <template id="interactable-model"> - <a-entity - gltf-model-plus="inflate: false;" - class="interactable" - super-networked-interactable="counter: #media-counter; mass: 1;" + super-networked-interactable="counter: #media-counter;" body="type: dynamic; shape: none; mass: 1;" grabbable stretchable="useWorldPosition: true; usePhysics: never" hoverable + auto-scale-cannon-physics-body sticky-object="autoLockOnRelease: true; autoLockOnLoad: true;" - auto-box-collider position-at-box-shape-border="target:.delete-button" - auto-scale-cannon-physics-body - > - <a-entity class="delete-button" visible-while-frozen scale="0.08 0.08 0.08"> - <a-entity mixin="rounded-text-button" remove-object-button position="0 0 0"> </a-entity> - <a-entity text=" value:Delete; width:2.5; align:center;" text-raycast-hack position="0 0 0.01"></a-entity> - </a-entity> - </a-entity> - </template> - - <template id="interactable-image"> - <a-entity - class="interactable" - super-networked-interactable="counter: #media-counter; mass: 1;" - body="type: dynamic; shape: none; mass: 1;" - auto-scale-cannon-physics-body - grabbable - stretchable="useWorldPosition: true; usePhysics: never" - hoverable - geometry="primitive: plane" - image-plus - sticky-object="autoLockOnLoad: true; autoLockOnRelease: true;" - position-at-box-shape-border="target:.delete-button;dirs:forward,back" + destroy-at-extreme-distances > <a-entity class="delete-button" visible-while-frozen> <a-entity mixin="rounded-text-button" remove-networked-object-button position="0 0 0"> </a-entity> @@ -241,7 +203,6 @@ </a-assets> <!-- Interactables --> - <a-entity id="counter" networked-counter="max: 3; ttl: 120"></a-entity> <a-entity id="media-counter" networked-counter="max: 10;"></a-entity> <a-entity diff --git a/src/hub.js b/src/hub.js index 4f4b205b97d3b393213ce59280f044a5b93e21a1..cf326c1491e276b73ac8437c985d5b1ac4035e50 100644 --- a/src/hub.js +++ b/src/hub.js @@ -64,7 +64,6 @@ import "./components/css-class"; import "./components/scene-shadow"; import "./components/avatar-replay"; import "./components/image-plus"; -import "./components/auto-box-collider"; import "./components/pinch-to-move"; import "./components/look-on-mobile"; import "./components/pitch-yaw-rotator"; @@ -73,6 +72,8 @@ import "./components/sticky-object"; import "./components/auto-scale-cannon-physics-body"; import "./components/position-at-box-shape-border"; import "./components/remove-networked-object-button"; +import "./components/destroy-at-extreme-distances"; +import "./components/media-loader"; import ReactDOM from "react-dom"; import React from "react"; @@ -297,8 +298,17 @@ const onReady = async () => { NAF.connection.entities.completeSync(ev.detail.clientId); }); + const offset = { x: 0, y: 0, z: -1.5 }; + const spawnMediaInfrontOfPlayer = url => { + const entity = addMedia(url, true); + entity.setAttribute("offset-relative-to", { + target: "#player-camera", + offset + }); + }; + scene.addEventListener("add_media", e => { - addMedia(e.detail); + spawnMediaInfrontOfPlayer(e.detail); }); if (qsTruthy("mediaTools")) { @@ -307,7 +317,7 @@ const onReady = async () => { const imgUrl = e.clipboardData.getData("text"); console.log("Pasted: ", imgUrl, e); - addMedia(imgUrl); + spawnMediaInfrontOfPlayer(imgUrl); }); document.addEventListener("dragover", e => { @@ -318,8 +328,8 @@ const onReady = async () => { e.preventDefault(); const imgUrl = e.dataTransfer.getData("url"); if (imgUrl) { - console.log("Droped: ", imgUrl); - addMedia(imgUrl); + console.log("Dropped: ", imgUrl); + spawnMediaInfrontOfPlayer(imgUrl); } }); } diff --git a/src/network-schemas.js b/src/network-schemas.js index a67b0d02381f0accc53472818dfdcac56951171a..5993934a4b0c613ca5b311d14c585fda7a44856c 100644 --- a/src/network-schemas.js +++ b/src/network-schemas.js @@ -82,22 +82,7 @@ function registerNetworkSchemas() { }); NAF.schemas.add({ - template: "#interactable-template", - components: [ - { - component: "position", - requiresNetworkUpdate: vectorRequiresUpdate(0.001) - }, - { - component: "rotation", - requiresNetworkUpdate: vectorRequiresUpdate(0.5) - }, - "scale" - ] - }); - - NAF.schemas.add({ - template: "#interactable-image", + template: "#interactable-media", components: [ { component: "position", @@ -108,14 +93,9 @@ function registerNetworkSchemas() { requiresNetworkUpdate: vectorRequiresUpdate(0.5) }, "scale", - "image-plus" + "media-loader" ] }); - - NAF.schemas.add({ - template: "#interactable-model", - components: ["position", "rotation", "scale", "gltf-model-plus"] - }); } export default registerNetworkSchemas; diff --git a/src/react-components/info-dialog.js b/src/react-components/info-dialog.js index 83b46b9dfee5569044581b44879566a13ec3c934..ff9b4da1b86f19c864bcab89b2eb997fc2eb6a1f 100644 --- a/src/react-components/info-dialog.js +++ b/src/react-components/info-dialog.js @@ -5,11 +5,10 @@ import PropTypes from "prop-types"; import { FormattedMessage } from "react-intl"; import formurlencoded from "form-urlencoded"; import LinkDialog from "./link-dialog.js"; +import MediaToolsDialog from "./media-tools-dialog.js"; // TODO i18n -let lastAddMediaUrl = ""; - class InfoDialog extends Component { static dialogTypes = { slack: Symbol("slack"), @@ -38,17 +37,14 @@ class InfoDialog extends Component { this.shareLink = `${loc.protocol}//${loc.host}${loc.pathname}`; this.onKeyDown = this.onKeyDown.bind(this); this.onContainerClicked = this.onContainerClicked.bind(this); - this.onAddMediaClicked = this.onAddMediaClicked.bind(this); } componentDidMount() { window.addEventListener("keydown", this.onKeyDown); - this.setState({ addMediaUrl: lastAddMediaUrl }); } componentWillUnmount() { window.removeEventListener("keydown", this.onKeyDown); - lastAddMediaUrl = this.state.addMediaUrl; } onKeyDown(e) { @@ -63,11 +59,6 @@ class InfoDialog extends Component { } } - onAddMediaClicked() { - this.props.onAddMedia(this.state.addMediaUrl); - this.props.onCloseDialog(); - } - shareLinkClicked = () => { navigator.share({ title: document.title, @@ -83,8 +74,7 @@ class InfoDialog extends Component { state = { mailingListEmail: "", mailingListPrivacy: false, - copyLinkButtonText: "Copy", - addMediaUrl: "" + copyLinkButtonText: "Copy" }; signUpForMailingList = async e => { @@ -199,28 +189,7 @@ class InfoDialog extends Component { break; case InfoDialog.dialogTypes.add_media: dialogTitle = "Add Media"; - dialogBody = ( - <div> - <div>Tip: You can paste media urls directly into hubs with ctrl+v</div> - <form onSubmit={this.onAddMediaClicked}> - <div className="add-media-form"> - <input - type="url" - placeholder="Image, Video, or GLTF URL" - className="add-media-form__link_field" - value={this.state.addMediaUrl} - onChange={e => this.setState({ addMediaUrl: e.target.value })} - required - /> - <div className="add-media-form__buttons"> - <button className="add-media-form__action-button"> - <span>Add</span> - </button> - </div> - </div> - </form> - </div> - ); + dialogBody = <MediaToolsDialog onAddMedia={this.props.onAddMedia} onCloseDialog={this.props.onCloseDialog} />; break; case InfoDialog.dialogTypes.updates: dialogTitle = ""; diff --git a/src/react-components/media-tools-dialog.js b/src/react-components/media-tools-dialog.js new file mode 100644 index 0000000000000000000000000000000000000000..e0753b2882b9462e44a49023df9171822fbccf02 --- /dev/null +++ b/src/react-components/media-tools-dialog.js @@ -0,0 +1,79 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +import giphyLogo from "../assets/images/giphy_logo.png"; + +const attributionHostnames = { + "giphy.com": giphyLogo +}; + +let lastAddMediaUrl = ""; +export default class MediaToolsDialog extends Component { + state = { + addMediaUrl: "" + }; + + static propTypes = { + onAddMedia: PropTypes.func, + onCloseDialog: PropTypes.func + }; + + constructor() { + super(); + this.onAddMediaClicked = this.onAddMediaClicked.bind(this); + this.onUrlChange = this.onUrlChange.bind(this); + } + + componentDidMount() { + this.setState({ addMediaUrl: lastAddMediaUrl }, () => { + this.onUrlChange({ target: this.input }); + }); + } + + componentWillUnmount() { + lastAddMediaUrl = this.state.addMediaUrl; + } + + onUrlChange(e) { + this.setState({ + addMediaUrl: e.target.value, + attributionImage: e.target.validity.valid && attributionHostnames[new URL(e.target.value).hostname] + }); + } + + onAddMediaClicked() { + this.props.onAddMedia(this.state.addMediaUrl); + this.props.onCloseDialog(); + } + + render() { + return ( + <div> + <div>Tip: You can paste media urls directly into hubs with ctrl+v</div> + <form onSubmit={this.onAddMediaClicked}> + <div className="add-media-form"> + <input + ref={el => (this.input = el)} + type="url" + placeholder="Image, Video, or GLTF URL" + className="add-media-form__link_field" + value={this.state.addMediaUrl} + onChange={this.onUrlChange} + required + /> + <div className="add-media-form__buttons"> + <button className="add-media-form__action-button"> + <span>Add</span> + </button> + </div> + {this.state.attributionImage ? ( + <div> + <img src={this.state.attributionImage} /> + </div> + ) : null} + </div> + </form> + </div> + ); + } +} diff --git a/src/utils/async-utils.js b/src/utils/async-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..99954f683ccbf14cb7360524a7b44b48db734c30 --- /dev/null +++ b/src/utils/async-utils.js @@ -0,0 +1,5 @@ +export const waitForEvent = function(eventName, eventObj) { + return new Promise(resolve => { + eventObj.addEventListener(eventName, resolve, { once: true }); + }); +}; diff --git a/src/utils/auto-box-collider.js b/src/utils/auto-box-collider.js new file mode 100644 index 0000000000000000000000000000000000000000..0a8b69037f59147065a141273616e30a034d6ee6 --- /dev/null +++ b/src/utils/auto-box-collider.js @@ -0,0 +1,21 @@ +const rotation = new THREE.Euler(); +export function getBox(entity, boxRoot) { + const box = new THREE.Box3(); + rotation.copy(entity.object3D.rotation); + entity.object3D.rotation.set(0, 0, 0); + entity.object3D.updateMatrixWorld(true); + box.setFromObject(boxRoot); + entity.object3D.worldToLocal(box.min); + entity.object3D.worldToLocal(box.max); + entity.object3D.rotation.copy(rotation); + return box; +} + +export function getScaleCoefficient(length, box) { + const { max, min } = box; + const dX = Math.abs(max.x - min.x); + const dY = Math.abs(max.y - min.y); + const dZ = Math.abs(max.z - min.z); + const lengthOfLongestComponent = Math.max(dX, dY, dZ); + return length / lengthOfLongestComponent; +} diff --git a/src/utils/media-utils.js b/src/utils/media-utils.js index 9c17813b926e5541eef96b586f8efb133e696f6c..e4f812b59dd04d2402905e97e8a6e0f29f4e85f9 100644 --- a/src/utils/media-utils.js +++ b/src/utils/media-utils.js @@ -1,6 +1,5 @@ const whitelistedHosts = [/^.*\.reticulum\.io$/, /^.*hubs\.mozilla\.com$/, /^hubs\.local$/]; const isHostWhitelisted = hostname => !!whitelistedHosts.filter(r => r.test(hostname)).length; - let resolveMediaUrl = "/api/v1/media"; if (process.env.NODE_ENV === "development") { resolveMediaUrl = `https://${process.env.DEV_RETICULUM_SERVER}${resolveMediaUrl}`; @@ -9,67 +8,23 @@ if (process.env.NODE_ENV === "development") { export const resolveFarsparkUrl = async url => { const parsedUrl = new URL(url); if ((parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") || isHostWhitelisted(parsedUrl.hostname)) - return url; + return { raw: url, origin: url }; - return (await fetch(resolveMediaUrl, { + return await fetch(resolveMediaUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ media: { url } }) - }).then(r => r.json())).raw; + }).then(r => r.json()); }; -const fetchContentType = async url => fetch(url, { method: "HEAD" }).then(r => r.headers.get("content-type")); let interactableId = 0; - -const offset = { x: 0, y: 0, z: -1.5 }; -export const spawnNetworkedImage = (src, contentType) => { - const scene = AFRAME.scenes[0]; - const image = document.createElement("a-entity"); - image.id = "interactable-image-" + interactableId++; - image.setAttribute("networked", { template: "#interactable-image" }); - image.setAttribute("offset-relative-to", { - target: "#player-camera", - offset: offset, - selfDestruct: true - }); - image.setAttribute("image-plus", { src, contentType }); - scene.appendChild(image); - return image; -}; - -export const spawnNetworkedInteractable = src => { +export const addMedia = (src, resize = false) => { const scene = AFRAME.scenes[0]; - const model = document.createElement("a-entity"); - model.id = "interactable-model-" + interactableId++; - model.setAttribute("networked", { template: "#interactable-model" }); - model.setAttribute("offset-relative-to", { - on: "model-loaded", - target: "#player-camera", - offset: offset, - selfDestruct: true - }); - model.setAttribute("gltf-model-plus", "src", src); - model.setAttribute("auto-box-collider", { resize: true }); - scene.appendChild(model); - return model; -}; - -export const addMedia = async url => { - try { - const farsparkUrl = await resolveFarsparkUrl(url); - console.log("resolved", url, farsparkUrl); - - const contentType = await fetchContentType(farsparkUrl); - if (contentType.startsWith("image/") || contentType.startsWith("video/")) { - spawnNetworkedImage(farsparkUrl, contentType); - } else if (contentType.startsWith("model/gltf") || url.endsWith(".gltf") || url.endsWith(".glb")) { - spawnNetworkedInteractable(farsparkUrl); - } else { - throw new Error(`Unsupported content type: ${contentType}`); - } - } catch (e) { - console.error("Error adding media", e); - spawnNetworkedImage("error"); - } + const entity = document.createElement("a-entity"); + entity.id = "interactable-media-" + interactableId++; + entity.setAttribute("networked", { template: "#interactable-media" }); + entity.setAttribute("media-loader", { src, resize }); + scene.appendChild(entity); + return entity; }; diff --git a/src/vendor/GLTFLoader.js b/src/vendor/GLTFLoader.js index 754ae5d9460e80e176a928cc53ea75ab7a6f5212..5e23d9250e1f917f1206398fa361115e4be77d28 100644 --- a/src/vendor/GLTFLoader.js +++ b/src/vendor/GLTFLoader.js @@ -40,7 +40,7 @@ THREE.GLTFLoader = ( function () { loader.setResponseType( 'arraybuffer' ); - var farsparkURL = await resolveFarsparkUrl(url); + var farsparkURL = (await resolveFarsparkUrl(url)).raw; loader.load( farsparkURL, function ( data ) { @@ -1623,7 +1623,7 @@ THREE.GLTFLoader = ( function () { var options = this.options; - var farsparkURL = await resolveFarsparkUrl(resolveURL(bufferDef.uri, options.path)); + var farsparkURL = (await resolveFarsparkUrl(resolveURL(bufferDef.uri, options.path))).raw; return new Promise( function ( resolve, reject ) { @@ -1823,7 +1823,7 @@ THREE.GLTFLoader = ( function () { var urlToLoad = resolveURL(sourceURI, options.path); if (!hasBufferView){ - urlToLoad = await resolveFarsparkUrl(urlToLoad); + urlToLoad = (await resolveFarsparkUrl(urlToLoad)).raw; } return Promise.resolve( sourceURI ).then( function ( sourceURI ) { diff --git a/webpack.config.js b/webpack.config.js index bddcd3303febc079720043ba172306e9cf41c182..a631e867c87dcbbeb4335443cb369421977813f8 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -134,7 +134,8 @@ const config = { loader: "worker-loader", options: { name: "assets/js/[name]-[hash].js", - publicPath: "/" + publicPath: "/", + inline: true } }, { diff --git a/yarn.lock b/yarn.lock index 4bd30225dc8626c30c5a590523bcd70c9626f655..eb95576ea64ee647be75accadf0e993c3cdab7bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -184,7 +184,7 @@ aframe-physics-extras@^0.1.3: "aframe-physics-system@https://github.com/mozillareality/aframe-physics-system#hubs/master": version "3.1.2" - resolved "https://github.com/mozillareality/aframe-physics-system#50f5deb1134eb0d43c0435d287eef7037818d3cc" + resolved "https://github.com/mozillareality/aframe-physics-system#e4767d90dd3b828f348eea93814a2ebb58c0aee3" dependencies: browserify "^14.3.0" budo "^10.0.3" @@ -5495,7 +5495,7 @@ 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#06236f794f83cfebdc4ea9f3a9e8a5804f5bdcf9" + resolved "https://github.com/mozillareality/networked-aframe#da92fd0068e9ef7430b3970733cec17dae910505" dependencies: buffered-interpolation "^0.2.4" easyrtc "1.1.0"