components/RenderAnimator.js

import StateMap from '../StateMap.js';
import {arrayCache} from '../utils/array.js';
import createComponentClass from '../factory.js';

const
    createTest = function (testStates, animation) {
        if (testStates === 'default') {
            return () => animation;
        } else {
            //TODO: Better clean-up: Create a lot of these without removing them later... DDD 2/5/2016
            const
                states = StateMap.setUp(testStates);

            return (ownerState) => ownerState.includes(states) ? animation : false;
        }
    },
    methodPlay = function (animation, loop, restart) {
        this.component.playAnimation(animation, loop, restart);
    },
    methodStop = function (animation) {
        this.component.stopAnimation(animation);
    },
    triggerPlay = function (animation, loop, restart) {
        /**
         * On entering a new animation-mapped state, this component triggers this event to play an animation.
         *
         * @event platypus.Entity#play-animation
         * @param animation {String} Describes the animation to play.
         * @param loop {Boolean} Whether to loop a playing animation.
         * @param restart {Boolean} Whether to restart a playing animation.
         */
        this.owner.triggerEvent('play-animation', animation, loop, restart);
    },
    triggerStop = function (animation) {
        /**
         * On attaining an animation-mapped state, this component triggers this event to stop a previous animation.
         *
         * @event platypus.Entity#stop-animation
         * @param animation {String} Describes the animation to stop.
         */
        this.owner.triggerEvent('stop-animation', animation);
    };

