This commit is contained in:
cha0s 2021-01-04 20:30:21 -06:00
parent 455a7dacf3
commit 89278f1d9c
13 changed files with 274 additions and 310 deletions

View File

@ -1,6 +1,9 @@
export { export {
default as fastApply, default as fastApply,
} from './fast-apply'; } from './fast-apply';
export {
default as mapValuesAsync,
} from './map-values-async';
export { export {
mergeDiff, mergeDiff,
mergeDiffArray, mergeDiffArray,

View File

@ -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)])),
);

View File

@ -29,6 +29,9 @@ export default class Container extends Renderable {
if (this.container.isFake) { if (this.container.isFake) {
return; return;
} }
if (-1 !== this._children.indexOf(child)) {
return;
}
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
child.parent = this; child.parent = this;
this.isDirty = true; this.isDirty = true;

View File

@ -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}), {});
}
}

View File

@ -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}), {});
}
}

View File

@ -27,7 +27,8 @@
"@avocado/traits": "^2.0.0", "@avocado/traits": "^2.0.0",
"@latus/core": "2.0.0", "@latus/core": "2.0.0",
"@latus/socket": "2.0.0", "@latus/socket": "2.0.0",
"debug": "4.3.1" "debug": "4.3.1",
"lodash.mapvalues": "^4.6.0"
}, },
"devDependencies": { "devDependencies": {
"@neutrinojs/airbnb-base": "^9.4.0", "@neutrinojs/airbnb-base": "^9.4.0",

View File

@ -9,4 +9,4 @@ export {default as Animation} from './animation';
export {default as Lfo} from './lfo'; export {default as Lfo} from './lfo';
export {default as Ticker} from './ticker'; export {default as Ticker} from './ticker';
export {default as TimedIndex} from './timed-index'; export {default as TimedIndex} from './timed-index';
export {TransitionMixin as Transition, TransitionResult} from './transition'; export {default as Transition, TransitionResult} from './transition';

View File

@ -2,13 +2,10 @@ import {Packet} from '@latus/socket';
export default class TraitUpdateAnimatedPacket extends Packet { export default class TraitUpdateAnimatedPacket extends Packet {
static get schema() { static get data() {
return { return {
...super.schema, currentAnimation: 'string',
data: { isAnimating: 'bool',
currentAnimation: 'string',
isAnimating: 'bool',
},
}; };
} }

View File

@ -1,6 +1,8 @@
import {mapValuesAsync} from '@avocado/core';
import {StateProperty, Trait} from '@avocado/traits'; import {StateProperty, Trait} from '@avocado/traits';
import {Rectangle, Vector} from '@avocado/math'; import {Rectangle, Vector} from '@avocado/math';
import {compose} from '@latus/core'; import {compose} from '@latus/core';
import mapValues from 'lodash.mapvalues';
import Animation from '../animation'; import Animation from '../animation';
import AnimationView from '../animation-view'; import AnimationView from '../animation-view';
@ -16,6 +18,34 @@ const decorate = compose(
export default class Animated extends decorate(Trait) { 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() { static defaultParams() {
return { return {
animations: {}, 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() { destroy() {
if (this.animationViews) { if (this.#animationViews) {
const animationViews = Object.entries(this.animationViews); const animationViews = Object.entries(this.#animationViews);
for (let i = 0; i < animationViews.length; i++) { for (let i = 0; i < animationViews.length; i++) {
const [key, animationView] = animationViews[i]; const [key, animationView] = animationViews[i];
this.hideAnimation(key); this.hideAnimation(key);
animationView.destroy(); 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++) { for (let i = 0; i < animations.length; i++) {
animations[i].destroy(); animations[i].destroy();
} }
this.animations = {}; this.#animations = {};
this.animationsPromise = undefined; this.#cachedAabbs = {};
this._cachedAabbs = {};
} }
acceptPacket(packet) { static async extendJson(json) {
if ('TraitUpdateAnimated' === packet.constructor.type) { const extended = await super.extendJson(json);
this.entity.currentAnimation = packet.data.currentAnimation; if (Object.keys(this.params.animations).length > 0) {
this.entity.isAnimating = packet.data.isAnimating; extended.animations = await mapValuesAsync(
this.params.animations,
(json) => Animation.load(json),
);
extended.animationViews = mapValues(
extended.animations,
(animation) => new AnimationView(animation),
);
} }
return extended;
} }
hideAnimation(key) { hideAnimation(key) {
if (!this.animationViews) { if (!this.#animationViews) {
return; return;
} }
const animationView = this.animationViews[key]; const animationView = this.#animationViews[key];
if (!animationView) { if (!animationView) {
return; return;
} }
if (!this.entity.container) {
return;
}
this.entity.container.removeChild(animationView); this.entity.container.removeChild(animationView);
} }
hydrate() { hooks() {
return Promise.all([ return {
this.loadAnimations(),
this.loadAnimationImagesIfPossible(), 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) { 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 0;
} }
return this._animations[key].jitter; return this.params.animations[key].jitter;
} }
async loadAnimations() { listeners() {
if (this.animationsPromise) { return {
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();
}
async loadAnimationImagesIfPossible() { currentAnimationChanged: (oldKey, currentAnimation) => {
if (!this.entity.container) { this.#currentAnimation = currentAnimation;
return; // Reset old animation.
} if (oldKey in this.#animations) {
if (!this.animationsPromise) { const oldAnimation = this.#animations[oldKey];
return; oldAnimation.reset();
} }
if (this.animationViews) { // Bounding box update.
return; this.entity.updateVisibleBoundingBox();
} // Only client/graphics.
// Store keyed animation views. if (!this.#animationViews) {
this.animationViews = {}; return;
const animations = await this.animationsPromise; }
animations.forEach(({animation, key}) => { // Swap the animation.
this.animationViews[key] = new AnimationView(animation); this.hideAnimation(oldKey);
// Calculate any offset. this.showAnimation(this.#currentAnimation);
const animationView = this.animationViews[key]; },
animationView.position = this.offsetFor(key);
// Ensure animation is made visible upfront. directionChanged: () => {
const isCurrentAnimation = key === this._currentAnimation; // All animations track direction.
if (isCurrentAnimation) { const animations = Object.values(this.#animations);
this.showAnimation(key); for (let i = 0; i < animations.length; i++) {
} animations[i].direction = this.entity.direction;
}); }
this.setSpriteScale(); },
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) { 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 [0, 0];
} }
return this._animations[key].offset; return this.params.animations[key].offset;
} }
packets() { packets() {
const {currentAnimation, isAnimating} = this.stateDifferences(); const {currentAnimation, isAnimating} = this.stateDifferences();
if (currentAnimation || isAnimating) { if (currentAnimation || isAnimating) {
return [ return [
'TraitUpdateAnimatedPacket', 'TraitUpdateAnimated',
{ {
currentAnimation: this.state.currentAnimation, currentAnimation: this.state.currentAnimation,
isAnimating: this.state.isAnimating, isAnimating: this.state.isAnimating,
@ -193,107 +242,39 @@ export default class Animated extends decorate(Trait) {
} }
setSpriteScale() { setSpriteScale() {
if (!this.animationViews) { if (!this.#animationViews) {
return; return;
} }
const animationViews = Object.values(this.animationViews); const animationViews = Object.values(this.#animationViews);
for (let i = 0; i < animationViews.length; i++) { for (let i = 0; i < animationViews.length; i++) {
animationViews[i].scale = this.entity.rawVisibleScale; animationViews[i].scale = this.entity.rawVisibleScale;
} }
} }
showAnimation(key) { showAnimation(key) {
if (!this.animationViews) { if (!this.#animationViews) {
return; return;
} }
if (!(key in this.animationViews)) { const animationView = this.#animationViews[key];
if (!animationView) {
return;
}
if (!this.entity.container) {
return; return;
} }
const animationView = this.animationViews[key];
this.entity.container.addChild(animationView); 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) { tick(elapsed) {
if (!this.isAnimating) { if (!this.isAnimating) {
return; return;
} }
// Only tick current animation. // Only tick current animation.
const currentAnimation = this._currentAnimation; const currentAnimation = this.#currentAnimation;
if (!(currentAnimation in this.animations)) { if (!(currentAnimation in this.#animations)) {
return; return;
} }
const animation = this.animations[currentAnimation]; const animation = this.#animations[currentAnimation];
const jitterAmount = this.jitterFor(currentAnimation); const jitterAmount = this.jitterFor(currentAnimation);
if (jitterAmount > 0) { if (jitterAmount > 0) {
const jitter = Math.random() * jitterAmount; const jitter = Math.random() * jitterAmount;

View File

@ -31,7 +31,7 @@ import TransitionResult from './result';
export {TransitionResult}; export {TransitionResult};
export function TransitionMixin(Superclass) { export default function TransitionMixin(Superclass) {
return class Transition extends Superclass { return class Transition extends Superclass {
transition(props, duration, easing) { transition(props, duration, easing) {

View File

@ -9,20 +9,18 @@ const decorate = compose(
export default class TransitionResult extends decorate(Class) { export default class TransitionResult extends decorate(Class) {
constructor(subject, props, duration, easing) { constructor(subject, props, duration, easing) {
super(subject, props, duration, easing); super();
// Speed might not get passed. If it doesn't, default to 100 // Speed might not get passed. If it doesn't, default to 100 milliseconds.
// milliseconds.
this.duration = parseFloat(duration || 0.1); this.duration = parseFloat(duration || 0.1);
this.elapsed = 0; this.elapsed = 0;
this._isEmittingProgress = false; this.isEmittingProgress = false;
this.props = props; this.props = props;
this.subject = subject; this.subject = subject;
if ('function' === typeof easing) { if ('function' === typeof easing) {
this.easing = easing; this.easing = easing;
} }
// If easing isn't passed in as a function, attempt to look it up // If easing isn't passed in as a function, attempt to look it up as a string key into
// as a string key into Transition.easing. If that fails, then // Transition.easing. If that fails, then default to 'easeOutQuad'.
// default to 'easeOutQuad'.
else { else {
this.easing = easingFunctions[easing] || easingFunctions.easeOutQuad; this.easing = easingFunctions[easing] || easingFunctions.easeOutQuad;
} }
@ -36,44 +34,33 @@ export default class TransitionResult extends decorate(Class) {
this.change[key] = prop - value; this.change[key] = prop - value;
} }
this.promise = new Promise((resolve) => { this.promise = new Promise((resolve) => {
this.once('stopped', () => resolve()); this.once('stopped', resolve);
}); });
} }
get isEmittingProgress() { // Immediately finish the transition. This will leave the object in the fully transitioned state.
return this._isEmittingProgress;
}
set isEmittingProgress(isEmittingProgress) {
this._isEmittingProgress = isEmittingProgress;
}
// Immediately finish the transition. This will leave the object
// in the fully transitioned state.
skipTransition() { skipTransition() {
// Just trick it into thinking the time passed and do one last // Just trick it into thinking the time passed and do one last tick.
// tick.
this.elapsed = this.duration; this.elapsed = this.duration;
this.tick(0); this.tick(0);
} }
// Immediately stop the transition. This will leave the object in // Immediately stop the transition. This will leave the object in its current state;
// its current state; potentially partially transitioned. // potentially partially transitioned.
stopTransition() { stopTransition() {
// Let any listeners know that the transition is complete. // Let any listeners know that the transition is complete.
if (this._isEmittingProgress) { if (this.isEmittingProgress) {
this.emit('progress', [this.elapsed, this.duration]); this.emit('progress', [this.elapsed, this.duration]);
} }
this.emit('stopped'); this.emit('stopped');
} }
// Tick callback. Called repeatedly while this transition is // Tick callback. Called repeatedly while this transition is running.
// running.
tick(elapsed) { tick(elapsed) {
// Update the transition's elapsed time. // Update the transition's elapsed time.
this.elapsed += elapsed; this.elapsed += elapsed;
// If we've overshot the duration, we'll fix it up here, so // If we've overshot the duration, we'll fix it up here, so things never transition too far
// things never transition too far (through the end point). // (through the end point).
if (this.elapsed >= this.duration) { if (this.elapsed >= this.duration) {
this.elapsed = this.duration; this.elapsed = this.duration;
const changes = Object.entries(this.change); const changes = Object.entries(this.change);
@ -103,7 +90,7 @@ export default class TransitionResult extends decorate(Class) {
if (this.elapsed === this.duration) { if (this.elapsed === this.duration) {
this.stopTransition(); this.stopTransition();
} }
if (this._isEmittingProgress) { if (this.isEmittingProgress) {
this.emit('progress', [this.elapsed, this.duration]); this.emit('progress', [this.elapsed, this.duration]);
} }
} }

View File

@ -4,8 +4,8 @@
"@avocado/core@2.0.0", "@avocado/core@^2.0.0": "@avocado/core@2.0.0", "@avocado/core@^2.0.0":
version "2.0.0" version "2.0.0"
resolved "https://npm.i12e.cha0s.io/@avocado%2fcore/-/core-2.0.0.tgz#636ea3c3b54a38538c59485080f6a0e48f1798e7" resolved "https://npm.i12e.cha0s.io/@avocado%2fcore/-/core-2.0.0.tgz#ee56331fb389deb196f0184d798d72b5d0fe6015"
integrity sha512-VW+ygRHaQQwaL5rKZGm0n0DNfvj+H89qQx+67veCUmUuRav3XAeE0iYs8Lgfc3CJLPz/alqt/dVPMXd5QDR+Mg== integrity sha512-AynteHSxM7TTzzGGRWrn3qvHi1Cru2Yhg1z4ZgJgh0FE2yxXRefIVe2PPRCTeYoRXvIpE7c+QtJjrJIvuApViQ==
dependencies: dependencies:
debug "4.3.1" 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" resolved "https://npm.i12e.cha0s.io/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= 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: lodash.omit@^4.5.0:
version "4.5.0" version "4.5.0"
resolved "https://npm.i12e.cha0s.io/lodash.omit/-/lodash.omit-4.5.0.tgz#6eb19ae5a1ee1dd9df0b969e66ce0b7fa30b5e60" resolved "https://npm.i12e.cha0s.io/lodash.omit/-/lodash.omit-4.5.0.tgz#6eb19ae5a1ee1dd9df0b969e66ce0b7fa30b5e60"

View File

@ -89,9 +89,6 @@ export default class Trait extends decorate(JsonResource) {
return {}; return {};
} }
// eslint-disable-next-line class-methods-use-this, no-empty-function
async hydrate() {}
get isDirty() { get isDirty() {
const keys = Object.keys(this.state); const keys = Object.keys(this.state);
for (let i = 0; i < keys.length; i++) { for (let i = 0; i < keys.length; i++) {