UNPKG

@vatom/3d-face

Version:

This vAtom face can plug into the SDKs to render 3D content in either binary glTF or V3D format.

453 lines (319 loc) 14.7 kB
// // Manages the animation being played by the scene const get = require('lodash/get') const ShowDebugLogs = false module.exports = class AnimationManager { /** * Manages and executes animations. * * @param {THREE.Object3D} scene The root object for animations. * @param {THREE.AnimationClip[]} clips Animation clips * @param {object[]} rules Set of animation_rules attached to the vatom * @param {object} objectState The vatom's raw payload * @param {THREE.AudioListener} audioListener Audio listener for the scene */ constructor(scene, clips, rules, objectState = {}, audioListener) { // Store vars this.scene = scene this.clips = clips || [] this.rules = rules || [] this.audioListener = audioListener if (ShowDebugLogs) console.debug(`[Animation Manager] Loaded, rules =`, rules) // Currently playing animation this.currentAnimation = "" // Stores pending and completed audio buffer promises this.audioBufferPromises = {} // Stores the last playback time of a sound. This helps prevent playing back multiple times after loading. this.audioPlayTimes = {} // Delayed actions. Each object contains an `at` timestamp and a `rule` to perform. this.delayedActions = [] // Create animation mixer this.mixer = new THREE.AnimationMixer(scene) this.mixer.addEventListener("finished", this.onAnimationFinished.bind(this)) // Trigger event this.onStart() // Trigger state changed event this.onStateChanged(objectState) // Set by the user of this class, will be called when an animation rule requests to perform an action. this.requestingPerformAction = null // Set by the user of this class, will be called to map a resource name to a URL this.requestingResourceURL = null // Set by the user of this class, will be called when an alert needs to be displayed to the user. If null, will use the browser's `alert()` instead. this.requestingAlert = null // Set by the user of this class, will be called when an animation rule passes a custom value to the host app. This can be used to trigger host actions, such as closing a window, etc. this.requestingCustomAction = null // Set by the user of this class, will be called when an animation rule wants to remove this object. this.requestingRemove = null } /** Must be called every frame by the renderer */ update(delta) { // Update animation mixer this.mixer.update(delta) // Perform any delayed actions while (this.delayedActions.length > 0 && this.delayedActions[0].at <= Date.now()) { // Perform this delayed action now let action = this.delayedActions.shift() this.performAction(action.rule, true) } } /** Plays the specified named animation */ play(name) { // Expand vars in name name = this.expandVars(name) // Check if currently playing animation name matches this one if (name == this.currentAnimation) return // Find new animation var anim = this.clips.find(c => c.name == name) if (!anim) return console.warn(`[Animation Manager] Unable to find animation with the name ${name}. Ignoring.`) // Stop all current animations this.mixer.stopAllAction() // HACK: For some reason the animation just won't play if you call play() a second time? Let's just remove all // cached actions until this can be sorted. for (let clip of this.clips) this.mixer.uncacheClip(clip) // Play this one if (ShowDebugLogs) console.debug(`[Animation Manager] Playing animation = ${name}`) this.currentAnimation = name let action = this.mixer.clipAction(anim) action.clampWhenFinished = true action.setLoop(THREE.LoopOnce) action.reset() action.play() // Check for "animation-start" events (also prevent replaying the animation again) this.performActions(this.rules.filter(r => r.on == 'animation-start' && r.target == name && r.play != name)) } /** Expand variables in the string */ expandVars(str) { // Ensure it's a string if (!str || !str.substring) return str // Create variable context let ctx = { result: this.latestResult } // Go through each item, maximum of 100 vars for (let count = 0 ; count < 100 ; count++) { // Find brackets let startIdx = str.indexOf('${') let endIdx = str.indexOf('}', startIdx) if (startIdx == -1 || endIdx == -1) break // Get value let path = str.substring(startIdx + 2, endIdx) let value = get(ctx, path) // Replace in string str = str.substring(0, startIdx) + value + str.substring(endIdx + 1) } // Done return str } /** Display an alert */ showAlert(text, title) { // Expand vars text = this.expandVars(text) title = this.expandVars(title) // Show alert, either by asking the host to show it, or just using the browser alert if (this.requestingAlert) this.requestingAlert(text, title) else alert(`${title} - ${text}`) } /** Perform the specified actions from the rules specified */ performActions(rules) { // Perform all rules that don't have a condition rules.filter(r => !r.condition).map(r => this.performAction(r)) // Go through each rule with a condition for (let rule of rules.filter(r => r.condition)) { // Get components let matched = /(.*)(==|!=)(.*)/g.exec(rule.condition) let left = matched && matched[1] || "" let type = matched && matched[2] || "" let right = matched && matched[3] || "" // Expand vars left = this.expandVars(left) right = this.expandVars(right) // Check type let passes = false if (!type && !right && left == "true") { // Always match passes = true } else if (type == "==") { // Check if equal passes = left == right } else if (type == "!=") { // Check if unequal passes = left != right } // Stop if didn't pass if (!passes) { console.log('[Animation Manager] Condition did not pass', left, type, right) continue } // Passed, run it console.log('[Animation Manager] Condition passed', left, type, right) this.performAction(rule) return } } /** Perform the specified action(s) from the rule payload */ performAction(rule, isDelayedAlready) { // Execute action if (ShowDebugLogs) { let info = [] if (rule.delay) info.push('delay = ' + rule.delay) if (rule.play) info.push('animation = ' + rule.play) if (rule.sound) info.push('sound = ' + rule.sound.resource_name) if (rule.action) info.push('action = ' + rule.action.name) console.debug(`[Animation Manager] ${rule.delay && !isDelayedAlready ? 'Delaying' : 'Executing'} ${rule.on} rule: ${info.join(', ')}`) } // Do delay if necessary if (rule.delay && !isDelayedAlready) return this.delayedActions.push({ rule, at: Date.now() + rule.delay }) // Trigger animation if (rule.play) this.play(rule.play) // Pass custom data to the host if (rule.custom && !this.requestingCustomAction) console.warn(`[Animation Manager] Tried to perform custom action, but requestingCustomAction is not supplied by the host. custom = ${this.expandVars(rule.custom)}`) else if (rule.custom) this.requestingCustomAction(this.expandVars(rule.custom)) // Show alert if (rule.alert) this.showAlert(rule.alert.text, rule.alert.title) // Trigger action if (rule.action) { // Send action this.runningVatomAction = Promise.resolve().then(e => this.requestingPerformAction(rule.action)).then(result => { // Action success, play related animations if (ShowDebugLogs) console.debug(`[Animation Manager] ${rule.action.name} action complete`, result) this.latestResult = result this.performActions(this.rules.filter(r => r.on == 'action-complete' && (!r.target || r.target == rule.action.name))) }).catch(err => { // Action failed, play related animations console.warn(`[Animation Manager] ${rule.action.name} action failed: ${err.message}`) let failRules = this.rules.filter(r => r.on == 'action-fail' && (!r.target || r.target == rule.action.name)) if (failRules.length == 0) this.showAlert(err.message, "There was a problem") else this.performActions(failRules) }).then(e => { // Remove running action this.runningVatomAction = null }) } // Play audio if (rule.sound) { let soundResource = this.expandVars(rule.sound.resource_name) this.fetchAudioBuffer(this.expandVars(soundResource)).then(buffer => { // Prevent overkill if (ShowDebugLogs) console.debug(`[Animation Manager] Playing sound = ${soundResource}`) let lastPlayTime = this.audioPlayTimes[soundResource] || 0 if (Date.now() - lastPlayTime < 100) return this.audioPlayTimes[soundResource] = Date.now() // Check which class to create let sound = rule.sound.is_positional ? new THREE.PositionalAudio(this.audioListener) : new THREE.Audio(this.audioListener) // Set buffer sound.setBuffer(buffer) // Set volume let volume = parseFloat(rule.sound.volume) if (volume) sound.setVolume(volume) // Add to scene if positional if (rule.sound.is_positional) { // Add to scene this.scene.add(sound) // Remove from scene once sound has finished playing sound.onEnded = e => { this.scene.remove(sound) } } // Play sound.play() }) } // Remove object if requested if (rule.remove && this.requestingRemove) this.requestingRemove() } /** Fetch audio buffer */ fetchAudioBuffer(resourceName) { // Check if one exists already if (this.audioBufferPromises[resourceName]) return this.audioBufferPromises[resourceName] // Create promise chain let promise = Promise.resolve().then(e => this.requestingResourceURL(resourceName)).then(url => { // Load resource return new Promise((resolve, reject) => new THREE.AudioLoader().load(url, resolve, null, reject)) }) // Store promise this.audioBufferPromises[resourceName] = promise return promise } /** Called on startup */ onStart() { // If no animation rules, just play the first clip if (this.rules.length == 0) { // Play first clip, if any if (this.clips.length > 0) this.mixer.clipAction(this.clips[0]).play() // Done return } // Go through all "start" rules this.performActions(this.rules.filter(r => r.on == "start")) } /** Called when the current animation clip finishes */ onAnimationFinished(e) { // No longer playing an animation let lastAnimation = this.currentAnimation this.currentAnimation = "" // Go through all rules matching what to do next this.performActions(this.rules.filter(r => r.on == "animation-complete" && r.target == lastAnimation)) } /** Call this when the user clicks on the 3D model. Returns `true` if a click rule was executed. */ onClick() { // Stop if an action is running if (this.runningVatomAction) { console.log("[Animation Manager] Click ignored since there's a running action.") return true } // Fetch click events this.performActions(this.rules.filter(r => r.on == "click" && (typeof r.target == "undefined" || r.target == this.currentAnimation))) // Done. Return true if we have any click handlers at all. return this.rules.some(r => r.on == 'click') } /** Call this when the object state changes. */ onStateChanged(newState) { // Go through each rule let rulesToPerform = [] for (let rule of this.rules) { // Check rule type if (rule.on != "state") continue // Get key path let keyPath = rule.target.split(/\.(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/).map(k => k.replace(/"/g, '')) // Follow key path and get the value let keyValue = newState while (keyPath.length > 0) { keyValue = keyValue[keyPath[0]] keyPath.splice(0, 1) if (!keyValue) break } // Check if value matches if (rule.value != keyValue) continue // Matched! rulesToPerform.push(rule) } // Do them this.performActions(rulesToPerform) } /** Call this when the 3D model is going to be removed. Returns `true` if a rule was executed. */ onRemoveAction() { // Run actions this.performActions(this.rules.filter(r => r.on == "remove" && (typeof r.target == "undefined" || r.target == this.currentAnimation))) // Done. Return true if we have any remove handlers at all. return this.rules.some(r => r.on == 'remove') } }