diff --git a/src/assets/avatars/BotDom_Avatar.glb b/src/assets/avatars/BotDom_Avatar.glb new file mode 100644 index 0000000000000000000000000000000000000000..9c72e3d2fc170d6b0782d741eca441a62780daad Binary files /dev/null and b/src/assets/avatars/BotDom_Avatar.glb differ diff --git a/src/assets/avatars/BotDom_Avatar_Unlit.glb b/src/assets/avatars/BotDom_Avatar_Unlit.glb new file mode 100644 index 0000000000000000000000000000000000000000..30b6af80ec3ab756eca52ff426e184890260e463 Binary files /dev/null and b/src/assets/avatars/BotDom_Avatar_Unlit.glb differ diff --git a/src/assets/avatars/BotGreg_Avatar.glb b/src/assets/avatars/BotGreg_Avatar.glb new file mode 100644 index 0000000000000000000000000000000000000000..92dc4e48291683d740b47c59871a62a8f1730ea4 Binary files /dev/null and b/src/assets/avatars/BotGreg_Avatar.glb differ diff --git a/src/assets/avatars/BotGreg_Avatar_Unlit.glb b/src/assets/avatars/BotGreg_Avatar_Unlit.glb new file mode 100644 index 0000000000000000000000000000000000000000..5aa65d9c30c90bdbacbe18df0a3a32041ef195f2 Binary files /dev/null and b/src/assets/avatars/BotGreg_Avatar_Unlit.glb differ diff --git a/src/assets/avatars/BotPinky_Avatar.glb b/src/assets/avatars/BotPinky_Avatar.glb new file mode 100644 index 0000000000000000000000000000000000000000..b41c3b4cadc810fddad7f13e15dcdf353f60a4df Binary files /dev/null and b/src/assets/avatars/BotPinky_Avatar.glb differ diff --git a/src/assets/avatars/BotPinky_Avatar_Unlit.glb b/src/assets/avatars/BotPinky_Avatar_Unlit.glb new file mode 100644 index 0000000000000000000000000000000000000000..4b4cc3d36a5e05c7f2f84a4ac45a10153299e404 Binary files /dev/null and b/src/assets/avatars/BotPinky_Avatar_Unlit.glb differ diff --git a/src/assets/avatars/BotRobert_Avatar.glb b/src/assets/avatars/BotRobert_Avatar.glb new file mode 100644 index 0000000000000000000000000000000000000000..8cb2a57c63234639c0913717f2e13610e4ecbb21 Binary files /dev/null and b/src/assets/avatars/BotRobert_Avatar.glb differ diff --git a/src/assets/avatars/BotRobert_Avatar_Unlit.glb b/src/assets/avatars/BotRobert_Avatar_Unlit.glb new file mode 100644 index 0000000000000000000000000000000000000000..e457f49cf3a5f5ad3e48399ec4a91b405e5bb598 Binary files /dev/null and b/src/assets/avatars/BotRobert_Avatar_Unlit.glb differ diff --git a/src/assets/avatars/avatars.json b/src/assets/avatars/avatars.json new file mode 100644 index 0000000000000000000000000000000000000000..04642a1278ad0ee48f6aba5d5621d19eb2f5e906 --- /dev/null +++ b/src/assets/avatars/avatars.json @@ -0,0 +1,39 @@ +{ + "avatars": [ + { + "id": "botdefault", + "models": { + "low": "BotDefault_Avatar_Unlit.glb", + "high": "BotDefault_Avatar.glb" + } + }, + { + "id": "botdom", + "models": { + "low": "BotDom_Avatar_Unlit.glb", + "high": "BotDom_Avatar.glb" + } + }, + { + "id": "botgreg", + "models": { + "low": "BotGreg_Avatar_Unlit.glb", + "high": "BotGreg_Avatar.glb" + } + }, + { + "id": "botpinky", + "models": { + "low": "BotPinky_Avatar_Unlit.glb", + "high": "BotPinky_Avatar.glb" + } + }, + { + "id": "botrobert", + "models": { + "low": "BotRobert_Avatar_Unlit.glb", + "high": "BotRobert_Avatar.glb" + } + } + ] +} diff --git a/src/assets/stylesheets/avatar-selector.scss b/src/assets/stylesheets/avatar-selector.scss new file mode 100644 index 0000000000000000000000000000000000000000..0f75add66f7f42953eccaa3d6279840b3e3f3e0c --- /dev/null +++ b/src/assets/stylesheets/avatar-selector.scss @@ -0,0 +1,27 @@ +#selector-root { + height: 100%; +} +.avatar-selector { + overflow: hidden; + height: 100%; + &__prev-button, &__next-button { + position: absolute; + top: 50%; + margin-top: -4em; + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + background: transparent; + color: white; + border: none; + } + &__prev-button { + left: -2em; + } + &__next-button { + right: -2em; + } + &__button-icon { + font-size: 84pt; + } +} diff --git a/src/assets/stylesheets/profile.scss b/src/assets/stylesheets/profile.scss index 05b2e5d801f42cee41a9e245eb57daed200798cc..408b55368b7cf2246a1fd5534e4b9b1e896fa826 100644 --- a/src/assets/stylesheets/profile.scss +++ b/src/assets/stylesheets/profile.scss @@ -8,16 +8,22 @@ align-items: center; display: flex; pointer-events: auto; + + &__avatar-selector { + border: none; + width: 280px; + height: 300px; + margin: 1em 0; + } } .profile-entry__box { - height: 150px; border-radius: 8px; display: flex; flex-direction: column; justify-content: space-between; align-items: center; - padding: 25px; + padding: 15px; flex: 1 1 100%; } @@ -42,6 +48,7 @@ line-height: 2.0em; padding-left: 1.25em; padding-right: 1.25em; + margin: 0.5em 0; } .profile-entry__form-submit { diff --git a/src/avatar-selector.html b/src/avatar-selector.html new file mode 100644 index 0000000000000000000000000000000000000000..8496ef94d7f95ec90d29ae5c7e3c030c15659238 --- /dev/null +++ b/src/avatar-selector.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="utf-8"> + <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"> + <% if(NODE_ENV === "production") { %> + <script src="https://cdn.rawgit.com/brianpeiris/aframe/845825ae694449524c185c44a314d361eead4680/dist/aframe-master.min.js"></script> + <% } else { %> + <script src="https://cdn.rawgit.com/brianpeiris/aframe/845825ae694449524c185c44a314d361eead4680/dist/aframe-master.js"></script> + <% } %> +</head> + +<body> + <div id="selector-root"></div> +</body> + +</html> diff --git a/src/avatar-selector.js b/src/avatar-selector.js new file mode 100644 index 0000000000000000000000000000000000000000..55ac8df6f8aff0d76a6e9fff96a922a3cd21b652 --- /dev/null +++ b/src/avatar-selector.js @@ -0,0 +1,45 @@ +import ReactDOM from "react-dom"; +import React from "react"; +import queryString from "query-string"; + +import "./assets/stylesheets/avatar-selector.scss"; +import "./vendor/GLTFLoader"; + +import "./components/animation-mixer"; +import "./components/audio-feedback"; +import "./components/loop-animation"; +import "./elements/a-progressive-asset"; +import "./gltf-component-mappings"; +import { avatars } from "./assets/avatars/avatars.json"; +import { avatarIds } from "./utils/identity"; + +import { App } from "./App"; +import AvatarSelector from "./react-components/avatar-selector"; + +window.APP = new App(); +const hash = queryString.parse(location.hash); +const isMobile = AFRAME.utils.device.isMobile(); +if (hash.quality) { + window.APP.quality = hash.quality; +} else { + window.APP.quality = isMobile ? "low" : "high"; +} + +const avatar = hash.avatar; + +function postAvatarToParent(newAvatar) { + window.parent.postMessage({avatar: newAvatar}, location.origin); +} + +function mountUI() { + const selector = ReactDOM.render( + <AvatarSelector {...{ avatars, avatar, onChange: postAvatarToParent }} />, + document.getElementById("selector-root") + ); + + window.addEventListener('hashchange', () => { + const hash = queryString.parse(location.hash); + selector.setState({avatar: hash.avatar}); + }); +} +document.addEventListener("DOMContentLoaded", mountUI); diff --git a/src/components/debug.js b/src/components/debug.js new file mode 100644 index 0000000000000000000000000000000000000000..77202f03295be677b9156e1ff16f11100ab19351 --- /dev/null +++ b/src/components/debug.js @@ -0,0 +1,25 @@ +AFRAME.registerComponent("lifecycle-checker", { + schema: { + tick: { default: false } + }, + init: function() { + console.log("init", this.el); + }, + update: function() { + console.log("update", this.el); + }, + tick: function() { + if (this.data.tick) { + console.log("tick", this.el); + } + }, + remove: function() { + console.log("remove", this.el); + }, + pause: function() { + console.log("pause", this.el); + }, + play: function() { + console.log("play", this.el); + } +}); diff --git a/src/components/ik-controller.js b/src/components/ik-controller.js index 3b4f323df72d83cfbe0d03ab00094318485eed22..ea8a1d216ad19cefc1b7d7450330888e1776c9c6 100644 --- a/src/components/ik-controller.js +++ b/src/components/ik-controller.js @@ -23,13 +23,16 @@ AFRAME.registerComponent("ik-root", { this.rightController = this.el.querySelector(this.data.rightController); updated = true; } - - if (updated) { - this.el.querySelector("[ik-controller]").components["ik-controller"].updateIkRoot(this); - } } }); +function findIKRoot(entity) { + while (entity && !(entity.components && entity.components["ik-root"])) { + entity = entity.parentNode; + } + return entity && entity.components["ik-root"]; +} + AFRAME.registerComponent("ik-controller", { schema: { leftEye: { type: "string", default: ".LeftEye" }, @@ -65,6 +68,8 @@ AFRAME.registerComponent("ik-controller", { this.rootToChest = new Matrix4(); this.invRootToChest = new Matrix4(); + this.ikRoot = findIKRoot(this.el); + this.hands = { left: { lastVisible: true, @@ -124,10 +129,6 @@ AFRAME.registerComponent("ik-controller", { .negate(); }, - updateIkRoot(ikRoot) { - this.ikRoot = ikRoot; - }, - tick(time, dt) { if (!this.ikRoot) { return; diff --git a/src/components/player-info.js b/src/components/player-info.js new file mode 100644 index 0000000000000000000000000000000000000000..717b1d2a96336f38ced2ce511b68b12f8acdcddf --- /dev/null +++ b/src/components/player-info.js @@ -0,0 +1,32 @@ +AFRAME.registerComponent("player-info", { + schema: { + displayName: { type: "string" }, + avatar: { type: "string" } + }, + init() { + this.applyProperties = this.applyProperties.bind(this); + }, + play() { + this.el.addEventListener("model-loaded", this.applyProperties); + }, + pause() { + this.el.removeEventListener("model-loaded", this.applyProperties); + }, + update(oldProps) { + this.applyProperties(); + }, + applyProperties() { + const nametagEl = this.el.querySelector(".nametag"); + console.log("updating properties", this.data, nametagEl); + if (this.data.displayName && nametagEl) { + nametagEl.setAttribute("text", { + value: this.data.displayName + }); + } + + const modelEl = this.el.querySelector(".model"); + if (this.data.avatar && modelEl) { + modelEl.setAttribute("src", this.data.avatar); + } + } +}); diff --git a/src/elements/a-gltf-entity.js b/src/elements/a-gltf-entity.js index 3c9c07031eecbf21069569b0f2738fe48afcea25..2c36e2fc0409053c73ac0b0fe8f14a22fd2f50cc 100644 --- a/src/elements/a-gltf-entity.js +++ b/src/elements/a-gltf-entity.js @@ -2,6 +2,9 @@ const GLTFCache = {}; AFRAME.AGLTFEntity = { defaultInflator(el, componentName, componentData) { + if (!AFRAME.components[componentName]) { + throw new Error(`Inflator failed. "${componentName}" component does not exist.`); + } if (AFRAME.components[componentName].multiple && Array.isArray(componentData)) { for (let i = 0; i < componentData.length; i++) { el.setAttribute(componentName + "__" + i, componentData[i]); @@ -22,19 +25,21 @@ AFRAME.AGLTFEntity = { // From https://gist.github.com/cdata/f2d7a6ccdec071839bc1954c32595e87 // Tracking glTF cloning here: https://github.com/mrdoob/three.js/issues/11573 function cloneGltf(gltf) { - const clone = { - animations: gltf.animations, - scene: gltf.scene.clone(true) - }; - const skinnedMeshes = {}; - gltf.scene.traverse(node => { + if (!node.name) { + node.name = node.uuid; + } if (node.isSkinnedMesh) { skinnedMeshes[node.name] = node; } }); + const clone = { + animations: gltf.animations, + scene: gltf.scene.clone(true) + }; + const cloneBones = {}; const cloneSkinnedMeshes = {}; @@ -68,17 +73,22 @@ function cloneGltf(gltf) { return clone; } -const inflateEntities = function(classPrefix, parentEl, node) { +const inflateEntities = function(parentEl, node) { // setObject3D mutates the node's parent, so we have to copy const children = node.children.slice(0); const el = document.createElement("a-entity"); // Remove invalid CSS class name characters. - const className = node.name.replace(/[^\w-]/g, ""); - el.classList.add(classPrefix + className); + const className = (node.name || node.uuid).replace(/[^\w-]/g, ""); + el.classList.add(className); parentEl.appendChild(el); + // AFRAME rotation component expects rotations in YXZ, convert it + if (node.rotation.order !== "YXZ") { + node.rotation.setFromQuaternion(node.quaternion, "YXZ"); + } + // Copy over transform to the THREE.Group and reset the actual transform of the Object3D el.setAttribute("position", { x: node.position.x, @@ -96,9 +106,8 @@ const inflateEntities = function(classPrefix, parentEl, node) { z: node.scale.z }); - node.position.set(0, 0, 0); - node.rotation.set(0, 0, 0); - node.scale.set(1, 1, 1); + node.matrixAutoUpdate = false; + node.matrix.identity(); el.setObject3D(node.type.toLowerCase(), node); @@ -116,7 +125,6 @@ const inflateEntities = function(classPrefix, parentEl, node) { } const entityComponents = node.userData.components; - if (entityComponents) { for (const prop in entityComponents) { if (entityComponents.hasOwnProperty(prop)) { @@ -130,116 +138,164 @@ const inflateEntities = function(classPrefix, parentEl, node) { } children.forEach(childNode => { - inflateEntities(classPrefix, el, childNode); + inflateEntities(el, childNode); }); -}; -function attachTemplate(templateEl) { - const selector = templateEl.getAttribute("data-selector"); - const targetEls = templateEl.parentNode.querySelectorAll(selector); - const clone = document.importNode(templateEl.content, true); - const templateRoot = clone.firstElementChild; + return el; +}; +function attachTemplate(root, { selector, templateRoot }) { + const targetEls = root.querySelectorAll(selector); for (const el of targetEls) { + const root = templateRoot.cloneNode(true); // Merge root element attributes with the target element - for (const { name, value } of templateRoot.attributes) { + for (const { name, value } of root.attributes) { el.setAttribute(name, value); } // Append all child elements - for (const child of templateRoot.children) { - el.appendChild(document.importNode(child, true)); + for (const child of root.children) { + el.appendChild(child); } } } +function cachedLoadGLTF(src, onProgress) { + return new Promise((resolve, reject) => { + // Load the gltf model from the cache if it exists. + if (GLTFCache[src]) { + // Use a cloned copy of the cached model. + resolve(cloneGltf(GLTFCache[src])); + } else { + // Otherwise load the new gltf model. + new THREE.GLTFLoader().load( + src, + model => { + if (!GLTFCache[src]) { + // Store a cloned copy of the gltf model. + GLTFCache[src] = cloneGltf(model); + } + resolve(model); + }, + onProgress, + reject + ); + } + }); +} + AFRAME.registerElement("a-gltf-entity", { prototype: Object.create(AFRAME.AEntity.prototype, { load: { - value() { + async value() { if (this.hasLoaded || !this.parentEl) { return; } - // Get the src url. - let src = this.getAttribute("src"); + // The code above and below this are from AEntity.prototype.load, we need to monkeypatch in gltf loading mid function + await this.loadTemplates(); + await this.setSrc(this.getAttribute("src")); + // - // If the src attribute is a selector, get the url from the asset item. - if (src.charAt(0) === "#") { - const assetEl = document.getElementById(src.substring(1)); - - const fallbackSrc = assetEl.getAttribute("src"); - const highSrc = assetEl.getAttribute("high-src"); - const lowSrc = assetEl.getAttribute("low-src"); + AFRAME.ANode.prototype.load.call(this, () => { + // Check if entity was detached while it was waiting to load. + if (!this.parentEl) { + return; + } - if (highSrc && window.APP.quality === "high") { - src = highSrc; - } else if (lowSrc && window.APP.quality === "low") { - src = lowSrc; - } else { - src = fallbackSrc; + this.updateComponents(); + if (this.isScene || this.parentEl.isPlaying) { + this.play(); } - } + }); + } + }, - const onLoad = gltfModel => { - if (!GLTFCache[src]) { - // Store a cloned copy of the gltf model. - GLTFCache[src] = cloneGltf(gltfModel); + loadTemplates: { + value() { + return new Promise((resolve, reject) => { + this.templates = []; + this.querySelectorAll(":scope > template").forEach(templateEl => { + this.templates.push({ + selector: templateEl.getAttribute("data-selector"), + templateRoot: document.importNode(templateEl.firstElementChild || templateEl.content.firstElementChild, true) + }) + }); + setTimeout(resolve, 0); + }); + } + }, + + setSrc: { + async value(src) { + try { + // If the src attribute is a selector, get the url from the asset item. + if (src && src.charAt(0) === "#") { + const assetEl = document.getElementById(src.substring(1)); + if (!assetEl) { return; } + + const fallbackSrc = assetEl.getAttribute("src"); + const highSrc = assetEl.getAttribute("high-src"); + const lowSrc = assetEl.getAttribute("low-src"); + + if (highSrc && window.APP.quality === "high") { + src = highSrc; + } else if (lowSrc && window.APP.quality === "low") { + src = lowSrc; + } else { + src = fallbackSrc; + } } - this.model = gltfModel.scene || gltfModel.scenes[0]; - this.model.animations = gltfModel.animations; + if (src === this.lastSrc) return; + this.lastSrc = src; - this.setObject3D("mesh", this.model); - this.emit("model-loaded", { format: "gltf", model: this.model }); + if (!src) return; - if (this.getAttribute("inflate")) { - inflate(this.model, finalizeLoad); - } else { - finalizeLoad(); - } - }; + const model = await cachedLoadGLTF(src); - const inflate = (model, callback) => { - inflateEntities("", this, model); - this.querySelectorAll(":scope > template").forEach(attachTemplate); + // If we started loading something else already + // TODO: there should be a way to cancel loading instead + if (src != this.lastSrc) return; - // Wait one tick for the appended custom elements to be connected before calling finalizeLoad - setTimeout(callback, 0); - }; + // If we had inflated something already before, clean that up + if (this.inflatedEl) { + this.inflatedEl.parentNode.removeChild(this.inflatedEl); + delete this.inflatedEl; + } - const finalizeLoad = () => { - AFRAME.ANode.prototype.load.call(this, () => { - // Check if entity was detached while it was waiting to load. - if (!this.parentEl) { - return; - } + this.model = model.scene || model.scenes[0]; + this.model.animations = model.animations; - this.updateComponents(); - if (this.isScene || this.parentEl.isPlaying) { - this.play(); - } - }); - }; + this.setObject3D("mesh", this.model); - // Load the gltf model from the cache if it exists. - const gltf = GLTFCache[src]; + if (this.getAttribute("inflate")) { + this.inflatedEl = inflateEntities(this, this.model); + this.templates.forEach(attachTemplate.bind(null, this)); + } - if (gltf) { - // Use a cloned copy of the cached model. - const clonedGltf = cloneGltf(gltf); - onLoad(clonedGltf); - return; + this.emit("model-loaded", { format: "gltf", model: this.model }); + } catch (e) { + console.error("Failed to load glTF model", e.message, this); + this.emit("model-error", { format: "gltf", src }); } + } + }, - // Otherwise load the new gltf model. - new THREE.GLTFLoader().load(src, onLoad, undefined /* onProgress */, error => { - // On glTF load error + attributeChangedCallback: { + value(attr, oldVal, newVal) { + if (attr === "src") { + this.setSrc(newVal); + } + } + }, - const message = error && error.message ? error.message : "Failed to load glTF model"; - console.warn(message); - this.emit("model-error", { format: "gltf", src }); - }); + setAttribute: { + value(attr, arg1, arg2) { + if (attr === "src") { + this.setSrc(arg1); + } + AFRAME.AEntity.prototype.setAttribute.call(this, attr, arg1, arg2); } } }) diff --git a/src/elements/a-progressive-asset.js b/src/elements/a-progressive-asset.js index a367e374aece30163bc73802ab8cf11950f28c1a..8bf922aa17b10ce6eec09baf413e93dcf6d871f1 100644 --- a/src/elements/a-progressive-asset.js +++ b/src/elements/a-progressive-asset.js @@ -9,17 +9,17 @@ AFRAME.registerElement("a-progressive-asset", { value() { this.data = null; this.isAssetItem = true; + } + }, + attachedCallback: { + value() { if (!this.parentNode.fileLoader) { throw new Error("a-progressive-asset must be the child of an a-assets element."); } this.fileLoader = this.parentNode.fileLoader; - } - }, - attachedCallback: { - value() { const self = this; const fallbackSrc = this.getAttribute("src"); const highSrc = this.getAttribute("high-src"); diff --git a/src/network-schemas.js b/src/network-schemas.js index 4632a5933807b1e97132c726fcc335afa6b68964..54cc0b63ba40efed17baf43c0d3554b1ea2fa4bc 100644 --- a/src/network-schemas.js +++ b/src/network-schemas.js @@ -5,6 +5,7 @@ function registerNetworkSchemas() { "position", "rotation", "scale", + "player-info", { selector: ".camera", component: "position" @@ -36,11 +37,6 @@ function registerNetworkSchemas() { { selector: ".right-controller", component: "visible" - }, - { - selector: ".nametag", - component: "text", - property: "value" } ] }); diff --git a/src/react-components/avatar-selector.js b/src/react-components/avatar-selector.js new file mode 100644 index 0000000000000000000000000000000000000000..203f855ad5c2ad8b022b34a7bde23f836fab7038 --- /dev/null +++ b/src/react-components/avatar-selector.js @@ -0,0 +1,98 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +class AvatarSelector extends Component { + static propTypes = { + avatars: PropTypes.array, + avatar: PropTypes.string, + onChange: PropTypes.func, + } + + constructor(props) { + super(props); + this.state = { avatar: this.props.avatar }; + } + + getAvatarIndex(direction=0) { + const currAvatarIndex = this.props.avatars.findIndex(avatar => avatar.id === this.state.avatar); + const numAvatars = this.props.avatars.length; + return ((currAvatarIndex + direction) % numAvatars + numAvatars) % numAvatars; + } + + nextAvatar = () => { + const newAvatarIndex = this.getAvatarIndex(1); + this.props.onChange(this.props.avatars[newAvatarIndex].id); + } + + prevAvatar = () => { + const newAvatarIndex = this.getAvatarIndex(-1); + this.props.onChange(this.props.avatars[newAvatarIndex].id); + } + + render () { + const avatarAssets = this.props.avatars.map(avatar => ( + <a-progressive-asset + id={avatar.id} + key={avatar.id} + response-type="arraybuffer" + high-src={`./src/assets/avatars/${avatar.models.high}`} + low-src={`./src/assets/avatars/${avatar.models.low}`} + ></a-progressive-asset> + )); + + const avatarEntities = this.props.avatars.map((avatar, i) => ( + <a-entity key={avatar.id} position="0 0 0" rotation={`0 ${360 * i / this.props.avatars.length} 0`}> + <a-gltf-entity position="0 0 5" rotation="0 180 0" src={'#' + avatar.id} inflate="true"> + <template data-selector=".RootScene"> + <a-entity animation-mixer></a-entity> + </template> + <a-animation attribute="rotation" dur="2000" to="0 -180 0" fill="forwards" repeat="indefinite"></a-animation> + </a-gltf-entity> + </a-entity> + )); + + return ( + <div className="avatar-selector"> + <a-scene vr-mode-ui="enabled: false" debug> + <a-assets> + {avatarAssets} + <a-asset-item + id="meeting-space1-mesh" + response-type="arraybuffer" + src="./src/assets/environments/MeetingSpace1_mesh.glb" + ></a-asset-item> + </a-assets> + + <a-entity rotation={`0 ${360 * this.getAvatarIndex() / this.props.avatars.length - 180} 0`}> + {avatarEntities} + </a-entity> + + <a-entity position="0 1.5 -6.6" rotation="-10 180 0" camera></a-entity> + + <a-entity + hide-when-quality="low" + light="type: directional; color: #F9FFCE; intensity: 0.6" + position="0 5 -15" + ></a-entity> + <a-entity + hide-when-quality="low" + light="type: ambient; color: #FFF" + ></a-entity> + <a-gltf-entity + id="meeting-space" + src="#meeting-space1-mesh" + position="0 0 0" + ></a-gltf-entity> + </a-scene> + <button className="avatar-selector__prev-button" onClick={this.nextAvatar}> + <i className="avatar-selector__button-icon material-icons">keyboard_arrow_left</i> + </button> + <button className="avatar-selector__next-button" onClick={this.prevAvatar}> + <i className="avatar-selector__button-icon material-icons">keyboard_arrow_right</i> + </button> + </div> + ); + } +} + +export default AvatarSelector; diff --git a/src/react-components/profile-entry-panel.js b/src/react-components/profile-entry-panel.js index 28c8cd7d9ce8c8bf9902f6a654c835bbd7fe95ea..45721138607615d4ded41ecc4fcced174df9b20d 100644 --- a/src/react-components/profile-entry-panel.js +++ b/src/react-components/profile-entry-panel.js @@ -13,29 +13,49 @@ class ProfileEntryPanel extends Component { constructor(props) { super(props); window.store = this.props.store; - this.state = {name: this.props.store.state.profile.display_name}; + this.state = { + display_name: this.props.store.state.profile.display_name, + avatar: this.props.store.state.profile.avatar, + }; this.props.store.addEventListener("statechanged", this.storeUpdated); } storeUpdated = () => { - this.setState({name: this.props.store.state.profile.display_name}); + this.setState({ + display_name: this.props.store.state.profile.display_name, + avatar: this.props.store.state.profile.avatar, + }); } - saveName = (e) => { + saveStateAndFinish = (e) => { e.preventDefault(); - this.props.store.update({ profile: { display_name: this.nameInput.value } }); + this.props.store.update({profile: { + display_name: this.state.display_name, + avatar: this.state.avatar + }}); this.props.finished(); } + stopPropagation = (e) => { + e.stopPropagation(); + } + componentDidMount() { // stop propagation so that avatar doesn't move when wasd'ing during text input. - this.nameInput.addEventListener('keydown', e => e.stopPropagation()); - this.nameInput.addEventListener('keypress', e => e.stopPropagation()); - this.nameInput.addEventListener('keyup', e => e.stopPropagation()); + this.nameInput.addEventListener('keydown', this.stopPropagation); + this.nameInput.addEventListener('keypress', this.stopPropagation); + this.nameInput.addEventListener('keyup', this.stopPropagation); + window.addEventListener('message', (e) => { + if (e.source !== this.avatarSelector.contentWindow) { return; } + this.setState({avatar: e.data.avatar}); + }); } componentWillUnmount() { this.props.store.removeEventListener('statechanged', this.storeUpdated); + this.nameInput.removeEventListener('keydown', this.stopPropagation); + this.nameInput.removeEventListener('keypress', this.stopPropagation); + this.nameInput.removeEventListener('keyup', this.stopPropagation); } render () { @@ -43,19 +63,23 @@ class ProfileEntryPanel extends Component { return ( <div className="profile-entry"> - <form onSubmit={this.saveName}> + <form onSubmit={this.saveStateAndFinish}> <div className="profile-entry__box profile-entry__box--darkened"> <div className="profile-entry__subtitle"> <FormattedMessage id="profile.header"/> </div> <input className="profile-entry__form-field-text" - value={this.state.name} onChange={(e) => this.setState({name: e.target.value})} + value={this.state.display_name} onChange={(e) => this.setState({display_name: e.target.value})} required pattern={SCHEMA.definitions.profile.properties.display_name.pattern} title={formatMessage({ id: "profile.display_name.validation_warning" })} ref={inp => this.nameInput = inp}/> + <iframe + className="profile-entry__avatar-selector" + src={`avatar-selector.html#avatar=${this.state.avatar}`} + ref={ifr => this.avatarSelector = ifr}>loading...</iframe> <input className="profile-entry__form-submit" type="submit" value={formatMessage({ id: "profile.save" }) }/> - </div> + </div> </form> </div> ); diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js index e2f4c2a1629def812e484f2617b07d3c61548d2a..9a7118ad4cfcae9badbaa238621f2124444d39c8 100644 --- a/src/react-components/ui-root.js +++ b/src/react-components/ui-root.js @@ -90,7 +90,7 @@ class UIRoot extends Component { sceneLoaded: false, exited: false, - showProfileEntry: false + showProfileEntry: true } componentDidMount() { diff --git a/src/room.html b/src/room.html index 52fd9ddd2a9ab2cdc4b54dba0f0093c55dee0d7f..0bc4d32f480927adc71b3d6fee8f06ed1891a60b 100644 --- a/src/room.html +++ b/src/room.html @@ -30,7 +30,7 @@ high-src="./assets/avatars/BotDefault_Avatar.glb" low-src="./assets/avatars/BotDefault_Avatar_Unlit.glb" ></a-progressive-asset> - + <a-asset-item id="bot-dom-mesh" response-type="arraybuffer" src="./assets/avatars/BotDom_Avatar.glb"></a-asset-item> <a-asset-item id="watch-model" response-type="arraybuffer" src="./assets/hud/watch.glb"></a-asset-item> <a-asset-item id="meeting-space1-mesh" response-type="arraybuffer" src="./assets/environments/MeetingSpace1_mesh.glb"></a-asset-item> @@ -48,16 +48,16 @@ </template> <template id="remote-avatar-template"> - <a-entity ik-root> + <a-entity ik-root player-info> <a-entity class="camera"></a-entity> <a-entity class="left-controller"></a-entity> <a-entity class="right-controller"></a-entity> - <a-gltf-entity src="#bot-skinned-mesh" inflate="true" ik-controller > + <a-gltf-entity class="model" inflate="true"> <template data-selector=".RootScene"> - <a-entity animation-mixer ></a-entity> + <a-entity ik-controller animation-mixer></a-entity> </template> <template data-selector=".Neck"> @@ -81,7 +81,7 @@ </a-entity> </template> - <template selector=".LeftHand"> + <template data-selector=".LeftHand"> <a-entity personal-space-invader ></a-entity> </template> @@ -147,6 +147,7 @@ wasd-to-analog2d character-controller="pivot: #player-camera" ik-root + player-info > <a-entity id="player-camera" @@ -181,9 +182,13 @@ haptic-feedback ></a-entity> - <a-gltf-entity src="#bot-skinned-mesh" inflate="true" ik-controller > + <a-gltf-entity class="model" inflate="true"> <template data-selector=".RootScene"> - <a-entity animation-mixer animated-robot-hands ></a-entity> + <a-entity + ik-controller + animated-robot-hands + animation-mixer + ></a-entity> </template> <template data-selector=".Neck"> diff --git a/src/room.js b/src/room.js index b09ff896a09145e54bb76ea45f9f659606eaa022..48a33fea05c6a0218cc3761a4a6f1fd64d0bbb02 100644 --- a/src/room.js +++ b/src/room.js @@ -37,6 +37,8 @@ import "./components/layers"; import "./components/spawn-controller"; import "./components/animated-robot-hands"; import "./components/hide-when-quality"; +import "./components/player-info"; +import "./components/debug"; import "./components/animation-mixer"; import "./components/loop-animation"; @@ -128,9 +130,11 @@ async function exitScene() { document.body.removeChild(scene); } -function setNameTagFromStore() { - const myNametag = document.querySelector("#player-rig .nametag"); - myNametag.setAttribute("text", "value", store.state.profile.display_name); +function applyProfile(playerRig) { + playerRig.setAttribute("player-info", { + displayName: store.state.profile.display_name, + avatar: store.state.profile.avatar || "#bot-skinned-mesh" + }); } async function enterScene(mediaStream, enterInVR) { @@ -148,6 +152,7 @@ async function enterScene(mediaStream, enterInVR) { document.querySelector("#player-camera").setAttribute("look-controls", "pointerLockEnabled: true;"); const qs = queryString.parse(location.search); + const playerRig = document.querySelector("#player-rig"); scene.setAttribute("networked-scene", { room: qs.room && !isNaN(parseInt(qs.room)) ? parseInt(qs.room) : 1, @@ -159,12 +164,12 @@ async function enterScene(mediaStream, enterInVR) { } if (isMobile || qs.mobile) { - const playerRig = document.querySelector("#player-rig"); playerRig.setAttribute("virtual-gamepad-controls", {}); } - setNameTagFromStore(); - store.addEventListener('statechanged', setNameTagFromStore); + const applyProfileOnPlayerRig = applyProfile.bind(null, playerRig); + applyProfileOnPlayerRig(); + store.addEventListener("statechanged", applyProfileOnPlayerRig); const avatarScale = parseInt(qs.avatarScale, 10); diff --git a/src/storage/store.js b/src/storage/store.js index 5b00b7ac8f5570c28651587997ae4c0546a3b722..7e7d1a722e7aa53c581b7fe1498a132d7e7257e5 100644 --- a/src/storage/store.js +++ b/src/storage/store.js @@ -17,6 +17,7 @@ export const SCHEMA = { additionalProperties: false, properties: { display_name: { type: "string", pattern: "^[A-Za-z0-9-]{3,32}$" }, + avatar: { type: "string" }, } } }, diff --git a/src/utils/identity.js b/src/utils/identity.js index 72cabdf008bc2574b4408d62c8f7f17f30fc9a59..b1d5148251d76b637b9ce0201d5f2b7e28fbcff2 100644 --- a/src/utils/identity.js +++ b/src/utils/identity.js @@ -1,3 +1,5 @@ +import { avatars } from "../assets/avatars/avatars.json"; + const names = [ "albattani", "allen", @@ -161,7 +163,16 @@ const names = [ "yonath" ]; +function selectRandom(arr) { + return arr[Math.floor(Math.random() * arr.length)] +} + +export const avatarIds = avatars.map(av => av.id); + export function generateDefaultProfile() { - const name = names[Math.floor(Math.random() * names.length)]; - return { display_name: name.replace(/^./, name[0].toUpperCase()) }; + const name = selectRandom(names); + return { + display_name: name.replace(/^./, name[0].toUpperCase()) , + avatar: selectRandom(avatarIds) + }; } diff --git a/webpack.config.js b/webpack.config.js index 4996b8925731f3e5912b954dafa5c6b1dc2a66c2..d404babbb6d5af438271621b4e3f2fbac235a266 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -77,6 +77,7 @@ const config = { entry: { lobby: path.join(__dirname, "src", "lobby.js"), room: path.join(__dirname, "src", "room.js"), + 'avatar-selector': path.join(__dirname, "src", "avatar-selector.js"), onboarding: path.join(__dirname, "src", "onboarding.js") }, output: { @@ -196,6 +197,12 @@ const config = { chunks: ["room"], inject: "head" }), + new HTMLWebpackPlugin({ + filename: "avatar-selector.html", + template: path.join(__dirname, "src", "avatar-selector.html"), + chunks: ["avatar-selector"], + inject: "head" + }), new HTMLWebpackPlugin({ filename: "onboarding.html", template: path.join(__dirname, "src", "onboarding.html"),