export default createComponentClass(/** @lends platypus.components.RenderAnimator.prototype */{
    id: 'RenderAnimator',

    properties: {
        /**
         * An object containg key-value pairs that define a mapping from entity states to the animation that should play. The list is processed from top to bottom, so the most important actions should be listed first (for example, a jumping animation might take precedence over an idle animation). If not specified, an 1-to-1 animation map is created from the list of animations in the sprite sheet definition using the animation names as the keys.
         *
         *  "animationStates":{
         *      "standing": "default-animation"  // On receiving a "standing" event, or when this.owner.state.standing === true, the "default" animation will begin playing.
         *      "ground,moving": "walking",  // Comma separated values have a special meaning when evaluating "state-changed" messages. The above example will cause the "walking" animation to play ONLY if the entity's state includes both "moving" and "ground" equal to true.
         *      "ground,striking": "swing!", // Putting an exclamation after an animation name causes this animation to complete before going to the next animation. This is useful for animations that would look poorly if interrupted.
         *      "default": "default-animation" // Optional. "default" is a special property that matches all states. If none of the above states are valid for the entity, it will use the default animation listed here.
         *  }
         *
         * @property animationStates
         * @type Object
         * @default null
         */
        animationStates: null,

        /**
         * An object containg key-value pairs that define a mapping from triggered events to the animation that should play.
         *
         *     "animationEvents":{
         *         "move": "walk-animation",
         *         "jump": "jumping-animation"
         *     }
         *
         * The above will create two event listeners on the entity, "move" and "jump", that will play their corresponding animations when the events are triggered.
         *
         * @property animationEvents
         * @type Object
         * @default null
         */
        animationEvents: null,

        /**
         * Sets a component that this component should be connected to.
         *
         * @property component
         * @type Component
         * @default null
         */
        component: null,

        /**
         * Optional. Forces animations to complete before starting a new animation. Defaults to `false`.
         *
         * @property forcePlayThrough
         * @type Boolean
         * @default false
         */
        forcePlayThrough: false,

        /**
         * Whether to restart a playing animation on event.
         *
         * @property restart
         * @type Boolean
         * @default true
         */
        restart: true,

        /**
         * Whether to loop a playing animation on event.
         *
         * @property loop
         * @type Boolean
         * @default false
         */
        loop: false
    },

    /**
     * This component is typically added to an entity automatically by a render component. It handles mapping entity states and events to playable animations.
     *
     * @memberof platypus.components
     * @uses platypus.Component
     * @constructs
     * @listens platypus.Entity#animation-ended
     * @listens platypus.Entity#state-changed
     * @listens platypus.Entity#update-animation
     * @fires platypus.Entity#play-animation
     * @fires platypus.Entity#stop-animation
     */
    initialize: (function () {
        const
            trigger = function (animation, loop, restart) {
                this.override = animation;
                this.owner.triggerEvent('play-animation', animation, loop, restart);
            },
            method = function (animation, loop, restart) {
                this.override = animation;
                this.playAnimation(animation, loop, restart);
            };

        return function () {
            const
                events = this.animationEvents,
                states = this.animationStates;

            //Handle Events:
            this.override = false;
            if (events) {
                const
                    keys = Object.keys(events),
                    {length} = keys,
                    component = this.component,
                    func = component ? method : trigger;
        
                for (let i = 0; i < length; i++) {
                    const
                        key = keys[i];
                        
                    this.addEventListener(key, func.bind(component ?? this, events[key], this.loop, this.restart));
                }
            }

            //Handle States:
            this.followThroughs = {};
            this.checkStates = arrayCache.setUp();
            this.state = this.owner.state;
            this.stateChange = true; //Check state against entity's prior state to update animation if necessary on instantiation.
            this.lastState = -1;

            if (states) {
                const
                    keys = Object.keys(states),
                    {length} = keys;

                for (let i = 0; i < length; i++) {
                    const
                        key = keys[i],
                        animationLabel = states[key],
                        lastCharacter = animationLabel.length - 1,
                        isNot = animationLabel[lastCharacter] === '!',
                        animation = isNot ? animationLabel.substring(0, lastCharacter) : animationLabel;

                    //TODO: Should probably find a cleaner way to accomplish this. Maybe in the animationMap definition? - DDD
                    this.followThroughs[animation] = isNot;
                    this.checkStates.push(createTest(key, animation));
                }
            }

            this.waitingAnimation = false;
            this.waitingState = 0;
            this.playWaiting = false;
            this.animationFinished = false;

            if (this.component) {
                this.playAnimation = methodPlay;
                this.stopAnimation = methodStop;
            } else {
                this.playAnimation = triggerPlay;
                this.stopAnimation = triggerStop;
            }
        };
    } ()),

    events: {
        "state-changed": function () {
            this.stateChange = true;
        },

        "animation-ended": function (animation) {
            if (animation === this.currentAnimation) {
                if (this.override && (animation === this.override)) {
                    this.stateChange = true;
                    this.override = false;
                }

                if (this.waitingAnimation) {
                    this.currentAnimation = this.waitingAnimation;
                    this.waitingAnimation = false;
                    this.lastState = this.waitingState;
                    
                    this.animationFinished = false;
                    this.playAnimation(this.currentAnimation);
                } else {
                    this.animationFinished = true;
                }
            }
        },

        "update-animation": function (playing) {
            if (this.stateChange && !this.override) {
                if (this.state.has('visible')) {
                    this.visible = this.state.get('visible');
                }
                for (let i = 0; i < this.checkStates.length; i++) {
                    const
                        testCase = this.checkStates[i](this.state);

                    if (testCase) {
                        if (this.currentAnimation !== testCase) {
                            if (!this.followThroughs[this.currentAnimation] && (!this.forcePlaythrough || (this.animationFinished || (this.lastState >= +i)))) {
                                this.currentAnimation = testCase;
                                this.lastState = +i;
                                this.animationFinished = false;
                                if (playing) {
                                    this.playAnimation(this.currentAnimation);
                                } else {
                                    this.stopAnimation(this.currentAnimation);
                                }
                            } else {
                                this.waitingAnimation = testCase;
                                this.waitingState = +i;
                            }
                        } else if (this.waitingAnimation && !this.followThroughs[this.currentAnimation]) {// keep animating this animation since this animation has already overlapped the waiting animation.
                            this.waitingAnimation = false;
                        }
                        break;
                    }
                }
                this.stateChange = false;
            }
        }
    },
    
    methods: {
        toJSON: function () { // This component is added by another component, so it shouldn't be returned for reconstruction.
            return null;
        },

        destroy: function () {
            arrayCache.recycle(this.checkStates);
            this.checkStates = null;
            this.followThroughs = null;
            this.state = null;
        }
    }
});