From 09d909521945d2e8df7ffab5e21eb6303bae64ff Mon Sep 17 00:00:00 2001 From: Greg Fodor Date: Sun, 11 Nov 2018 07:03:09 +0000 Subject: [PATCH] Add a bunch of killer animations --- package-lock.json | 5 + package.json | 1 + src/components/animation.js | 643 ++++++++++++++++++ src/components/pinnable.js | 21 + .../remove-networked-object-button.js | 13 +- src/hub.html | 2 + src/hub.js | 1 + src/utils/media-utils.js | 9 + 8 files changed, 694 insertions(+), 1 deletion(-) create mode 100644 src/components/animation.js diff --git a/package-lock.json b/package-lock.json index b71dbe9e1..e86a9d3b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -659,6 +659,11 @@ "resolved": "https://registry.npmjs.org/an-array/-/an-array-1.0.0.tgz", "integrity": "sha1-wSWlu4JXd4419LT2qpx9D6nkJmU=" }, + "animejs": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/animejs/-/animejs-2.2.0.tgz", + "integrity": "sha1-Ne79/FNbgZScnLBvCz5gwC5v3IA=" + }, "ansi-colors": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.0.5.tgz", diff --git a/package.json b/package.json index 40a158408..55b8f2ad4 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "aframe-rounded": "^1.0.3", "aframe-slice9-component": "^1.0.0", "aframe-teleport-controls": "github:mozillareality/aframe-teleport-controls#hubs/master", + "animejs": "^2.2.0", "classnames": "^2.2.5", "copy-to-clipboard": "^3.0.8", "deepmerge": "^2.1.1", diff --git a/src/components/animation.js b/src/components/animation.js new file mode 100644 index 000000000..f0a709b95 --- /dev/null +++ b/src/components/animation.js @@ -0,0 +1,643 @@ +// Taken from A-Frame 0.9.0 master, TODO remove + +const anime = require("animejs"); +const components = AFRAME.components; +const registerComponent = AFRAME.registerComponent; +const utils = AFRAME.utils; + +const colorHelperFrom = new THREE.Color(); +const colorHelperTo = new THREE.Color(); + +const getComponentProperty = utils.entity.getComponentProperty; +const setComponentProperty = utils.entity.setComponentProperty; +const splitCache = {}; + +const TYPE_COLOR = "color"; +const PROP_POSITION = "position"; +const PROP_ROTATION = "rotation"; +const PROP_SCALE = "scale"; +const STRING_COMPONENTS = "components"; +const STRING_OBJECT3D = "object3D"; + +/** + * Given property name, check schema to see what type we are animating. + * We just care whether the property is a vector. + */ +function getPropertyType(el, property) { + const split = property.split("."); + const componentName = split[0]; + const propertyName = split[1]; + const component = el.components[componentName] || components[componentName]; + + // Primitives. + if (!component) { + return null; + } + + // Dynamic schema. We only care about vectors anyways. + if (propertyName && !component.schema[propertyName]) { + return null; + } + + // Multi-prop. + if (propertyName) { + return component.schema[propertyName].type; + } + + // Single-prop. + return component.schema.type; +} + +/** + * Convert object to radians. + */ +function toRadians(obj) { + obj.x = THREE.Math.degToRad(obj.x); + obj.y = THREE.Math.degToRad(obj.y); + obj.z = THREE.Math.degToRad(obj.z); +} + +function addEventListeners(el, eventNames, handler) { + let i; + for (i = 0; i < eventNames.length; i++) { + el.addEventListener(eventNames[i], handler); + } +} + +function removeEventListeners(el, eventNames, handler) { + let i; + for (i = 0; i < eventNames.length; i++) { + el.removeEventListener(eventNames[i], handler); + } +} + +function splitDot(path) { + if (path in splitCache) { + return splitCache[path]; + } + splitCache[path] = path.split("."); + return splitCache[path]; +} + +function getRawProperty(el, path) { + let i; + let value; + const split = splitDot(path); + value = el; + for (i = 0; i < split.length; i++) { + value = value[split[i]]; + } + return value; +} + +function setRawProperty(el, path, value, type) { + let i; + + if (path.startsWith("object3D.rotation")) { + value = THREE.Math.degToRad(value); + } + + // Walk. + const split = splitDot(path); + let targetValue = el; + for (i = 0; i < split.length - 1; i++) { + targetValue = targetValue[split[i]]; + } + const propertyName = split[split.length - 1]; + + // Raw color. + if (type === TYPE_COLOR) { + if ("r" in targetValue[propertyName]) { + targetValue[propertyName].r = value.r; + targetValue[propertyName].g = value.g; + targetValue[propertyName].b = value.b; + } else { + targetValue[propertyName].x = value.r; + targetValue[propertyName].y = value.g; + targetValue[propertyName].z = value.b; + } + return; + } + + targetValue[propertyName] = value; +} + +function isRawProperty(data) { + return data.isRawProperty || data.property.startsWith(STRING_COMPONENTS) || data.property.startsWith(STRING_OBJECT3D); +} +/** + * Animation component for A-Frame using anime.js. + * + * The component manually controls the tick by setting `autoplay: false` on anime.js and + * manually * calling `animation.tick()` in the tick handler. To pause or resume, we toggle a + * boolean * flag * `isAnimationPlaying`. + * + * anime.js animation config for tweenining Javascript objects and values works as: + * + * config = { + * targets: {foo: 0.0, bar: '#000'}, + * foo: 1.0, + * bar: '#FFF' + * } + * + * The above will tween each property in `targets`. The `to` values are set in the root of + * the config. + * + * @member {object} animation - anime.js instance. + * @member {boolean} animationIsPlaying - Control if animation is playing. + */ +module.exports.Component = registerComponent("animation", { + schema: { + autoplay: { default: true }, + delay: { default: 0 }, + dir: { default: "" }, + dur: { default: 1000 }, + easing: { default: "easeInQuad" }, + elasticity: { default: 400 }, + enabled: { default: true }, + from: { default: "" }, + loop: { + default: 0, + parse: function(value) { + // Boolean or integer. + if (value === true || value === "true") { + return true; + } + if (value === false || value === "false") { + return false; + } + return parseInt(value, 10); + } + }, + property: { default: "" }, + startEvents: { type: "array" }, + pauseEvents: { type: "array" }, + resumeEvents: { type: "array" }, + round: { default: false }, + to: { default: "" }, + type: { default: "" }, + isRawProperty: { default: false } + }, + + multiple: true, + + init: function() { + const self = this; + + this.eventDetail = { name: this.attrName }; + this.time = 0; + + this.animation = null; + this.animationIsPlaying = false; + this.onStartEvent = this.onStartEvent.bind(this); + this.beginAnimation = this.beginAnimation.bind(this); + this.pauseAnimation = this.pauseAnimation.bind(this); + this.resumeAnimation = this.resumeAnimation.bind(this); + + this.fromColor = {}; + this.toColor = {}; + this.targets = {}; + this.targetsArray = []; + + this.updateConfigForDefault = this.updateConfigForDefault.bind(this); + this.updateConfigForRawColor = this.updateConfigForRawColor.bind(this); + + this.config = { + complete: function() { + self.animationIsPlaying = false; + self.el.emit("animationcomplete", self.eventDetail, false); + if (self.id) { + self.el.emit("animationcomplete__" + self.id, self.eventDetail, false); + } + } + }; + }, + + update: function(oldData) { + const config = this.config; + const data = this.data; + + this.animationIsPlaying = false; + + if (oldData.enabled && !this.data.enabled) { + return; + } + + if (!data.property) { + return; + } + + // Base config. + config.autoplay = false; + config.direction = data.dir; + config.duration = data.dur; + config.easing = data.easing; + config.elasticity = data.elasticity; + config.loop = data.loop; + config.round = data.round; + + // Start new animation. + this.createAndStartAnimation(); + }, + + tick: function(t, dt) { + if (!this.animationIsPlaying) { + return; + } + this.time += dt; + this.animation.tick(this.time); + }, + + remove: function() { + this.pauseAnimation(); + this.removeEventListeners(); + }, + + pause: function() { + this.paused = true; + this.pausedWasPlaying = true; + this.pauseAnimation(); + this.removeEventListeners(); + }, + + /** + * `play` handler only for resuming scene. + */ + play: function() { + if (!this.paused) { + return; + } + this.paused = false; + this.addEventListeners(); + if (this.pausedWasPlaying) { + this.resumeAnimation(); + this.pausedWasPlaying = false; + } + }, + + /** + * Start animation from scratch. + */ + createAndStartAnimation: function() { + const data = this.data; + + this.updateConfig(); + this.animationIsPlaying = false; + this.animation = anime(this.config); + + this.removeEventListeners(); + this.addEventListeners(); + + // Wait for start events for animation. + if (!data.autoplay || (data.startEvents && data.startEvents.length)) { + return; + } + + // Delay animation. + if (data.delay) { + setTimeout(this.beginAnimation, data.delay); + return; + } + + // Play animation. + this.beginAnimation(); + }, + + /** + * This is before animation start (including from startEvents). + * Set to initial state (config.from, time = 0, seekTime = 0). + */ + beginAnimation: function() { + this.updateConfig(); + this.time = 0; + this.animationIsPlaying = true; + this.stopRelatedAnimations(); + this.el.emit("animationbegin", this.eventDetail); + }, + + pauseAnimation: function() { + this.animationIsPlaying = false; + }, + + resumeAnimation: function() { + this.animationIsPlaying = true; + }, + + /** + * startEvents callback. + */ + onStartEvent: function() { + if (!this.data.enabled) { + return; + } + + this.updateConfig(); + if (this.animation) { + this.animation.pause(); + } + this.animation = anime(this.config); + + // Include the delay before each start event. + if (this.data.delay) { + setTimeout(this.beginAnimation, this.data.delay); + return; + } + this.beginAnimation(); + }, + + /** + * rawProperty: true and type: color; + */ + updateConfigForRawColor: function() { + const config = this.config; + const data = this.data; + const el = this.el; + let from; + let key; + let to; + + if (this.waitComponentInitRawProperty(this.updateConfigForRawColor)) { + return; + } + + from = data.from === "" ? getRawProperty(el, data.property) : data.from; + to = data.to; + + // Use r/g/b vector for color type. + this.setColorConfig(from, to); + from = this.fromColor; + to = this.toColor; + + this.targetsArray.length = 0; + this.targetsArray.push(from); + config.targets = this.targetsArray; + for (key in to) { + config[key] = to[key]; + } + + config.update = (function() { + const lastValue = {}; + return function(anim) { + const value = anim.animatables[0].target; + // For animation timeline. + if (value.r === lastValue.r && value.g === lastValue.g && value.b === lastValue.b) { + return; + } + + setRawProperty(el, data.property, value, data.type); + }; + })(); + }, + + /** + * Stuff property into generic `property` key. + */ + updateConfigForDefault: function() { + const config = this.config; + const data = this.data; + const el = this.el; + let from; + let to; + + if (this.waitComponentInitRawProperty(this.updateConfigForDefault)) { + return; + } + + if (data.from === "") { + // Infer from. + from = isRawProperty(data) ? getRawProperty(el, data.property) : getComponentProperty(el, data.property); + } else { + // Explicit from. + from = data.from; + } + + to = data.to; + + const isNumber = !isNaN(from || to); + if (isNumber) { + from = parseFloat(from); + to = parseFloat(to); + } else { + from = from ? from.toString() : from; + to = to ? to.toString() : to; + } + + // Convert booleans to integer to allow boolean flipping. + const isBoolean = data.to === "true" || data.to === "false" || data.to === true || data.to === false; + if (isBoolean) { + from = data.from === "true" || data.from === true ? 1 : 0; + to = data.to === "true" || data.to === true ? 1 : 0; + } + + this.targets.aframeProperty = from; + config.targets = this.targets; + config.aframeProperty = to; + config.update = (function() { + let lastValue; + + return function(anim) { + let value; + value = anim.animatables[0].target.aframeProperty; + + // Need to do a last value check for animation timeline since all the tweening + // begins simultaenously even if the value has not changed. Also better for perf + // anyways. + if (value === lastValue) { + return; + } + lastValue = value; + + if (isBoolean) { + value = value >= 1; + } + + if (isRawProperty(data)) { + setRawProperty(el, data.property, value, data.type); + } else { + setComponentProperty(el, data.property, value); + } + }; + })(); + }, + + /** + * Extend x/y/z/w onto the config. + * Update vector by modifying object3D. + */ + updateConfigForVector: function() { + const config = this.config; + const data = this.data; + const el = this.el; + let key; + + // Parse coordinates. + const from = + data.from !== "" + ? utils.coordinates.parse(data.from) // If data.from defined, use that. + : getComponentProperty(el, data.property); // If data.from not defined, get on the fly. + const to = utils.coordinates.parse(data.to); + + if (data.property === PROP_ROTATION) { + toRadians(from); + toRadians(to); + } + + // Set to and from. + this.targetsArray.length = 0; + this.targetsArray.push(from); + config.targets = this.targetsArray; + for (key in to) { + config[key] = to[key]; + } + + // If animating object3D transformation, run more optimized updater. + if (data.property === PROP_POSITION || data.property === PROP_ROTATION || data.property === PROP_SCALE) { + config.update = (function() { + const lastValue = {}; + return function(anim) { + const value = anim.animatables[0].target; + + if (data.property === PROP_SCALE) { + value.x = Math.max(0.0001, value.x); + value.y = Math.max(0.0001, value.y); + value.z = Math.max(0.0001, value.z); + } + + // For animation timeline. + if (value.x === lastValue.x && value.y === lastValue.y && value.z === lastValue.z) { + return; + } + + lastValue.x = value.x; + lastValue.y = value.y; + lastValue.z = value.z; + + el.object3D[data.property].set(value.x, value.y, value.z); + }; + })(); + return; + } + + // Animating some vector. + config.update = (function() { + const lastValue = {}; + return function(anim) { + const value = anim.animations[0].target; + + // Animate rotation through radians. + // For animation timeline. + if (value.x === lastValue.x && value.y === lastValue.y && value.z === lastValue.z) { + return; + } + lastValue.x = value.x; + lastValue.y = value.y; + lastValue.z = value.z; + setComponentProperty(el, data.property, value); + }; + })(); + }, + + /** + * Update the config before each run. + */ + updateConfig: function() { + // Route config type. + const propType = getPropertyType(this.el, this.data.property); + if (isRawProperty(this.data) && this.data.type === TYPE_COLOR) { + this.updateConfigForRawColor(); + } else if (propType === "vec2" || propType === "vec3" || propType === "vec4") { + this.updateConfigForVector(); + } else { + this.updateConfigForDefault(); + } + }, + + /** + * Wait for component to initialize. + */ + waitComponentInitRawProperty: function(cb) { + const data = this.data; + const el = this.el; + const self = this; + + if (data.from !== "") { + return false; + } + + if (!data.property.startsWith(STRING_COMPONENTS)) { + return false; + } + + const componentName = splitDot(data.property)[1]; + if (el.components[componentName]) { + return false; + } + + el.addEventListener("componentinitialized", function wait(evt) { + if (evt.detail.name !== componentName) { + return; + } + cb(); + // Since the config was created async, create the animation now since we missed it + // earlier. + self.animation = anime(self.config); + el.removeEventListener("componentinitialized", wait); + }); + return true; + }, + + /** + * Make sure two animations on the same property don't fight each other. + * e.g., animation__mouseenter="property: material.opacity" + * animation__mouseleave="property: material.opacity" + */ + stopRelatedAnimations: function() { + let component; + let componentName; + for (componentName in this.el.components) { + component = this.el.components[componentName]; + if (componentName === this.attrName) { + continue; + } + if (component.name !== "animation") { + continue; + } + if (!component.animationIsPlaying) { + continue; + } + if (component.data.property !== this.data.property) { + continue; + } + component.animationIsPlaying = false; + } + }, + + addEventListeners: function() { + const data = this.data; + const el = this.el; + addEventListeners(el, data.startEvents, this.onStartEvent); + addEventListeners(el, data.pauseEvents, this.pauseAnimation); + addEventListeners(el, data.resumeEvents, this.resumeAnimation); + }, + + removeEventListeners: function() { + const data = this.data; + const el = this.el; + removeEventListeners(el, data.startEvents, this.onStartEvent); + removeEventListeners(el, data.pauseEvents, this.pauseAnimation); + removeEventListeners(el, data.resumeEvents, this.resumeAnimation); + }, + + setColorConfig: function(from, to) { + colorHelperFrom.set(from); + colorHelperTo.set(to); + from = this.fromColor; + to = this.toColor; + from.r = colorHelperFrom.r; + from.g = colorHelperFrom.g; + from.b = colorHelperFrom.b; + to.r = colorHelperTo.r; + to.g = colorHelperTo.g; + to.b = colorHelperTo.b; + } +}); diff --git a/src/components/pinnable.js b/src/components/pinnable.js index e719c033c..3ffbece7f 100644 --- a/src/components/pinnable.js +++ b/src/components/pinnable.js @@ -42,6 +42,27 @@ AFRAME.registerComponent("pinnable", { _fireEvents() { if (this.data.pinned) { this.el.emit("pinned", { el: this.el }); + + this.el.removeAttribute("animation__pin-start"); + this.el.removeAttribute("animation__pin-end"); + const currentScale = this.el.object3D.scale; + + this.el.setAttribute("animation__pin-start", { + property: "scale", + dur: 100, + from: { x: currentScale.x, y: currentScale.y, z: currentScale.z }, + to: { x: currentScale.x * 1.1, y: currentScale.y * 1.1, z: currentScale.z * 1.1 }, + easing: "easeInOutElastic" + }); + + this.el.setAttribute("animation__pin-end", { + property: "scale", + delay: 150, + dur: 100, + from: { x: currentScale.x * 1.1, y: currentScale.y * 1.1, z: currentScale.z * 1.1 }, + to: { x: currentScale.x, y: currentScale.y, z: currentScale.z }, + easing: "easeInOutElastic" + }); } else { this.el.emit("unpinned", { el: this.el }); } diff --git a/src/components/remove-networked-object-button.js b/src/components/remove-networked-object-button.js index e3f4aa2c0..2dbf49a2a 100644 --- a/src/components/remove-networked-object-button.js +++ b/src/components/remove-networked-object-button.js @@ -3,7 +3,18 @@ AFRAME.registerComponent("remove-networked-object-button", { this.onClick = () => { if (!NAF.utils.isMine(this.targetEl) && !NAF.utils.takeOwnership(this.targetEl)) return; - this.targetEl.parentNode.removeChild(this.targetEl); + this.targetEl.setAttribute("animation__remove", { + property: "scale", + dur: 200, + to: { x: 0.01, y: 0.01, z: 0.01 }, + easing: "easeInQuad" + }); + + this.el.parentNode.setAttribute("visible", false); + + this.targetEl.addEventListener("animationcomplete", () => { + this.targetEl.parentNode.removeChild(this.targetEl); + }); }; NAF.utils.getNetworkedEntity(this.el).then(networkedEl => { diff --git a/src/hub.html b/src/hub.html index 6ecee5f04..6f6ea6d55 100644 --- a/src/hub.html +++ b/src/hub.html @@ -167,6 +167,7 @@ class="interactable" super-networked-interactable="counter: #media-counter;" body="type: dynamic; shape: none; mass: 1;" + animation__spawn="property: scale; dur: 200; from: 0.5 0.5 0.5; to: 1 1 1; easing: easeInQuad" grabbable stretchable="useWorldPosition: true; usePhysics: never" hoverable @@ -239,6 +240,7 @@ hoverable stretchable camera-tool + animation__spawn="property: scale; dur: 200; from: 0.5 0.5 0.5; to: 1 1 1; easing: easeInQuad" body="type: dynamic; shape: none; mass: 1;" shape="shape: box; halfExtents: 0.22 0.145 0.1; offset: 0 0.02 0" sticky-object="autoLockOnRelease: true; autoLockOnLoad: true; autoLockSpeedLimit: 0;" diff --git a/src/hub.js b/src/hub.js index 553f2a3a1..ee9f93bbe 100644 --- a/src/hub.js +++ b/src/hub.js @@ -67,6 +67,7 @@ import "./components/scene-sound"; import "./components/emit-state-change"; import "./components/action-to-event"; import "./components/stop-event-propagation"; +import "./components/animation"; import ReactDOM from "react-dom"; import React from "react"; diff --git a/src/utils/media-utils.js b/src/utils/media-utils.js index 74dfd96eb..e69048132 100644 --- a/src/utils/media-utils.js +++ b/src/utils/media-utils.js @@ -115,6 +115,15 @@ export const addMedia = (src, template, contentOrigin, resolve = false, resize = ["model-loaded", "video-loaded", "image-loaded"].forEach(eventName => { entity.addEventListener(eventName, () => { clearTimeout(fireLoadingTimeout); + + entity.setAttribute("animation__spawn-start", { + property: "scale", + dur: 300, + from: { x: 0.5, y: 0.5, z: 0.5 }, + to: { x: 1.0, y: 1.0, z: 1.0 }, + easing: "easeOutElastic" + }); + scene.emit("media-loaded", { src: src }); }); });