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 {
default as fastApply,
} from './fast-apply';
export {
default as mapValuesAsync,
} from './map-values-async';
export {
mergeDiff,
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) {
return;
}
if (-1 !== this._children.indexOf(child)) {
return;
}
// eslint-disable-next-line no-param-reassign
child.parent = this;
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",
"@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",

View File

@ -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';

View File

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

View File

@ -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;

View File

@ -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) {

View File

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

View File

@ -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"

View File

@ -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++) {