diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 638cf7e..69cca84 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -1,6 +1,9 @@ export { default as fastApply, } from './fast-apply'; +export { + default as mapValuesAsync, +} from './map-values-async'; export { mergeDiff, mergeDiffArray, diff --git a/packages/core/src/map-values-async.js b/packages/core/src/map-values-async.js new file mode 100644 index 0000000..b899fae --- /dev/null +++ b/packages/core/src/map-values-async.js @@ -0,0 +1,3 @@ +export default async (o, f) => Object.fromEntries( + await Promise.all(Object.entries(o).map(async ([key, v]) => [key, await f(v)])), +); diff --git a/packages/graphics/src/container.js b/packages/graphics/src/container.js index 488f241..aec9f19 100644 --- a/packages/graphics/src/container.js +++ b/packages/graphics/src/container.js @@ -29,6 +29,9 @@ export default class Container extends Renderable { if (this.container.isFake) { return; } + if (-1 !== this._children.indexOf(child)) { + return; + } // eslint-disable-next-line no-param-reassign child.parent = this; this.isDirty = true; diff --git a/packages/sound/src/traits/audible.js b/packages/sound/src/traits/audible.js new file mode 100644 index 0000000..4bf5347 --- /dev/null +++ b/packages/sound/src/traits/audible.js @@ -0,0 +1,91 @@ +import {Trait} from '@avocado/traits'; + +import Sound from '../sound'; + +const { + SIDE, +} = process.env; + +export default class Audible extends Trait { + + #sounds; + + constructor(json) { + super(json); + this.#sounds = json.sounds || {}; + } + + static behaviorTypes() { + return { + hasSound: { + type: 'bool', + label: 'Has $1 sound', + args: [ + ['key', { + type: 'string', + }], + ], + }, + playSound: { + type: 'void', + label: 'Play the $1 sound.', + args: [ + ['key', { + type: 'string', + options: (entity) => this.optionsForSounds(entity), + }], + ], + }, + }; + } + + static defaultParams() { + return { + sounds: {}, + }; + } + + static describeParams() { + return { + sounds: { + type: 'object', + label: 'Sounds', + }, + }; + } + + destroy() { + Object.values(this.#sounds).forEach((sound) => { + sound.destroy(); + }); + } + + async extendJson(json) { + const extended = await super.extendJson(json); + if (this.params.sounds && 'client' === SIDE) { + extended.sounds = Object.fromEntries( + await Promise.all( + Object.entries(this.params.sounds) + .map(async ([key, sound]) => [key, await Sound.load(sound.uri)]), + ), + ); + } + return extended; + } + + methods() { + return { + + hasSound: (key) => !!this.#sounds[key], + + playSound: (key) => this.hasSound(key) && this.#sounds[key].play(), + + }; + } + + static optionsForSounds(entity) { + return Object.keys(entity.trait('Audible').params.sounds) + .reduce((r, key) => ({...r, [key]: key}), {}); + } + +} diff --git a/packages/sound/src/traits/audible.trait.js b/packages/sound/src/traits/audible.trait.js deleted file mode 100644 index 742b953..0000000 --- a/packages/sound/src/traits/audible.trait.js +++ /dev/null @@ -1,104 +0,0 @@ -import {Trait} from '@avocado/traits'; - -import Sound from '../sound'; - -export default class Audible extends Trait { - - static behaviorTypes() { - return { - hasSound: { - type: 'bool', - label: 'Has $1 sound', - args: [ - ['key', { - type: 'string', - }], - ], - }, - playSound: { - type: 'void', - label: 'Play the $1 sound.', - args: [ - ['key', { - type: 'string', - options: (entity) => this.optionsForSounds(entity), - }], - ], - }, - }; - } - - static defaultParams() { - return { - sounds: {}, - }; - } - - static describeParams() { - return { - sounds: { - type: 'object', - label: 'Sounds', - }, - }; - } - - hydrate() { - if (AVOCADO_CLIENT) { - this.loadSounds(); - } - } - - constructor(entity, params, state) { - super(entity, params, state); - this._sounds = this.params.sounds; - this.sounds = {}; - } - - destroy() { - const sounds = Object.values(this.sounds); - for (let i = 0; i < sounds.length; i++) { - sounds[i].destroy(); - } - } - - loadSounds() { - const keys = Object.keys(this._sounds); - const soundPromises = []; - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - const soundJSON = this._sounds[key]; - soundPromises.push(Sound.load(soundJSON.uri)); - } - const soundsPromise = Promise.all(soundPromises); - soundsPromise.then((sounds) => { - for (let i = 0; i < sounds.length; i++) { - const key = keys[i]; - const sound = sounds[i]; - this.sounds[key] = sound; - } - }); - } - - methods() { - return { - - hasSound: (key) => !!this.sounds[key], - - playSound: (key) => { - const sound = this.sounds[key]; - if (!sound) { - return; - } - sound.play(); - }, - - }; - } - - static optionsForSounds(entity) { - return Object.keys(entity.trait('Audible').params.sounds) - .reduce((r, key) => ({...r, [key]: key}), {}); - } - -} diff --git a/packages/timing/package.json b/packages/timing/package.json index df9794e..1867cf1 100644 --- a/packages/timing/package.json +++ b/packages/timing/package.json @@ -27,7 +27,8 @@ "@avocado/traits": "^2.0.0", "@latus/core": "2.0.0", "@latus/socket": "2.0.0", - "debug": "4.3.1" + "debug": "4.3.1", + "lodash.mapvalues": "^4.6.0" }, "devDependencies": { "@neutrinojs/airbnb-base": "^9.4.0", diff --git a/packages/timing/src/index.js b/packages/timing/src/index.js index 59cd0d6..081c20a 100644 --- a/packages/timing/src/index.js +++ b/packages/timing/src/index.js @@ -9,4 +9,4 @@ export {default as Animation} from './animation'; export {default as Lfo} from './lfo'; export {default as Ticker} from './ticker'; export {default as TimedIndex} from './timed-index'; -export {TransitionMixin as Transition, TransitionResult} from './transition'; +export {default as Transition, TransitionResult} from './transition'; diff --git a/packages/timing/src/packets/trait-update-animated.js b/packages/timing/src/packets/trait-update-animated.js index 0159b70..a79f49e 100644 --- a/packages/timing/src/packets/trait-update-animated.js +++ b/packages/timing/src/packets/trait-update-animated.js @@ -2,13 +2,10 @@ import {Packet} from '@latus/socket'; export default class TraitUpdateAnimatedPacket extends Packet { - static get schema() { + static get data() { return { - ...super.schema, - data: { - currentAnimation: 'string', - isAnimating: 'bool', - }, + currentAnimation: 'string', + isAnimating: 'bool', }; } diff --git a/packages/timing/src/traits/animated.trait.js b/packages/timing/src/traits/animated.js similarity index 51% rename from packages/timing/src/traits/animated.trait.js rename to packages/timing/src/traits/animated.js index 88fcde4..7bba3cf 100644 --- a/packages/timing/src/traits/animated.trait.js +++ b/packages/timing/src/traits/animated.js @@ -1,6 +1,8 @@ +import {mapValuesAsync} from '@avocado/core'; import {StateProperty, Trait} from '@avocado/traits'; import {Rectangle, Vector} from '@avocado/math'; import {compose} from '@latus/core'; +import mapValues from 'lodash.mapvalues'; import Animation from '../animation'; import AnimationView from '../animation-view'; @@ -16,6 +18,34 @@ const decorate = compose( export default class Animated extends decorate(Trait) { + #animations = {}; + + #animationViews = {}; + + #cachedAabbs = {}; + + #currentAnimation; + + constructor(json) { + super(json); + if (json.animations) { + Object.values(json.animations).forEach((animation) => { + // eslint-disable-next-line no-param-reassign + animation.direction = this.entity.direction; + }); + this.#animations = json.animations; + this.#animationViews = json.animationViews; + } + this.#currentAnimation = this.state.currentAnimation; + } + + acceptPacket(packet) { + if ('TraitUpdateAnimated' === packet.constructor.type) { + this.entity.currentAnimation = packet.data.currentAnimation; + this.entity.isAnimating = packet.data.isAnimating; + } + } + static defaultParams() { return { animations: {}, @@ -55,134 +85,153 @@ export default class Animated extends decorate(Trait) { }; } - constructor(entity, params, state) { - super(entity, params, state); - this._animations = this.params.animations; - this.animations = {}; - this.animationViews = undefined; - this.animationsPromise = undefined; - this._cachedAabbs = {}; - this._currentAnimation = this.state.currentAnimation; - } - destroy() { - if (this.animationViews) { - const animationViews = Object.entries(this.animationViews); + if (this.#animationViews) { + const animationViews = Object.entries(this.#animationViews); for (let i = 0; i < animationViews.length; i++) { const [key, animationView] = animationViews[i]; this.hideAnimation(key); animationView.destroy(); } - this.animationViews = undefined; + this.#animationViews = undefined; } - const animations = Object.values(this.animations); + const animations = Object.values(this.#animations); for (let i = 0; i < animations.length; i++) { animations[i].destroy(); } - this.animations = {}; - this.animationsPromise = undefined; - this._cachedAabbs = {}; + this.#animations = {}; + this.#cachedAabbs = {}; } - acceptPacket(packet) { - if ('TraitUpdateAnimated' === packet.constructor.type) { - this.entity.currentAnimation = packet.data.currentAnimation; - this.entity.isAnimating = packet.data.isAnimating; + static async extendJson(json) { + const extended = await super.extendJson(json); + if (Object.keys(this.params.animations).length > 0) { + extended.animations = await mapValuesAsync( + this.params.animations, + (json) => Animation.load(json), + ); + extended.animationViews = mapValues( + extended.animations, + (animation) => new AnimationView(animation), + ); } + return extended; } hideAnimation(key) { - if (!this.animationViews) { + if (!this.#animationViews) { return; } - const animationView = this.animationViews[key]; + const animationView = this.#animationViews[key]; if (!animationView) { return; } + if (!this.entity.container) { + return; + } this.entity.container.removeChild(animationView); } - hydrate() { - return Promise.all([ - this.loadAnimations(), - this.loadAnimationImagesIfPossible(), - ]); + hooks() { + return { + + visibleAabbs: () => { + const key = this.#currentAnimation; + if (key in this.#cachedAabbs) { + return this.#cachedAabbs[key]; + } + if (!(key in this.#animations)) { + return [0, 0, 0, 0]; + } + const {frameSize} = this.#animations[key]; + const scaledSize = Vector.mul(frameSize, this.entity.rawVisibleScale); + const viewPosition = Vector.sub( + this.offsetFor(key), + Vector.scale(scaledSize, 0.5), + ); + const rectangle = Rectangle.compose(viewPosition, frameSize); + const expanded = Rectangle.expand( + rectangle, + Vector.sub(scaledSize, frameSize), + ); + this.#cachedAabbs[key] = expanded; + return expanded; + }, + + }; } jitterFor(key) { - if (!(key in this._animations) || !this._animations[key].jitter) { + if (!(key in this.params.animations) || !this.params.animations[key].jitter) { return 0; } - return this._animations[key].jitter; + return this.params.animations[key].jitter; } - async loadAnimations() { - if (this.animationsPromise) { - return; - } - if (!this.animationsPromise) { - const animationPromises = []; - // Load all animations. - const animations = Object.entries(this._animations); - for (let i = 0; i < animations.length; i++) { - const [key, {uri}] = animations[i]; - const promise = Animation.load(uri).then((animation) => ({animation, key})); - animationPromises.push(promise); - } - this.animationsPromise = Promise.all(animationPromises); - } - // Store keyed animations. - this.animations = {}; - const animations = await this.animationsPromise; - animations.forEach(({animation, key}) => { - this.animations[key] = animation; - // Set direction upfront. - // eslint-disable-next-line no-param-reassign - animation.direction = this.entity.direction; - }); - // Bounding box update. - this.entity.updateVisibleBoundingBox(); - } + listeners() { + return { - async loadAnimationImagesIfPossible() { - if (!this.entity.container) { - return; - } - if (!this.animationsPromise) { - return; - } - if (this.animationViews) { - return; - } - // Store keyed animation views. - this.animationViews = {}; - const animations = await this.animationsPromise; - animations.forEach(({animation, key}) => { - this.animationViews[key] = new AnimationView(animation); - // Calculate any offset. - const animationView = this.animationViews[key]; - animationView.position = this.offsetFor(key); - // Ensure animation is made visible upfront. - const isCurrentAnimation = key === this._currentAnimation; - if (isCurrentAnimation) { - this.showAnimation(key); - } - }); - this.setSpriteScale(); + currentAnimationChanged: (oldKey, currentAnimation) => { + this.#currentAnimation = currentAnimation; + // Reset old animation. + if (oldKey in this.#animations) { + const oldAnimation = this.#animations[oldKey]; + oldAnimation.reset(); + } + // Bounding box update. + this.entity.updateVisibleBoundingBox(); + // Only client/graphics. + if (!this.#animationViews) { + return; + } + // Swap the animation. + this.hideAnimation(oldKey); + this.showAnimation(this.#currentAnimation); + }, + + directionChanged: () => { + // All animations track direction. + const animations = Object.values(this.#animations); + for (let i = 0; i < animations.length; i++) { + animations[i].direction = this.entity.direction; + } + }, + + isDyingChanged: (_, isDying) => { + this.isAnimating = !isDying; + }, + + traitAdded: () => { + this.entity.updateVisibleBoundingBox(); + this.setSpriteScale(); + Object.keys(this.#animations).forEach((key) => { + const method = key === this.#currentAnimation + ? 'showAnimation' + : 'hideAnimation'; + this[method](key); + }); + }, + + visibleScaleChanged: () => { + this.#cachedAabbs = {}; + this.setSpriteScale(); + }, + + }; } offsetFor(key) { - if (!(key in this._animations) || !this._animations[key].offset) { + if (!(key in this.params.animations) || !this.params.animations[key].offset) { return [0, 0]; } - return this._animations[key].offset; + return this.params.animations[key].offset; } packets() { const {currentAnimation, isAnimating} = this.stateDifferences(); if (currentAnimation || isAnimating) { return [ - 'TraitUpdateAnimatedPacket', + 'TraitUpdateAnimated', { currentAnimation: this.state.currentAnimation, isAnimating: this.state.isAnimating, @@ -193,107 +242,39 @@ export default class Animated extends decorate(Trait) { } setSpriteScale() { - if (!this.animationViews) { + if (!this.#animationViews) { return; } - const animationViews = Object.values(this.animationViews); + const animationViews = Object.values(this.#animationViews); for (let i = 0; i < animationViews.length; i++) { animationViews[i].scale = this.entity.rawVisibleScale; } } showAnimation(key) { - if (!this.animationViews) { + if (!this.#animationViews) { return; } - if (!(key in this.animationViews)) { + const animationView = this.#animationViews[key]; + if (!animationView) { + return; + } + if (!this.entity.container) { return; } - const animationView = this.animationViews[key]; this.entity.container.addChild(animationView); } - hooks() { - return { - - visibleAabbs: () => { - const key = this._currentAnimation; - if (key in this._cachedAabbs) { - return this._cachedAabbs[key]; - } - if (!(key in this.animations)) { - return [0, 0, 0, 0]; - } - const animation = this.animations[key]; - const size = animation.frameSize; - const scaledSize = Vector.mul(size, this.entity.rawVisibleScale); - const viewPosition = Vector.sub( - this.offsetFor(key), - Vector.scale(scaledSize, 0.5), - ); - const rectangle = Rectangle.compose(viewPosition, size); - const expanded = Rectangle.expand( - rectangle, - Vector.sub(scaledSize, size), - ); - this._cachedAabbs[key] = expanded; - return expanded; - }, - - }; - } - - listeners() { - return { - - currentAnimationChanged: (oldKey, currentAnimation) => { - this._currentAnimation = currentAnimation; - // Reset old animation. - if (oldKey in this.animations) { - const oldAnimation = this.animations[oldKey]; - oldAnimation.reset(); - } - // Bounding box update. - this.entity.updateVisibleBoundingBox(); - // Only client/graphics. - if (!this.animationViews) { - return; - } - // Swap the animation. - this.hideAnimation(oldKey); - this.showAnimation(this._currentAnimation); - }, - - directionChanged: () => { - // All animations track direction. - const animations = Object.values(this.animations); - for (let i = 0; i < animations.length; i++) { - animations[i].direction = this.entity.direction; - } - }, - - isDyingChanged: (_, isDying) => { - this.isAnimating = !isDying; - }, - - visibleScaleChanged: () => { - this._cachedAabbs = {}; - this.setSpriteScale(); - }, - - }; - } - tick(elapsed) { if (!this.isAnimating) { return; } // Only tick current animation. - const currentAnimation = this._currentAnimation; - if (!(currentAnimation in this.animations)) { + const currentAnimation = this.#currentAnimation; + if (!(currentAnimation in this.#animations)) { return; } - const animation = this.animations[currentAnimation]; + const animation = this.#animations[currentAnimation]; const jitterAmount = this.jitterFor(currentAnimation); if (jitterAmount > 0) { const jitter = Math.random() * jitterAmount; diff --git a/packages/timing/src/transition/index.js b/packages/timing/src/transition/index.js index f473a2d..4a3884a 100644 --- a/packages/timing/src/transition/index.js +++ b/packages/timing/src/transition/index.js @@ -31,7 +31,7 @@ import TransitionResult from './result'; export {TransitionResult}; -export function TransitionMixin(Superclass) { +export default function TransitionMixin(Superclass) { return class Transition extends Superclass { transition(props, duration, easing) { diff --git a/packages/timing/src/transition/result.js b/packages/timing/src/transition/result.js index 6a1fa3b..af51d61 100644 --- a/packages/timing/src/transition/result.js +++ b/packages/timing/src/transition/result.js @@ -9,20 +9,18 @@ const decorate = compose( export default class TransitionResult extends decorate(Class) { constructor(subject, props, duration, easing) { - super(subject, props, duration, easing); - // Speed might not get passed. If it doesn't, default to 100 - // milliseconds. + super(); + // Speed might not get passed. If it doesn't, default to 100 milliseconds. this.duration = parseFloat(duration || 0.1); this.elapsed = 0; - this._isEmittingProgress = false; + this.isEmittingProgress = false; this.props = props; this.subject = subject; if ('function' === typeof easing) { this.easing = easing; } - // If easing isn't passed in as a function, attempt to look it up - // as a string key into Transition.easing. If that fails, then - // default to 'easeOutQuad'. + // If easing isn't passed in as a function, attempt to look it up as a string key into + // Transition.easing. If that fails, then default to 'easeOutQuad'. else { this.easing = easingFunctions[easing] || easingFunctions.easeOutQuad; } @@ -36,44 +34,33 @@ export default class TransitionResult extends decorate(Class) { this.change[key] = prop - value; } this.promise = new Promise((resolve) => { - this.once('stopped', () => resolve()); + this.once('stopped', resolve); }); } - get isEmittingProgress() { - return this._isEmittingProgress; - } - - set isEmittingProgress(isEmittingProgress) { - this._isEmittingProgress = isEmittingProgress; - } - - // Immediately finish the transition. This will leave the object - // in the fully transitioned state. + // Immediately finish the transition. This will leave the object in the fully transitioned state. skipTransition() { - // Just trick it into thinking the time passed and do one last - // tick. + // Just trick it into thinking the time passed and do one last tick. this.elapsed = this.duration; this.tick(0); } - // Immediately stop the transition. This will leave the object in - // its current state; potentially partially transitioned. + // Immediately stop the transition. This will leave the object in its current state; + // potentially partially transitioned. stopTransition() { // Let any listeners know that the transition is complete. - if (this._isEmittingProgress) { + if (this.isEmittingProgress) { this.emit('progress', [this.elapsed, this.duration]); } this.emit('stopped'); } - // Tick callback. Called repeatedly while this transition is - // running. + // Tick callback. Called repeatedly while this transition is running. tick(elapsed) { // Update the transition's elapsed time. this.elapsed += elapsed; - // If we've overshot the duration, we'll fix it up here, so - // things never transition too far (through the end point). + // If we've overshot the duration, we'll fix it up here, so things never transition too far + // (through the end point). if (this.elapsed >= this.duration) { this.elapsed = this.duration; const changes = Object.entries(this.change); @@ -103,7 +90,7 @@ export default class TransitionResult extends decorate(Class) { if (this.elapsed === this.duration) { this.stopTransition(); } - if (this._isEmittingProgress) { + if (this.isEmittingProgress) { this.emit('progress', [this.elapsed, this.duration]); } } diff --git a/packages/timing/yarn.lock b/packages/timing/yarn.lock index f529570..5fd21ff 100644 --- a/packages/timing/yarn.lock +++ b/packages/timing/yarn.lock @@ -4,8 +4,8 @@ "@avocado/core@2.0.0", "@avocado/core@^2.0.0": version "2.0.0" - resolved "https://npm.i12e.cha0s.io/@avocado%2fcore/-/core-2.0.0.tgz#636ea3c3b54a38538c59485080f6a0e48f1798e7" - integrity sha512-VW+ygRHaQQwaL5rKZGm0n0DNfvj+H89qQx+67veCUmUuRav3XAeE0iYs8Lgfc3CJLPz/alqt/dVPMXd5QDR+Mg== + resolved "https://npm.i12e.cha0s.io/@avocado%2fcore/-/core-2.0.0.tgz#ee56331fb389deb196f0184d798d72b5d0fe6015" + integrity sha512-AynteHSxM7TTzzGGRWrn3qvHi1Cru2Yhg1z4ZgJgh0FE2yxXRefIVe2PPRCTeYoRXvIpE7c+QtJjrJIvuApViQ== dependencies: debug "4.3.1" @@ -4796,6 +4796,11 @@ lodash.flatten@^4.4.0: resolved "https://npm.i12e.cha0s.io/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= +lodash.mapvalues@^4.6.0: + version "4.6.0" + resolved "https://npm.i12e.cha0s.io/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c" + integrity sha1-G6+lAF3p3W9PJmaMMMo3IwzJaJw= + lodash.omit@^4.5.0: version "4.5.0" resolved "https://npm.i12e.cha0s.io/lodash.omit/-/lodash.omit-4.5.0.tgz#6eb19ae5a1ee1dd9df0b969e66ce0b7fa30b5e60" diff --git a/packages/traits/src/trait.js b/packages/traits/src/trait.js index 9c0b586..05cc458 100644 --- a/packages/traits/src/trait.js +++ b/packages/traits/src/trait.js @@ -89,9 +89,6 @@ export default class Trait extends decorate(JsonResource) { return {}; } - // eslint-disable-next-line class-methods-use-this, no-empty-function - async hydrate() {} - get isDirty() { const keys = Object.keys(this.state); for (let i = 0; i < keys.length; i++) {