components/Orientation.js

import {arrayCache, greenSplice} from '../utils/array.js';
import Data from '../Data.js';
import Vector from '../Vector.js';
import createComponentClass from '../factory.js';
import {greenSplit} from '../utils/string.js';

const
    normal = Vector.setUp(0, 0, 1),
    origin = Vector.setUp(1, 0, 0),
    matrices = {
        'identity': [[  1,  0,  0],
                        [  0,  1,  0],
                        [  0,  0,  1]],
        'horizontal': [[ -1,  0,  0],
                        [  0,  1,  0],
                        [  0,  0, -1]],
        'vertical': [[  1,  0,  0],
                        [  0, -1,  0],
                        [  0,  0, -1]],
        'diagonal': [[  0,  1,  0],
                        [  1,  0,  0],
                        [  0,  0, -1]],
        'diagonal-inverse': [[  0, -1,  0],
                                [ -1,  0,  0],
                                [  0,  0, -1]],
        'rotate-90': [[  0, -1,  0],
                        [  1,  0,  0],
                        [  0,  0,  1]],
        'rotate-180': [[ -1,  0,  0],
                        [  0, -1,  0],
                        [  0,  0,  1]],
        'rotate-270': [[  0,  1,  0],
                        [ -1,  0,  0],
                        [  0,  0,  1]]
    },
    multiply = (function () {
        const
            cell = function (row, column, m) {
                let sum = 0;

                for (let i = 0; i < row.length; i++) {
                    sum += row[i] * m[i][column];
                }

                return sum;
            };

        return function (a, b, dest) {
            const
                arr = arrayCache.setUp();

            for (let i = 0; i < a.length; i++) {
                for (let j = 0; j < a[0].length; j++) {
                    arr.push(cell(a[i], j, b));
                }
            }

            for (let i = 0; i < a.length; i++) {
                for (let j = 0; j < a[0].length; j++) {
                    dest[i][j] = arr.shift();
                }
            }
            
            arrayCache.recycle(arr);
        };
    }()),
    identitize = function (m) {
        for (let i = 0; i < 3; i++) {
            for (let j = 0; j < 3; j++) {
                if (i === j) {
                    m[i][j] = 1;
                } else {
                    m[i][j] = 0;
                }
            }
        }

        return m;
    },
    setupOrientation = function (self, orientation) {
        const
            vector = Vector.setUp(1, 0, 0),
            owner  = self.owner,
            matrix = arrayCache.setUp(
                arrayCache.setUp(1, 0, 0),
                arrayCache.setUp(0, 1, 0),
                arrayCache.setUp(0, 0, 1)
            );
        
        Object.defineProperty(owner, 'orientationMatrix', {
            get: function () {
                multiply(self.matrixTween, self.matrix, identitize(matrix));
                return matrix;
            },
            enumerable: true
        });

        delete owner.orientation;
        Object.defineProperty(owner, 'orientation', {
            get: function () {
                return vector.signedAngleTo(origin, normal);
            },
            set: function (value) {
                vector.setVector(origin).rotate(value);
            },
            enumerable: true
        });

        Object.defineProperty(owner, 'rotation', {
            get: function () {
                return owner.orientation / Math.PI * 180;
            },
            set: function (value) {
                owner.orientation = value * Math.PI / 180;
            },
            enumerable: true
        });

        if (orientation) {
            if (typeof orientation !== 'number') {
                vector.set(orientation);
            } else {
                vector.rotate(orientation);
            }
        }

        return vector;
    };
    
