components/RenderContainer.js

/* global platypus */
import {Container, Graphics, Matrix, filters} from 'pixi.js';
import AABB from '../AABB.js';
import Data from '../Data.js';
import Interactive from './Interactive.js';
import {arrayCache} from '../utils/array.js';
import createComponentClass from '../factory.js';
import {greenSplit} from '../utils/string.js';

export default (function () {
    var ColorMatrixFilter = filters.ColorMatrixFilter,
        pixiMatrix = new Matrix(),
        castValue = function (color) {
            if (color === null) {
                return color;
            }
            if ((typeof color === 'string') && (color[0] === '#')) {
                color = '0x' + color.substring(1);
            }
            return +color;
        },
        magSqr = function (x, y) {
            return x * x + y * y;
        },
        processGraphics = (function () {
            var process = function (gfx, value) {
                var i = 0,
                    paren  = value.indexOf('('),
                    func   = value.substring(0, paren),
                    values = value.substring(paren + 1, value.indexOf(')')),
                    polyRay = false;

                if (values.length) {
                    if (values[0] === '[') {
                        values = values.substring(1, values.length - 1);
                        polyRay = true;
                    }
                    values = greenSplit(values, ',');
                    i = values.length;
                    while (i--) {
                        values[i] = +values[i];
                    }
                    if (polyRay) {
                        gfx[func](values);
                    } else {
                        gfx[func].apply(gfx, values);
                        arrayCache.recycle(values); // cannot recycle polygon above since it's used by the polygon shape.
                    }
                } else {
                    gfx[func]();
                }
            };

            return function (gfx, value) {
                var i = 0,
                    arr = greenSplit(value, '.');

                for (i = 0; i < arr.length; i++) {
                    process(gfx, arr[i]);
                }
                
                arrayCache.recycle(arr);
            };
        }());
    
    return createComponentClass(/** @lends platypus.components.RenderContainer.prototype */{
        
        id: 'RenderContainer',
        
        properties: {
            /**
             * Optional. A mask definition that determines where the image should clip. A string can also be used to create more complex shapes via the PIXI graphics API like: "mask": "r(10,20,40,40).drawCircle(30,10,12)". Defaults to no mask or, if simply set to true, a rectangle using the entity's dimensions. Note that the mask is in world coordinates by default. To make the mask local to the entity's coordinates, set `localMask` to `true` in the RenderContainer properties.
             *
             *  "mask": {
             *      "x": 10,
             *      "y": 10,
             *      "width": 40,
             *      "height": 40
             *  },
             *
             *  -OR-
             *
             *  "mask": "r(10,20,40,40).drawCircle(30,10,12)"
             *
             * @property mask
             * @type Object
             * @default null
             */
            mask: null,

            /**
             * Defines whether the entity will respond to touch and click events. Setting this value will create an Interactive component on this entity with these properties. For example:
             *
             *  "interactive": {
             *      "hover": false,
             *      "hitArea": {
             *          "x": 10,
             *          "y": 10,
             *          "width": 40,
             *          "height": 40
             *      }
             *  }
             *
             * @property interactive
             * @type Boolean|Object
             * @default false
             */
            interactive: false,

            /**
             * Whether to render objects to their new position over time instead of instantaneously if there has been a time adjustment. Time is in milliseconds.
             *
             * @property interpolation
             * @type Number
             * @default 0
             */
            interpolation: 0,

            /**
             * Optional. What field this object should use to rotate.
             *
             * @property rotate
             * @type String
             * @default 'rotation'
             */
            rotate: 'rotation',

            /**
             * Whether this object can be mirrored over X. To mirror it over X set the this.owner.rotation value to be > 90  and < 270.
             *
             * @property mirror
             * @type Boolean
             * @default false
             */
            mirror: false,

            /**
             * Optional. Whether this object can be flipped over Y. To flip it over Y set the this.owner.rotation to be > 180.
             *
             * @property flip
             * @type Boolean
             * @default false
             */
            flip: false,

            /**
             * Optional. Whether this object is visible or not. To change the visible value dynamically set this.owner.state.visible to true or false.
             *
             * @property visible
             * @type Boolean
             * @default false
             */
            visible: true,

            /**
             * Optional. Whether this sprite should be cached into an entity with a `RenderTiles` component (like "render-layer"). The `RenderTiles` component must have its "entityCache" property set to `true`. Warning! This is a one-direction setting and will remove this component from the entity once the current frame has been cached.
             *
             * @property cache
             * @type Boolean
             * @default false
             */
            cache: false,

            /**
             * Optional. Ignores the opacity of the owner.
             *
             * @property ignoreOpacity
             * @type Boolean
             * @default false
             */
            ignoreOpacity: false,

            /**
             * Whether the mask should be relative to the entity's coordinates.
             *
             * @property localMask
             * @type boolean
             * @default false
             */
            localMask: false
        },

        publicProperties: {
            /**
             * Prevents sprite from becoming invisible out of frame and losing mouse input connection.
             *
             * @property dragMode
             * @type Boolean
             * @default false
             */
            dragMode: false,

            /**
             * The entity or id of the entity that will act as the parent container. If not set, the entity will be rendered in the layer's container.
             *
             * @property renderParent
             * @type String|Object
             * @default null
             */
            renderParent: null,

            /**
             * Optional. The rotation of the sprite in degrees. All sprites on the same entity are rotated the same amount unless they ignore the rotation value by setting 'rotate' to "".
             *
             * Boolean values for the "rotate" property has been deprecated. Use "rotation" or "orientationMatrix" to specify the source of rotation for the render container.
             *
             * @property rotation
             * @type Number
             * @default 0
             */
            rotation: 0,

            /**
             * Optional. The X scaling factor for the image. Defaults to 1.
             *
             * @property scaleX
             * @type Number
             * @default 1
             */
            scaleX: 1,

            /**
             * Optional. The Y scaling factor for the image. Defaults to 1.
             *
             * @property scaleY
             * @type Number
             * @default 1
             */
            scaleY: 1,

            /**
             * Optional. The X skew factor of the sprite. Defaults to 0.
             *
             * @property skewX
             * @type Number
             * @default 0
             */
            skewX: 0,

            /**
             * Optional. The Y skew factor for the image. Defaults to 0.
             *
             * @property skewY
             * @type Number
             * @default 0
             */
            skewY: 0,

            /**
             * Optional. The tint applied to the sprite. Tint may be specified by number or text. For example, to give the sprite a red tint, set to 0xff0000 or "#ff0000". Tint will be stored as a number even when set using text. Defaults to no tint.
             *
             * @property tint
             * @type Number|String
             * @default null
             */
            tint: null,

            /**
             * Optional. The x position of the entity. Defaults to 0.
             *
             * @property x
             * @type Number
             * @default 0
             */
            x: 0,
            
            /**
             * Optional. The y position of the entity. Defaults to 0.
             *
             * @property y
             * @type Number
             * @default 0
             */
            y: 0,
            
            /**
             * Optional. The z position of the entity. Defaults to 0.
             *
             * @property z
             * @type Number
             * @default 0
             */
            z: 0
        },
        
        /**
         * This component is attached to entities that will appear in the game world. It creates a PIXI Container to contain all other display objects on the entity and keeps the container updates with the entity's location and other dynamic properties.
         *
         * @memberof platypus.components
         * @uses platypus.Component
         * @constructs
         * @listens platypus.Entity#cache
         * @listens platypus.Entity#camera-update
         * @listens platypus.Entity#handle-render
         * @listens platypus.Entity#handle-render-load
         * @listens platypus.Entity#hide-sprite
         * @listens platypus.Entity#set-mask
         * @listens platypus.Entity#show-sprite
         * @fires platypus.Entity#cache-sprite
         * @fires platypus.Entity#input-on
         */
        initialize: function () {
            const
                owner = this.owner,
                container = this.container = this.owner.container = new Container(),
                initialTint = this.tint;

            container.sortableChildren = true;

            if (this.rotate === true) {
                this.rotate = 'rotation';
                platypus.debug.warn('RenderContainer: Boolean values for the "rotate" property has been deprecated. Use "rotation", "orientationMatrix", or "" to specify the source of rotation for the render container. This property defaults to "rotation".');
            }

            this.parentContainer = null;
            this.wasVisible = this.visible;
            this.lastX = owner.x;
            this.lastY = owner.y;
            this.camera = AABB.setUp();
            this.isOnCamera = true;
            this.needsCameraCheck = true;

            this._tint = null;

            if (this.interpolation) { // handle interpolation if timeline changes.
                const
                    updateUsingOwnerXY = () => {
                        this.updateSprite(true, this.owner.x, this.owner.y);
                    },
                    updateUsingInterpolationXY = () => {
                        const
                            owner = this.owner,
                            interpolationDist = magSqr(this.lastX - owner.x, this.lastY - owner.y);
    
                        if (!interpolationTime || interpolationDist < 1.5) {
                            interpolationTime = 0;
                            update = updateUsingOwnerXY;
                            update();
                        } else {
                            const
                                ratio = Math.min((interpolationTime / interpolation), (lastInterpolationDistance / interpolationDist)),
                                alt = 1 - ratio;
                            
                            interpolationTime = Math.max(0, interpolationTime - 5);
                            fromX = this.lastX;
                            fromY = this.lastY;
                            lastInterpolationDistance = interpolationDist;

                            this.updateSprite(true, owner.x * alt + fromX * ratio, owner.y * alt + fromY * ratio);
                        }
                    },
                    interpolation = this.interpolation;
                let interpolationTime = 0,
                    lastInterpolationDistance = 0,
                    fromX = 0,
                    fromY = 0,
                    update = updateUsingOwnerXY;

                this.addEventListener('handle-render', function (tick) {
                    if (tick.tick.timeShift) {
                        fromX = this.lastX;
                        fromY = this.lastY;
                        lastInterpolationDistance = magSqr(this.lastX - this.owner.x, this.lastY - this.owner.y);
                        interpolationTime = lastInterpolationDistance > 1.5 ? interpolation : 0;
                        update = updateUsingInterpolationXY;
                    } else {
                        update();
                    }
                });
            } else {
                this.addEventListener('handle-render', function () {
                    this.updateSprite(true, this.owner.x, this.owner.y);
                });
            }
            this.interpolate = Data.setUp(
                'x', 0,
                'y', 0
            );
            this.interpolationTime = 0;

            Object.defineProperty(owner, 'tint', {
                get: function () {
                    return this._tint;
                }.bind(this),
                set: function (value) {
                    var filters = this.container.filters,
                        matrix = null,
                        color = castValue(value);

                    if (color === this._tint) {
                        return;
                    }

                    if (color === null) {
                        if (filters) {
                            this.container.filters = null;
                        }
                    } else {
                        if (!filters) {
                            filters = this.container.filters = arrayCache.setUp(new ColorMatrixFilter());
                        }
                        matrix = filters[0].matrix;
                        matrix[0] = (color & 0xff0000) / 0xff0000; // Red
                        matrix[6] = (color & 0xff00) / 0xff00; // Green
                        matrix[12] = (color & 0xff) / 0xff; // Blue
                    }

                    this._tint = color;
                }.bind(this)
            });
        
            if (initialTint !== null) {
                this.tint = initialTint; // feed initial tint through setter.
            }

            if (this.interactive) {
                const
                    definition = Data.setUp(
                        'container', container,
                        'hitArea', this.interactive.hitArea,
                        'hover', this.interactive.hover
                    );
                owner.addComponent(new Interactive(owner, definition));
                definition.recycle();
            }

            if (this.cache) {
                this.updateSprite(false, owner.x, owner.y);
                this.owner.cacheRender = this.container;
            }

            if (this.mask && this.localMask) {
                this.setMask(this.mask);
            }
        },
        
        events: {
            /**
             * On receiving a "cache" event, this component triggers "cache-sprite" to cache its rendering into the background. This is an optimization for static images to reduce render calls.
             *
             * @event platypus.Entity#cache
             */
            "cache": function () {
                const owner = this.owner;

                this.updateSprite(false, owner.x, owner.y);
                owner.cacheRender = this.container;
                this.cache = true;
                if (owner.parent.triggerEventOnChildren) {
                    /**
                     * On receiving a "cache" event, this component triggers "cache-sprite" to cache its rendering into the background. This is an optimization for static images to reduce render calls.
                     *
                     * @event platypus.Entity#cache-sprite
                     * @param entity {platypus.Entity} This component's owner.
                     */
                    owner.parent.triggerEventOnChildren('cache-sprite', owner);
                } else {
                    platypus.debug.warn('Unable to cache sprite for ' + owner.type);
                }
            },

            "camera-update": function (camera) {
                this.camera.set(camera.viewport);
                
                // Set visiblity of sprite if within camera bounds
                this.needsCameraCheck = true;
            },
            
            "handle-render-load": function () {
                const owner = this.owner;

                owner.triggerEvent('input-on');
                this.updateSprite(true, owner.x, owner.y);
            },
            
            /**
             * This event makes the sprite invisible.
             *
             * @event platypus.Entity#hide-sprite
             */
            "hide-sprite": function () {
                this.visible = false;
            },

            /**
             * This event makes the sprite visible.
             *
             * @event platypus.Entity#show-sprite
             */
            "show-sprite": function () {
                this.visible = true;
            },
            
            /**
             * Defines the mask on the container/sprite. If no mask is specified, the mask is set to null.
             *
             * @event platypus.Entity#set-mask
             * @param mask {Object} The mask. This can specified the same way as the 'mask' parameter on the component.
             */
            "set-mask": function (mask) {
                this.setMask(mask);
            }
        },
        
        methods: {
            updateSprite: function (uncached, x, y) {
                const
                    matrix = pixiMatrix,
                    rotation = (this.rotate === 'rotation') && this.rotation || 0;
                let mirrored = 1,
                    flipped  = 1;
                
                if (this.container.zIndex !== this.owner.z) {
                    this.container.zIndex = this.owner.z;
                }

                if (!this.ignoreOpacity && (this.owner.opacity || (this.owner.opacity === 0))) {
                    this.container.alpha = this.owner.opacity;
                }
                
                if (this.mirror || this.flip) {
                    const angle = this.rotation % 360;
                    
                    if (this.mirror && (angle > 90) && (angle < 270)) {
                        mirrored = -1;
                    }
                    
                    if (this.flip && (angle < 180)) {
                        flipped = -1;
                    }
                }
                
                if (this.rotate === 'orientationMatrix') { // This is a 3x3 2D matrix describing an affine transformation.
                    const o = this.owner.orientationMatrix;

                    matrix.a = o[0][0];
                    matrix.b = o[1][0];
                    matrix.tx = x + o[0][2];
                    matrix.c = o[0][1];
                    matrix.d = o[1][1];
                    matrix.ty = y + o[1][2];
                    this.container.transform.setFromMatrix(matrix);
                } else {
                    this.container.setTransform(x, y, this.scaleX * mirrored, this.scaleY * flipped, (rotation ? (rotation / 180) * Math.PI : 0), this.skewX, this.skewY);
                }
                
                if (this.parentContainer && this.parentContainer.parentUpdated) {
                    this.needsCameraCheck = true;
                }
                if (this.container) {
                    if (this.container.childUpdated) {
                        this.needsCameraCheck = true;
                        this.container.childUpdated = false;
                    }
                    this.container.parentUpdated = false;
                }
                // Set isCameraOn of sprite if within camera bounds
                if (!this.needsCameraCheck) {
                    this.needsCameraCheck = (this.lastX !== this.owner.x) || (this.lastY !== this.owner.y);
                }
                if (uncached && this.container && (this.needsCameraCheck || (!this.wasVisible && this.visible))) {
                    this.isOnCamera = this.owner.parent.isOnCanvas(this.container.getBounds(false));
                    this.needsCameraCheck = false;
                    if (this.parentContainer) {
                        this.parentContainer.childUpdated = true;
                    }
                    this.container.parentUpdated = true;
                }
                
                this.lastX = x;
                this.lastY = y;
                this.wasVisible = this.visible;
                this.container.visible = (this.visible && this.isOnCamera) || this.dragMode;
            },
            
            setMask: function (shape) {
                var gfx = null;
                
                if (this.mask) {
                    if (this.localMask) {
                        this.container.removeChild(this.mask);
                    } else if (this.parentContainer) {
                        this.parentContainer.removeChild(this.mask);
                    }
                }
                
                if (!shape) {
                    this.mask = this.container.mask = null;
                    return;
                }
                
                if (shape.isMask || (shape instanceof Graphics)) {
                    gfx = shape;
                } else {
                    gfx = new Graphics();
                    gfx.beginFill(0x000000, 1);
                    if (typeof shape === 'string') {
                        processGraphics(gfx, shape);
                    } else if (shape.radius) {
                        gfx.drawCircle(shape.x || 0, shape.y || 0, shape.radius);
                    } else if (shape instanceof AABB) {
                        gfx.drawRect(shape.left, shape.top, shape.width, shape.height);
                    } else if (shape.width && shape.height) {
                        gfx.drawRect(shape.x || 0, shape.y || 0, shape.width, shape.height);
                    }
                    gfx.endFill();
                }
                
                gfx.isMask = true;

                this.mask = this.container.mask = gfx;
                this.mask.z = 0; //TML 12-4-16 - Masks don't need a Z, but this makes it play nice with the Z-ordering in HandlerRender.

                if (this.localMask) {
                    this.container.addChild(this.mask);
                } else if (this.parentContainer) {
                    this.parentContainer.addChild(this.mask);
                }
            },
            
            destroy: function () {
                this.camera.recycle();
                if (this.parentContainer && !this.container.mouseTarget) {
                    this.parentContainer.removeChild(this.container);
                    this.parentContainer = null;
                } else if (!this.cache) {
                    this.container.destroy();
                }
                this.container = null;

                this.interpolate.recycle();
                this.interpolate = null;
            }
        },
    
        publicMethods: {
            /**
             * Remove this entity's container from the containing rendering container.
             *
             * @method platypus.components.RenderContainer#removeFromParentContainer
             */
            removeFromParentContainer: function () {
                if (this.parentContainer) {
                    if (this.mask && !this.localMask) {
                        this.setMask();
                    }

                    this.parentContainer.removeChild(this.container);
                }
            },
            
            /**
             * Add this entity's container to a rendering container.
             *
             * @method platypus.components.RenderContainer#addToParentContainer
             * @param {Container} container Container to add this to.
             */
            addToParentContainer: function (container) {
                this.parentContainer = container;
                this.parentContainer.addChild(this.container);

                if (this.mask && !this.localMask) {
                    this.setMask(this.mask);
                }
            }
        }
    });
}());