/*global platypus */
import {AnimatedSprite, TextureSource, Container, Point, Rectangle, Sprite, Texture} from 'pixi.js';
import {arrayCache, greenSlice} from './utils/array.js';
import Data from './Data.js';
const
MAX_KEY_LENGTH_PER_IMAGE = 128,
animationCache = {},
textureSourceCache = {},
doNothing = function () {},
emptyFrame = Texture.EMPTY,
regex = /[\[\]{},-]/g,
getTextureSources = function (images) {
const
assetCache = platypus.assetCache,
bts = arrayCache.setUp();
for (let i = 0; i < images.length; i++) {
const
path = images[i];
if (typeof path === 'string') {
if (!textureSourceCache[path]) {
const
asset = assetCache.get(path);
if (!asset) {
platypus.debug.warn(`PIXIAnimation: "${path}" is not a loaded asset.`);
break;
}
textureSourceCache[path] = asset.source;
}
bts.push(textureSourceCache[path]);
} else {
bts.push(new TextureSource(path));
}
}
return bts;
},
getTexturesCacheId = function (spriteSheet) {
if (spriteSheet.id) {
return spriteSheet.id;
}
for (let i = 0; i < spriteSheet.images.length; i++) {
if (typeof spriteSheet.images[i] !== 'string') {
return '';
}
}
spriteSheet.id = JSON.stringify(spriteSheet).replace(regex, '');
return spriteSheet.id;
},
getDefaultAnimation = function (length, textures) {
const
frames = arrayCache.setUp();
for (let i = 0; i < length; i++) {
frames.push(textures[i] ?? emptyFrame);
}
return Data.setUp(
"id", "default",
"frames", frames,
"next", "default",
"speed", 1
);
},
standardizeAnimations = function (def, textures) {
const
anims = Data.setUp(),
keys = Object.keys(def),
{length} = keys;
for (let i = 0; i < length; i++) {
const
key = keys[i],
animation = def[key],
frames = greenSlice(animation.frames);
let j = frames.length;
while (j--) {
frames[j] = textures[frames[j]] || emptyFrame;
}
anims[key] = Data.setUp(
'id', key,
'frames', frames,
'next', animation.next,
'speed', animation.speed
);
}
if (!anims.default) {
// Set up a default animation that plays through all frames
anims.default = getDefaultAnimation(textures.length, textures);
}
return anims;
},
getAnimations = function (spriteSheet = {}) {
const
{frames, images} = spriteSheet,
bases = getTextureSources(images),
textures = frames.map((frame) => new Texture({
source: bases[frame[4]],
frame: new Rectangle(frame[0], frame[1], frame[2], frame[3]),
rotate: 0,
defaultAnchor: new Point((frame[5] || 0) / frame[2], (frame[6] || 0) / frame[3])
})), // Set up texture for each frame
anims = standardizeAnimations(spriteSheet.animations, textures); // Set up animations
// Set up a default animation that plays through all frames
if (!anims.default) {
anims.default = getDefaultAnimation(textures.length, textures);
}
arrayCache.recycle(bases);
return Data.setUp(
"textures", textures,
"animations", anims
);
},
cacheAnimations = function (spriteSheet, cacheId) {
const
{frames, images} = spriteSheet,
bases = getTextureSources(images),
textures = frames.map((frame) => new Texture({
source: bases[frame[4]],
frame: new Rectangle(frame[0], frame[1], frame[2], frame[3]),
rotate: 0,
defaultAnchor: new Point((frame[5] || 0) / frame[2], (frame[6] || 0) / frame[3])
})), // Set up texture for each frame
anims = standardizeAnimations(spriteSheet.animations, textures); // Set up animations
arrayCache.recycle(bases);
return Data.setUp(
"textures", textures,
"animations", anims,
"viable", 1,
"cacheId", cacheId
);
},
/**
* This class plays animation sequences of frames and mimics the syntax required for creating CreateJS Sprites, allowing CreateJS Sprite Sheet definitions to be used with PixiJS.
*
* @memberof platypus
* @class PIXIAnimation
* @param {Object} spriteSheet JSON sprite sheet definition.
* @param {string} animation The name of the animation to start playing.
*/
PIXIAnimation = class extends Container {
constructor (spriteSheet, animation) {
const
FR = 60,
cacheId = getTexturesCacheId(spriteSheet),
speed = (spriteSheet.framerate || FR) / FR;
let cache = (cacheId ? animationCache[cacheId] : null);
super();
if (!cacheId) {
cache = getAnimations(spriteSheet);
} else if (!cache) {
cache = animationCache[cacheId] = cacheAnimations(spriteSheet, cacheId);
this.cacheId = cacheId;
} else {
cache.viable += 1;
this.cacheId = cacheId;
}
/**
* @private
*/
this._animations = {};
{
const
_animations = this._animations,
{animations} = cache,
keys = Object.keys(animations),
{length} = keys;
for (let i = 0; i < length; i++) {
const
key = keys[i],
animation = animations[key];
if (animation.frames.length === 1) {
_animations[key] = new Sprite(animation.frames[0]);
} else {
const anim = _animations[key] = new AnimatedSprite(animation.frames);
anim.animationSpeed = speed * animation.speed;
anim.onComplete = anim.onLoop = () => {
if (this.onComplete) {
this.onComplete(key);
}
if (animation.next) {
this.gotoAndPlay(animation.next);
}
};
anim.updateAnchor = true;
}
}
}
this._animation = null;
/**
* The speed that the PIXIAnimation will play at. Higher is faster, lower is slower
*
* @member {number}
* @default 1
*/
this.animationSpeed = speed;
/**
* The currently playing animation name.
*
* @property currentAnimation
* @default ""
* @type String
*/
this.currentAnimation = null;
/**
* Indicates if the PIXIAnimation is currently playing
*
* @member {boolean}
* @readonly
*/
this.playing = false;
this._visible = true;
this._updating = false;
/*
* Updates the object transform for rendering
* @private
*/
this.update = doNothing;
// Set up initial playthrough.
this.gotoAndPlay(animation);
}
get visible () {
return this._visible;
}
set visible (value) {
this._visible = value;
}
/**
* The PIXIAnimations paused state. If paused, the animation doesn't update.
*
* @property paused
* @memberof platypus.PIXIAnimation.prototype
*/
get paused () {
return !this.playing;
}
set paused (value) {
if ((value && this.playing) || (!value && !this.playing)) {
this.playing = !value;
}
}
/**
* Stops the PIXIAnimation
*
* @method platypus.PIXIAnimation#stop
*/
stop () {
this.paused = true;
};
/**
* Plays the PIXIAnimation
*
* @method platypus.PIXIAnimation#play
*/
play () {
this.paused = false;
};
/**
* Stops the PIXIAnimation and goes to a specific frame
*
* @method platypus.PIXIAnimation#gotoAndStop
* @param animation {number} frame index to stop at
*/
gotoAndStop (animation) {
this.stop();
if (this._animation && this._animation.stop) {
this._animation.stop();
}
this._animation = this._animations[animation];
if (!this._animation) {
this._animation = this._animations.default;
}
this.removeChildren();
this.addChild(this._animation);
};
/**
* Goes to a specific frame and begins playing the PIXIAnimation
*
* @method platypus.PIXIAnimation#gotoAndPlay
* @param animation {string} The animation to begin playing.
* @param [loop = true] {Boolean} Whether this animation should loop.
* @param [restart = true] {Boolean} Whether to restart the animation if it's currently playing.
*/
gotoAndPlay (animation, loop = true, restart = true) {
if ((this.currentAnimation !== animation) || restart) {
if (this._animation && this._animation.stop) {
this._animation.stop();
}
this._animation = this._animations[animation];
this.currentAnimation = animation;
if (!this._animation) {
this._animation = this._animations.default;
this.currentAnimation = 'default';
}
this.removeChildren();
this.addChild(this._animation);
}
this._animation.loop = loop;
if (this._animation.play) {
this._animation.play();
}
this.play();
};
/**
* Returns whether a particular animation is available.
*
* @method platypus.PIXIAnimation#has
* @param animation {string} The animation to check.
*/
has (animation) {
return !!this._animations[animation];
};
/**
* Stops the PIXIAnimation and destroys it
*
* @method platypus.PIXIAnimation#destroy
*/
destroy () {
this.stop();
if (this._animation?.stop) {
this._animation.stop();
}
super.destroy();
if (this.cacheId) {
const
cachedAnimation = animationCache[this.cacheId];
cachedAnimation.viable -= 1;
if (cachedAnimation.viable <= 0) {
arrayCache.recycle(cachedAnimation.textures);
const
animations = cachedAnimation.animations,
keys = Object.keys(animations),
{length} = keys;
for (let i = 0; i < length; i++) {
const
key = keys[i];
arrayCache.recycle(animations[key].frames);
}
delete animationCache[this.cacheId];
}
}
};
static get EmptySpriteSheet () {
return {
framerate: 60,
frames: [],
images: [],
animations: {},
recycleSpriteSheet: function () {
// We don't recycle this sprite sheet.
}
};
}
/**
* This method formats a provided value into a valid PIXIAnimation Sprite Sheet. This includes accepting the EaselJS spec, strings mapping to Platypus sprite sheets, or arrays of either.
*
* @method platypus.PIXIAnimation.formatSpriteSheet
* @param spriteSheet {String|Array|Object} The value to cast to a valid Sprite Sheet.
* @return {Object}
*/
static formatSpriteSheet (spriteSheet) {
const
imageParts = /([\w-\.]+)\.(\w+)$/,
addAnimations = function (source = {}, destination, speedRatio, firstFrameIndex, id) {
const
keys = Object.keys(source),
{length} = keys;
for (let i = 0; i < length; i++) {
const
key = keys[i];
if (destination[key]) {
arrayCache.recycle(destination[key].frames);
destination[key].recycle();
platypus.debug.log('PIXIAnimation "' + id + '": Overwriting duplicate animation for "' + key + '".');
}
destination[key] = formatAnimation(key, source[key], speedRatio, firstFrameIndex);
}
},
addFrameObject = function (source, destination, firstImageIndex, bases) {
const
{width, height, regX = 0, regY = 0} = source;
for (let i = 0; i < bases.length; i++) {
// Subtract the size of a frame so that margin slivers aren't returned as frames.
const
base = bases[i],
w = base.realWidth - width,
h = base.realHeight - height;
for (let y = 0; y <= h; y += height) {
for (let x = 0; x <= w; x += width) {
destination.push([x, y, width, height, i + firstImageIndex, regX, regY]);
}
}
}
},
addFrameArray = function (source, destination, firstImageIndex) {
for (let i = 0; i < source.length; i++) {
const
frame = source[i];
destination.push(arrayCache.setUp(
frame[0],
frame[1],
frame[2],
frame[3],
frame[4] + firstImageIndex,
frame[5],
frame[6]
));
}
},
createId = (images) => images.map((image) => (image.src ?? image).substring(0, MAX_KEY_LENGTH_PER_IMAGE)).join(','),
format = function (source, destination) {
const
{
animations: sAnims,
id: sID,
images: sImages,
framerate: sFR = 60,
frames: sFrames
} = source,
{
animations: dAnims,
id: dID,
images: dImages,
framerate: dFR = 60,
frames: dFrames
} = destination,
images = sImages.map((image) => formatImages(image)),
firstImageIndex = dImages.length,
firstFrameIndex = dFrames.length;
// Set up id
if (dID) {
destination.id = `${dID};${sID ?? createId(sImages)}`;
} else {
destination.id = sID ?? createId(sImages);
}
// Set up images array
dImages.push(...images);
// Set up frames array
if (Array.isArray(sFrames)) {
addFrameArray(sFrames, dFrames, firstImageIndex);
} else {
const
bases = getTextureSources(images);
addFrameObject(sFrames, dFrames, firstImageIndex, bases);
arrayCache.recycle(bases);
}
// Set up animations object
addAnimations(sAnims, dAnims, sFR / dFR, firstFrameIndex, destination.id);
arrayCache.recycle(images);
return destination;
},
formatAnimation = function (key, animation, speedRatio, firstFrameIndex) {
const
frames = arrayCache.setUp();
if (typeof animation === 'number') {
frames.push(animation + firstFrameIndex);
return Data.setUp(
"frames", frames,
"next", key,
"speed", speedRatio
);
} else if (Array.isArray(animation)) {
const
first = animation[0] ?? 0,
last = (animation[1] ?? first) + 1 + firstFrameIndex,
offsetFirst = first + firstFrameIndex;
for (let i = offsetFirst; i < last; i++) {
frames.push(i);
}
return Data.setUp(
"frames", frames,
"next", animation[2] || key,
"speed", (animation[3] || 1) * speedRatio
);
} else {
for (let i = 0; i < animation.frames.length; i++) {
frames.push(animation.frames[i] + firstFrameIndex);
}
return Data.setUp(
"frames", frames,
"next", animation.next || key,
"speed", (animation.speed || 1) * speedRatio
);
}
},
formatImages = function (name) {
if (typeof name === 'string') {
const
match = name.match(imageParts);
if (match) {
return match[1];
}
}
return name;
},
recycle = function () {
const
animations = this.animations,
keys = Object.keys(animations),
{length} = keys;
for (let i = 0; i < length; i++) {
const
key = keys[i];
arrayCache.recycle(animations[key].frames);
animations[key].recycle();
}
arrayCache.recycle(this.frames, 2);
this.frames = null;
arrayCache.recycle(this.images);
this.images = null;
this.recycle();
},
merge = function (spriteSheets, destination) {
let i = spriteSheets.length;
while (i--) {
let ss = spriteSheets[i];
if (typeof ss === 'string') {
ss = platypus.game.settings.spriteSheets[ss];
}
if (ss) {
format(ss, destination);
}
}
return destination;
};
let response = PIXIAnimation.EmptySpriteSheet,
ss = spriteSheet;
if (typeof ss === 'string') {
ss = platypus.game.settings.spriteSheets[spriteSheet];
}
if (ss) {
response = Data.setUp(
"animations", Data.setUp(),
"framerate", 60,
"frames", arrayCache.setUp(),
"id", '',
"images", arrayCache.setUp(),
"recycleSpriteSheet", recycle
);
if (Array.isArray(ss)) {
return merge(ss, response);
} else if (ss) {
return format(ss, response);
}
}
return response;
}
};
export default PIXIAnimation;