export default createComponentClass(/** @lends platypus.components.Orientation.prototype */{
    id: 'Orientation',
    publicProperties: {
        /**
         * The Entity's scale along the X-axis will mirror the entity's initial orientation if it is negative. This value is available via `entity.scaleX`, but is not manipulated by this component after instantiation.
         *
         * @property scaleX
         * @type number
         * @default 1
         */
        "scaleX": 1,

        /**
         * The Entity's scale along the Y-axis will flip the entity's initial orientation if it is negative. This value is available via `entity.scaleY`, but is not manipulated by this component after instantiation.
         *
         * @property scaleY
         * @type number
         * @default 1
         */
        "scaleY": 1,

        /**
         * The Entity's rotation will rotate entity's initial orientation if it is a multiple of 90 degrees. This value is available via `entity.rotation`, but is not manipulated by this component after instantiation.
         *
         * @property rotation
         * @type number
         * @default 0
         */
        "rotation": 0,

        /**
         * The Entity's orientation is an angle in radians describing an entity's orientation around the Z-axis. This property is affected by a changing `entity.orientationMatrix` but does not itself change the orientation matrix.
         *
         * @property orientation
         * @type number
         * @default 0
         */
        "orientation": 0,
        
        /**
         * The entity's orientation matrix determines the orientation of an entity and its vectors. It's a 3x3 2D Array describing an affine transformation of the entity.
         *
         * @property orientationMatrix
         * @type Array
         * @default 3x3 identity matrix
         */
        "orientationMatrix": null
    },

    /**
     * This component handles the orientation of an entity. It maintains an `orientationMatrix` property on the owner to describe the entity's orientation using an affine transformation matrix.
     *
     * Several methods on this component accept either a 3x3 2D Array or a string to describe orientation changes. Accepted strings include:
     *  - "horizontal"       - This flips the entity around the y-axis.
     *  - "vertical"         - This flips the entity around the x-axis.
     *  - "diagonal"         - This flips the entity around the x=y axis.
     *  - "diagonal-inverse" - This flips the entity around the x=-y axis.
     *  - "rotate-90"        - This rotates the entity 90 degrees clockwise.
     *  - "rotate-180"       - This rotates the entity 180 degrees clockwise (noticeable when tweening).
     *  - "rotate-270"       - This rotates the entity 90 degrees counter-clockwise.
     *
     * NOTE: This component absorbs specific properties already on the entity into orientation:
     *  - **orientationMatrix**: 3x3 2D array describing an affine transformation.
     *  - If the above is not provided, these properties are used to set initial orientation. This is useful when importing Tiled maps.
     *     - **scaleX**: absorb -1 if described
     *     - **scaleY**: absorb -1 if described
     *     - **rotation**: absorb 90 degree rotations
     *
     * @memberof platypus.components
     * @uses platypus.Component
     * @constructs
     * @listens platypus.Entity#handle-logic
     * @listens platypus.Entity#append-transform
     * @listens platypus.Entity#complete-tweens
     * @listens platypus.Entity#drop-tweens
     * @listens platypus.Entity#load
     * @listens platypus.Entity#transform
     * @listens platypus.Entity#translate
     * @listens platypus.Entity#orient-vector
     * @listens platypus.Entity#prepend-transform
     * @listens platypus.Entity#remove-vector
     * @listens platypus.Entity#replace-transform
     * @listens platypus.Entity#tween-transform
     * @fires platypus.Entity#orient-vector
     * @fires platypus.Entity#orientation-updated
     * @fires platypus.Entity#relocate-entity
     */
    initialize () {
        this.loadedOrientationMatrix = this.orientationMatrix;
        
        // This is the stationary transform
        this.matrix = arrayCache.setUp(
            arrayCache.setUp(1, 0, 0),
            arrayCache.setUp(0, 1, 0),
            arrayCache.setUp(0, 0, 1)
        );
        
        // This is the tweening transform
        this.matrixTween = arrayCache.setUp(
            arrayCache.setUp(1, 0, 0),
            arrayCache.setUp(0, 1, 0),
            arrayCache.setUp(0, 0, 1)
        );
        
        this.relocationMessage = Data.setUp(
            "position", null
        );
        
        this.vectors  = arrayCache.setUp();
        this.inverses = arrayCache.setUp();
        this.tweens   = arrayCache.setUp();
        
        this.orientationVector = setupOrientation(this, this.orientation);

        /**
         * On receiving a vector via this event, the component will transform the vector using the current orientation matrix and then store the vector and continue manipulating it as the orientation matrix changes.
         *
         * @event platypus.Entity#orient-vector
         * @param vector {platypus.Vector} The vector whose orientation will be maintained.
         */
        this.owner.triggerEvent('orient-vector', this.orientationVector);
        
        this.owner.state.set('reorienting', false);
    },

    events: {
        "load": function () {
            if (this.loadedOrientationMatrix) {
                this.transform(this.loadedOrientationMatrix);
            } else {
                if (this.scaleX && this.scaleX < 0) {
                    this.scaleX = -this.scaleX;
                    this.transform('horizontal');
                }
                if (this.scaleY && this.scaleY < 0) {
                    this.scaleY = -this.scaleY;
                    this.transform('vertical');
                }
                if (this.rotation) {
                    if (((this.rotation + 270) % 360) === 0) {
                        this.rotation = 0;
                        this.transform('rotate-90');
                    } else if (((this.rotation + 180) % 360) === 0) {
                        this.rotation = 0;
                        this.transform('rotate-180');
                    } else if (((this.rotation + 90) % 360) === 0) {
                        this.rotation = 0;
                        this.transform('rotate-270');
                    }
                }
            }
            delete this.loadedOrientationMatrix;
        },
        
        "handle-logic": function ({delta}) {
            const
                state = this.owner.state;
            let i = this.tweens.length;
            
            if (i) {
                const
                    finishedTweening = arrayCache.setUp();

                state.set('reorienting', true);
                identitize(this.matrixTween);
                
                while (i--) {
                    if (this.updateTween(this.tweens[i], delta)) { // finished tweening
                        finishedTweening.push(greenSplice(this.tweens, i));
                    }
                }
                
                i = this.vectors.length;
                while (i--) {
                    this.updateVector(this.vectors[i], this.inverses[i]);
                }
                
                i = finishedTweening.length;
                while (i--) {
                    const
                        tween = finishedTweening[i];

                    this.transform(tween.endMatrix);
                    if (tween.anchor) {
                        const
                            msg = this.relocationMessage;

                        tween.offset.multiply(tween.endMatrix).addVector(tween.anchor);
                        msg.position = tween.offset;
                        this.owner.triggerEvent('relocate-entity', msg);
                        if (tween.recycleOffset) {
                            tween.offset.recycle();
                        }
                    }
                    tween.onFinished(tween.endMatrix);
                    tween.recycle();
                }
                
                arrayCache.recycle(finishedTweening);
            } else if (state.get('reorienting')) {
                identitize(this.matrixTween);
                state.set('reorienting', false);
            }
        },
        
        /**
         * On receiving this message, any currently running orientation tweens are immediately completed to give the entity a new stable position.
         *
         * @event platypus.Entity#complete-tweens
         */
        "complete-tweens": function () {
            for (let i = 0; i < this.tweens.length; i++) {
                this.tweens[i].time = this.tweens[i].endTime;
            }
        },
        
        /**
         * On receiving this message, any currently running orientation tweens are discarded, returning the entity to its last stable position.
         *
         * @event platypus.Entity#drop-tweens
         */
        "drop-tweens": function () {
            let i = this.tweens.length;

            while (i--) {
                if (this.tweens[i].offset) {
                    this.tweens[i].offset.recycle();
                }
            }
            this.tweens.length = 0;
            
            i = this.vectors.length;
            while (i--) {
                this.updateVector(this.vectors[i], this.inverses[i]);
            }
        },
        
        "orient-vector": function (vector) {
            const
                aligned = vector.aligned || false;
            
            if (vector.vector) {
                vector = vector.vector;
            }
            
            if (this.vectors.indexOf(vector) === -1) {
                if (!aligned) {
                    vector.multiply(this.matrix);
                }
                this.vectors.push(vector);
                this.inverses.push(Vector.setUp());
            }
        },
        
        "remove-vector": function (vector) {
            const
                i = this.vectors.indexOf(vector);
            
            if (i >= 0) {
                greenSplice(this.vectors, i);
                greenSplice(this.inverses, i).recycle();
            }
        },
        
        /**
         * This message performs a timed transform of the entity by performing the transformation via a prepended matrix multiplication.
         *
         * @event platypus.Entity#tween-transform
         * @param transform {Array|String} A 3x3 @D Array or a string describing a transformation.
         */
        "tween-transform": function (options) {
            this.tweenTransform(options);
        },
        
        /**
         * This message performs an immediate transform of the entity by performing the transformation via a prepended matrix multiplication.
         *
         * @event platypus.Entity#transform
         * @param transform {Array|String} A 3x3 @D Array or a string describing a transformation.
         */
        "transform": function (transform) {
            this.transform(transform);
        },
        
        /**
         * This message performs an immediate transform of the entity by performing the transformation via a prepended matrix multiplication.
         *
         * @event platypus.Entity#prepend-transform
         * @param transform {Array|String} A 3x3 @D Array or a string describing a transformation.
         */
        "prepend-transform": function (transform) {
            this.transform(transform);
        },
        
        /**
         * This message performs an immediate transform of the entity by performing the transformation via an appended matrix multiplication.
         *
         * @event platypus.Entity#append-transform
         * @param transform {Array|String} A 3x3 @D Array or a string describing a transformation.
         */
        "append-transform": function (transform) {
            this.transform(transform, true);
        },
        
        /**
         * This message performs an immediate transform of the entity by returning the entity to an identity transform before performing a matrix multiplication.
         *
         * @event platypus.Entity#replace-transform
         * @param transform {Array|String} A 3x3 @D Array or a string describing a transformation.
         */
        "replace-transform": function (transform) {
            if (Array.isArray(transform)) {
                this.replace(transform);
            } else if (typeof transform === 'string') {
                if (matrices[transform]) {
                    this.replace(matrices[transform]);
                }
            }
        }
    },
    
    methods: {
        transform: function (transform, append) {
            if (Array.isArray(transform)) {
                this.multiply(transform, append);
            } else if (typeof transform === 'string') {
                if (matrices[transform]) {
                    this.multiply(matrices[transform], append);
                }
            }
        },
        
        multiply: function (m, append) {
            if (append) {
                multiply(this.matrix, m, this.matrix);
            } else {
                multiply(m, this.matrix, this.matrix);
            }
            
            for (let i = 0; i < this.vectors.length; i++) {
                this.vectors[i].multiply(m);
                this.inverses[i].multiply(m);
            }
            
            /**
             * Once a transform is complete, this event is triggered to notify the entity of the completed transformation.
             *
             * @event platypus.Entity#orientation-updated
             * @param matrix {Array} A 3x3 2D array describing the change in orientation.
             */
            this.owner.triggerEvent('orientation-updated', m);
        },

        replace: (function () {
            const
                det2 = function (a, b, c, d) {
                    return a * d - b * c;
                },
                det3 = function (a) {
                    let sum = 0;

                    for (let i = 0; i < 3; i++) {
                        sum += a[i][0] * a[(i + 1) % 3][1] * a[(i + 2) % 3][2];
                        sum -= a[i][2] * a[(i + 1) % 3][1] * a[(i + 2) % 3][0];
                    }
                    return sum;
                },
                invert = function (a) {
                    const
                        arr = arrayCache.setUp(arrayCache.setUp(), arrayCache.setUp(), arrayCache.setUp()),
                        inv = 1 / det3(a);

                    arr[0].push(det2(a[1][1], a[1][2], a[2][1], a[2][2]) * inv);
                    arr[0].push(det2(a[0][2], a[0][1], a[2][2], a[2][1]) * inv);
                    arr[0].push(det2(a[0][1], a[0][2], a[1][1], a[1][2]) * inv);
                    arr[1].push(det2(a[1][2], a[1][0], a[2][2], a[2][0]) * inv);
                    arr[1].push(det2(a[0][0], a[0][2], a[2][0], a[2][2]) * inv);
                    arr[1].push(det2(a[0][2], a[0][0], a[1][2], a[1][0]) * inv);
                    arr[2].push(det2(a[1][0], a[1][1], a[2][0], a[2][1]) * inv);
                    arr[2].push(det2(a[0][1], a[0][0], a[2][1], a[2][0]) * inv);
                    arr[2].push(det2(a[0][0], a[0][1], a[1][0], a[1][1]) * inv);

                    return arr;
                };
            
            return function (m) {
                const
                    inversion = invert(this.matrix);
                
                // We invert the matrix so we can re-orient all vectors for the incoming replacement matrix.
                this.multiply(inversion);
                this.multiply(m);
                
                // clean-up
                arrayCache.recycle(inversion, 2);
            };
        }()),
        
        updateTween: (function () {
            const
                getMid = function (a, b, t) {
                    return (a * (1 - t) + b * t);
                };
            
            return function (tween, delta) {
                const
                    m = tween.endMatrix;
                let t = 0,
                    a = 1,                //  a c -
                    b = 0,                //  b d -
                    c = 0,                //  - - z
                    d = 1,
                    z = 1,
                    matrix = null,
                    angle = 0;
                
                if (tween.beforeTick(tween.time)) {
                    tween.time += delta;
                }
                
                if (tween.time >= tween.endTime) {
                    return true;
                }
                
                t = tween.tween(tween.time / tween.endTime);
                
                if (tween.angle) {
                    angle = t * tween.angle;
                    a = d = Math.cos(angle);
                    b = Math.sin(angle);
                    c = -b;
                } else {
                    a = getMid(a, m[0][0], t);
                    b = getMid(b, m[1][0], t);
                    c = getMid(c, m[0][1], t);
                    d = getMid(d, m[1][1], t);
                    z = getMid(z, m[2][2], t);
                }
                
                matrix = arrayCache.setUp(
                    arrayCache.setUp(a, c, 0),
                    arrayCache.setUp(b, d, 0),
                    arrayCache.setUp(0, 0, z)
                );

                multiply(this.matrixTween, matrix, this.matrixTween);
                
                if (tween.anchor) {
                    const
                        initialOffset = Vector.setUp(tween.offset).multiply(1 - t),
                        finalOffset = Vector.setUp(tween.offset).multiply(t);
                    
                    this.owner.triggerEvent('relocate-entity', {
                        position: initialOffset.add(finalOffset).multiply(matrix).addVector(tween.anchor)
                    });
                    
                    initialOffset.recycle();
                    finalOffset.recycle();
                }

                tween.afterTick(t, matrix);
                
                arrayCache.recycle(matrix, 2);
                
                return false;
            };
        }()),
        
        updateVector: function (vector, inverse) {
            inverse.setVector(vector.add(inverse)); // Inverses are stored to return to the original postion, *but* also allow outside changes on the vectors to be retained. This introduces floating point errors on tweened vectors. - DDD 2/10/2016
            vector.multiply(this.matrixTween);
            inverse.subtractVector(vector);
        },
        
        destroy: function () {
            arrayCache.recycle(this.vectors); this.vectors = null;
            arrayCache.recycle(this.inverses); this.inverses = null;
            arrayCache.recycle(this.tweens); this.tweens = null;
            this.orientationVector.recycle(); this.orientationVector = null;
            arrayCache.recycle(this.orientationMatrix, 2);/* this.orientationMatrix = null; - Only has a setter */
            arrayCache.recycle(this.matrix, 2); this.matrix = null;
            arrayCache.recycle(this.matrixTween, 2); this.matrixTween = null;
            this.relocationMessage.recycle(); this.relocationMessage = null;
        }
    },

    publicMethods: {
        /**
         * This message causes the component to begin tweening the entity's orientation over a span of time into the new orientation.
         *
         * @method platypus.components.Orientation#tweenTransform
         * @param {Object} options A list of key/value pairs describing the tween options.
         * @param {Array} options.matrix A transformation matrix: only required if `transform` is not provided
         * @param {String} options.transform A transformation type: only required if `matrix` is not provided.
         * @param {number} options.time The time over which the tween occurs. 0 makes it instantaneous.
         * @param {platypus.Vector} [options.anchor] The anchor of the orientation change. If not provided, the owner's position is used.
         * @param {platypus.Vector} [options.offset] If an anchor is supplied, this vector describes the entity's distance from the anchor. It defaults to the entity's current position relative to the anchor position.
         * @param {number} [options.angle] Angle in radians to transform. This is only valid for rotations and is derived from the transform if not provided.
         * @param {Function} [options.tween] A function describing the transition. Performs a linear transition by default. See CreateJS Ease for other options.
         * @param {Function} [options.beforeTick] A function that should be processed before each tick as the tween occurs. This function should return `true`, otherwise the tween doesn't take a step.
         * @param {Function} [options.afterTick] A function that should be processed after each tick as the tween occurs.
         * @param {Function} [options.onFinished] A function that should be run once the transition is complete.
         */
        tweenTransform: (function () {
            const
                doNothing = function () {
                    // Doing nothing!
                },
                returnTrue = function () {
                    return true;
                },
                linearEase = function (t) {
                    return t;
                };

            return function (props) {
                const
                    matrix = props.matrix ?? matrices[props.transform],
                    tween  = Data.setUp(
                        "transform", props.transform,
                        "anchor", props.anchor,
                        "endTime", props.time || 0,
                        "time", 0,
                        "tween", props.tween || linearEase,
                        "onFinished", props.onFinished || doNothing,
                        "beforeTick", props.beforeTick || returnTrue,
                        "afterTick", props.afterTick || doNothing
                    );
                let angle = props.angle || 0;
                
                tween.endMatrix = matrix;
                
                if (!angle && (props.transform.indexOf('rotate') === 0)) {
                    switch (props.transform) {
                    case 'rotate-90':
                        angle = Math.PI / 2;
                        break;
                    case 'rotate-180':
                        angle = Math.PI;
                        break;
                    case 'rotate-270':
                        angle = -Math.PI / 2;
                        break;
                    default: {
                            const
                                arr = greenSplit(props.transform, '-');

                            angle = (arr[1] / 180) * Math.PI;
                            arrayCache.recycle(arr);
                            break;
                        }
                    }
                }
                tween.angle = angle;
                
                if (props.anchor) {
                    tween.offset = props.offset;
                    if (!tween.offset) {
                        tween.offset = this.owner.position.copy().subtractVector(props.anchor, 2);
                        tween.recycleOffset = true;
                    }
                }
                
                this.tweens.push(tween);
            };
        }())
    }
});