components/Timeline.js

/* global platypus */
import {arrayCache, greenSplice} from '../utils/array.js';
import Data from '../Data.js';
import createComponentClass from '../factory.js';
import TimeEventList from '../TimeEventList.js';

const
    pause = function () {
        this.active--;
    },
    play = function () {
        this.active++;
    },
    updateLogic = function (tick) {
        const
            {executionFlow, owner, timelineInstances} = this,
            delta = tick.delta;
        let i = timelineInstances.length,
            removalOccurred = false;
        
        while (i--) {
            const
                instance = timelineInstances[i];

            if (instance.remove) {
                greenSplice(timelineInstances, i);
                instance.timeline.recycle();
                instance.recycle();
                removalOccurred = true;
            } else if (instance.active) {
                if (instance.timeline.list.length === 0) {
                    greenSplice(timelineInstances, i);
                    instance.timeline.recycle();
                    instance.recycle();
                    removalOccurred = true;
                } else {
                    this.progressTimeline(instance, delta);
                }
            }
        }
        
        if (timelineInstances.length) {
            if (removalOccurred && executionFlow === 'serial') {
                const
                    timeline = timelineInstances[0];
                    
                if (!timeline.active) {
                    timeline.play();
                }
            }
            owner.triggerEvent('timeline-progress', timelineInstances);
        }
    };

export default createComponentClass(/** @lends platypus.components.Timeline.prototype */{
    
    id: 'Timeline',
    
    properties: {
        /**
         * Sets the execution flow if multiple timelines are triggered.
         *     * "parallel" - Each timeline begins immediately when triggered and does not directly affect other timelines.
         *     * "serial" - A timeline doesn't start if another timeline is currently active: it will append itself to a queue of timelines and run eventually.
         *     * "exclusive" - Timeline begins immediately and will stop any other active timeline.
         *     * "tentative" - Timeline begins if no other timelines are active, otherwise it is ignored.
         */
        executionFlow: 'parallel',

        /**
         * Defines the set of timelines. Triggering the key for one of the events will run the timeline. A timeline can contain three different types integers >= 0, strings, and objects. Integers are interpreted as waits and define
         * pauses between events. Strings are intepreted as event calls. Objects can contain several parameters: entity, event, message. The entity is the id of the entity that
         * the event will be fired on. The event can be a string or array. If a string, it will call that event on the entity or owner. If an array, the value will be passed
         * to the event handling system.
         *
         *  "timelines": {
         *      "sample-timeline-1": [
         *          500,
         *          "sample-event",
         *          {"event": "sample-event", "message": "sample-message"},
         *          {"entity": "entity-id-to-trigger-event-on", "event": "sample-event"},
         *          {"event": ["sample-event", "sample-event-2", {"event": "sample-event-3", "message": "sample-message"}]},
         *      ],
         *      "sample-timeline-2": [
         *          200,
         *          "sample-event"
         *      ]
         * }
         *
         * @property timelines
         * @type Object
         * @default {}
         */
        timelines: {}
    },
    
    /**
     * Timeline enables the scheduling of events based on a linear timeline
     *
     * @memberof platypus.components
     * @uses platypus.Component
     * @constructs
     * @listens platypus.Entity#handle-logic
     * @listens platypus.Entity#stop-active-timelines
     */
    initialize: function () {
        const
            keys = Object.keys(this.timelines),
            {length} = keys;

        this.timelineInstances = arrayCache.setUp();

        for (let i = 0; i < length; i++) {
            const
                timelineId = keys[i];

            this.addTimeline(timelineId, this.timelines[timelineId]);
        }
    },

    events: {
        "handle-logic": updateLogic,

        "pause-timelines": function () {
            this.timelineInstances.forEach((timeline) => {
                if (timeline.active) {
                    timeline.pause();
                }
            });
        },

        "play-timelines": function () {
            const
                {executionFlow, timelineInstances} = this;

            if (executionFlow === 'serial') {
                if (timelineInstances.length) { 
                    const
                        timeline = timelineInstances[0];
                        
                    if (!timeline.active) {
                        timeline.play();
                    }
                }
            } else {
                timelineInstances.forEach((timeline) => {
                    if (!timeline.active) {
                        timeline.play();
                    }
                });
            }
        },

        /**
         * Stops active timelines.
         *
         * @event platypus.Entity#stop-active-timelines
         */
        "stop-active-timelines": function () {
            const
                instances = this.timelineInstances;
            let i = instances.length;

            while (i--) {
                if (instances[i].active) {
                    instances[i].remove = true;
                }
            }
        },

        /**
         * Stops all timelines.
         *
         * @event platypus.Entity#stop-all-timelines
         */
        "stop-all-timelines": function () {
            const
                instances = this.timelineInstances;
            let i = instances.length;

            while (i--) {
                instances[i].remove = true;
            }
        },

        /**
         * Add a timeline dynamically.
         *
         * @event platypus.Entity#add-timeline
         * @param eventId {String} The event to listen for to trigger the timeline.
         * @param timeline {Array} The array of timeline data.
         */
        "add-timeline": function (eventId, timeline) {
            this.addTimeline(eventId, timeline);
        },

        /**
         * Play a timeline dynamically.
         *
         * @event platypus.Entity#start-timeline
         * @param timeline {Array} The array of timeline data.
         */
        "start-timeline": function (timeline) {
            this.startTimeline(timeline);
        }
    },
    
    methods: {
        addTimeline: function (eventId, timeline) {
            this.addEventListener(eventId, () => this.startTimeline(timeline));
        },
        createTimeStampedTimeline: function (timeline, active = 1) {
            const
                timeStampedTimeline = TimeEventList.setUp(timeline);

            return Data.setUp(
                "timeline", timeStampedTimeline,
                "time", 0,
                "total", timeStampedTimeline.getDuration(),
                "active", 1,
                "pause", pause,
                "play", play,
                "remove", false
            );
        },
        startTimeline (timeline) {
            const
                {executionFlow, timelineInstances} = this;

            switch (executionFlow) {
            case 'serial':
                timelineInstances.push(this.createTimeStampedTimeline(timeline), timelineInstances.some(({active}) => active) ? 0 : 1);
                return true;
            case 'exclusive':
                timelineInstances.forEach((instance) => instance.remove = true);
                timelineInstances.push(this.createTimeStampedTimeline(timeline));
                return true;
            case 'tentative':
                if (!timelineInstances.some(({active}) => active)) {
                    timelineInstances.push(this.createTimeStampedTimeline(timeline));
                    return true;
                }
                return false;
            default:
                timelineInstances.push(this.createTimeStampedTimeline(timeline));
                return true;
            }
        },
        progressTimeline (instance, delta) {
            const
                timeline = instance.timeline;
            
            instance.time += delta;
            
            //Go through the timeline playing events if the time has progressed far enough to trigger them.
            timeline.getEvents(instance.time).forEach((entry) => {
                const
                    {event, entity} = entry;

                if (typeof event === 'function') {
                    event(this.owner, instance);
                } else {
                    let triggerOn = this.owner;

                    if (entity) {
                        if (typeof entity === 'string') {
                            if (this.owner.getEntityById) {
                                triggerOn = this.owner.getEntityById(entity);
                            } else {
                                triggerOn = this.owner.parent.getEntityById(entity);
                            }
                        } else {
                            triggerOn = entity; // Maybe it's an Entity.
                        }
                        
                        if (!triggerOn) {
                            platypus.debug.warn('No entity of that id');
                            triggerOn = this.owner;
                        }
                    }
                    
                    triggerOn.trigger(entry);
                }
                
                entry.recycle();
            });
        },
        destroy: function () {
            const
                instances = this.timelineInstances;
            let i = instances.length;
            
            while (i--) {
                const
                    instance = instances[i];

                instance.timeline.recycle();
                instance.recycle();
            }
            arrayCache.recycle(instances);
            this.timelineInstances = null;
        }
    },

    publicMethods: {
        // asynchronous wait that incorporates ticker pauses.
        wait (time) {
            return new Promise ((resolve, reject) => {
                if (!this.startTimeline([time, resolve])) {
                    reject();
                }
            });
        }
    }
});