components/HandlerCamera.js

/*global platypus, window */
import AABB from '../AABB.js';
import {Container} from 'pixi.js';
import Data from '../Data.js';
import TweenJS from '@tweenjs/tween.js';
import Vector from '../Vector.js';
import createComponentClass from '../factory.js';
import EntityCamera from './EntityCamera.js';

const
    DPR = window.devicePixelRatio || 1,
    STATIC = 0,
    PAN = 1,
    FOLLOW = 2,
    validModes = ['static', 'mouse-pan', 'following'],
    deprecatedModes = {
        pan: PAN,
        locked: FOLLOW,
        forward: FOLLOW,
        bounding: FOLLOW,
        'anchor-bound': FOLLOW
    },
    getCameraMode = (mode = 'static') => {
        if (validModes.indexOf(mode) >= 0) {
            return mode;
        } else if (typeof deprecatedModes[mode] === 'number') {
            const
                alt = validModes[deprecatedModes[mode]];

            platypus.debug.warn(`"${mode} has been deprecated. Use "${alt}" instead.`);
            return alt;
        } else {
            platypus.debug.warn(`"${mode} is not a valid mode. Available modes include "${validModes.join('", "')}".`);
            return 'static';
        }
    },
    doNothing = function () {
        return false;
    },

    // These fix coords for touch events filling in for pointer events from the PIXI InteractiveManager
    getClientX = function (event) {
        if (!event.clientX) {
            if (event.touches && event.touches[0] && event.touches[0].clientX) {
                return event.touches[0].clientX;
            }
            return 0;
        }
        return event.clientX;
    },
    getClientY = function (event) {
        if (!event.clientY) {
            if (event.touches && event.touches[0] && event.touches[0].clientY) {
                return event.touches[0].clientY;
            }
            return 0;
        }
        return event.clientY;
    };

export default createComponentClass(/** @lends platypus.components.Camera.prototype */{
    id: 'HandlerCamera',
    properties: {
        /**
         * Number specifying width of viewport in world coordinates.
         *
         * @property width
         * @type number
         * @default 0
         **/
        "width": 0,
            
        /**
         * Number specifying height of viewport in world coordinates.
         *
         * @property height
         * @type number
         * @default 0
         **/
        "height": 0,
        
        /**
         * Specifies whether the camera should be draggable via the mouse by setting to 'pan'.
         *
         * @property mode
         * @type String
         * @default 'static'
         **/
        "mode": "static",
        
        /**
         * Whether camera overflows to cover the whole canvas or remains contained within its aspect ratio's boundary.
         *
         * @property overflow
         * @type boolean
         * @default true
         * @deprecated
         */
        "overflow": false,
        
        /**
         * Boolean value that determines whether the camera should stretch the world viewport when window is resized. Defaults to false which maintains the proper aspect ratio.
         *
         * @property stretch
         * @type boolean
         * @default: false
         * @deprecated
         */
        "stretch": false,
        
        /**
         * Sets how many units the followed entity can move before the camera will re-center. This should be lowered for small-value coordinate systems such as Box2D.
         *
         * @property threshold
         * @type number
         * @default 1
         **/
        "threshold": 1,
        
        /**
         * Whether, when following an entity, the camera should rotate to match the entity's orientation.
         *
         * @property rotate
         * @type boolean
         * @default false
         **/
        "rotate": false,

        /**
         * Number specifying the horizontal center of viewport in world coordinates.
         *
         * @property x
         * @type number
         * @default 0
         **/
        "x": 0,
            
        /**
         * Number specifying the vertical center of viewport in world coordinates.
         *
         * @property y
         * @type number
         * @default 0
         **/
        "y": 0
    },
    publicProperties: {
        /**
         * The entity's canvas element is used to determine the window size of the camera.
         *
         * @property canvas
         * @type Canvas
         * @default null
         */
        "canvas": null,
        
        /**
         * Sets how quickly the camera should pan to a new position in the horizontal direction.
         *
         * @property transitionX
         * @type number
         * @default 400
         **/
        "transitionX": 400,
        
        /**
         * Sets how quickly the camera should pan to a new position in the vertical direction.
         *
         * @property transitionY
         * @type number
         * @default 600
         **/
        "transitionY": 600,
            
        /**
         * Sets how quickly the camera should rotate to a new orientation.
         *
         * @property transitionAngle
         * @type number
         * @default: 600
         **/
        "transitionAngle": 600,

        /**
         * Provides access at the layer level to the camera's world viewport.
         *
         * ```javascript
         *     {
         *         viewport, //platypus.AABB
         *         orientation
         *     }
         * ```
         *
         * @property worldCamera
         * @type platypus.Data
         */
        worldCamera: null,

        /**
         * Sets the z-order of this layer relative to other loaded layers.
         *
         * @property z
         * @type Number
         * @default 0
         */
        "z": 0
    },

    /**
     * This component controls the game camera deciding where and how it should move. The camera also broadcasts messages when the window resizes or its orientation changes.
     *
     * @memberof platypus.components
     * @uses platypus.Component
     * @constructs
     * @listens platypus.Entity#child-entity-added
     * @listens platypus.Entity#child-entity-updated
     * @listens platypus.Entity#follow
     * @listens platypus.Entity#load
     * @listens platypus.Entity#pointerdown
     * @listens platypus.Entity#pressmove
     * @listens platypus.Entity#pressup
     * @listens platypus.Entity#relocate
     * @listens platypus.Entity#render-world
     * @listens platypus.Entity#resize-camera
     * @listens platypus.Entity#shake
     * @listens platypus.Entity#tick
     * @listens platypus.Entity#world-loaded
     * @fires platypus.Entity#camera-loaded
     * @fires platypus.Entity#camera-update
     * @fires platypus.Entity#render-update
     */
    initialize: function (definition) {
        const
            worldVP = AABB.setUp(this.x, this.y, this.width, this.height),
            worldCamera = Data.setUp(
                "viewport", worldVP,
                "orientation", definition.orientation || 0
            );

        //The dimensions of the camera in the window
        this.viewport = AABB.setUp(0, 0, 0, 0);
        
        //The dimensions of the camera in the game world
        this.worldCamera = worldCamera;

        //Message object defined here so it's reusable
        this.worldDimensions = AABB.setUp();

        this.unbounded = true; // no bounds on camera movement

        //Whether the map has finished loading.
        this.worldIsLoaded = false;
        
        this.mode = getCameraMode(this.mode);
        
        //FOLLOW MODE
        this.cameras = [];
        
        this.xMagnitude = 0;
        this.yMagnitude = 0;
        this.xWaveLength = 0;
        this.yWaveLength = 0;
        this.xShakeTime = 0;
        this.yShakeTime = 0;
        this.shakeTime = 0;
        this.shakeIncrementor = 0;
        
        this.direction = true;
        this.stationary = false;
        
        this.viewportUpdate = false;
        
        if (this.owner.container) {
            this.parentContainer = this.owner.container;
        } else if (this.owner.stage) {
            this.canvas = this.canvas || platypus.game.canvas; //TODO: Probably need to find a better way to handle resizing - DDD 10/4/2015
            this.parentContainer = this.owner.stage;
            this.owner.width  = this.canvas.width;
            this.owner.height = this.canvas.height;
        } else {
            platypus.debug.warn('Camera: There appears to be no Container on this entity for the camera to display.');
        }
        this.container = new Container();
        this.container.zIndex = this.z;
        this.container.visible = false;
        this.parentContainer.addChild(this.container);
        this.movedCamera = false;
    },
    events: {
        "load": function () {
            this.resize();
        },
        
        "render-world": function (data) {
            this.world = data.world;
            this.container.addChild(this.world);
        },
        
        "child-entity-added": function (entity) {
            this.viewportUpdate = true;
            
            if (this.worldIsLoaded) {
                const
                    msg = Data.setUp(
                        "viewport", AABB.setUp(this.worldCamera.viewport),
                        "world", this.worldDimensions
                    );

                /**
                 * On receiving a "world-loaded" message, the camera broadcasts the world size to all children in the world.
                 *
                 * @event platypus.Entity#camera-loaded
                 * @param camera {Object}
                 * @param camera.world {platypus.AABB} The dimensions of the world.
                 * @param camera.viewport {platypus.AABB} The AABB describing the camera viewport in world units.
                 **/
                entity.triggerEvent('camera-loaded', msg);

                msg.viewport.recycle();
                msg.recycle();
            }

            if (typeof entity.cameraFocus === 'number') {
                this.addCamera(entity);
            }
        },

        "child-entity-updated": function (entity) {
            this.viewportUpdate = true;
            
            if (this.worldIsLoaded) {
                const
                    {container, owner, stationary, windowPerWorldUnitHeight, windowPerWorldUnitWidth, world, worldCamera} = this,
                    {viewport} = worldCamera,
                    msg = Data.setUp(
                        "viewport", AABB.setUp(viewport),
                        "scaleX", windowPerWorldUnitWidth,
                        "scaleY", windowPerWorldUnitHeight,
                        "orientation", worldCamera.orientation,
                        "stationary", stationary,
                        "world", this.worldDimensions
                    );

                entity.triggerEvent('camera-update', msg);
                msg.viewport.recycle();
                msg.recycle();
            }

            if (typeof entity.cameraFocus === 'number') {
                this.addCamera(entity);
            } else {
                this.removeCamera(entity);
            }
        },

        "child-entity-removed": function (entity) {
            this.viewportUpdate = true;
            
            this.removeCamera(entity);
        },

        "world-loaded": function (values) {
            const
                {owner} = this;
            
            this.worldDimensions.set(values.world);
            this.unbounded = !!values.level.infinite;
            
            this.worldIsLoaded = true;
            if (values.camera) {
                this.follow(values.camera);
            }
            if (owner.triggerEventOnChildren) {
                const
                    msg = Data.setUp(
                        "viewport", AABB.setUp(this.worldCamera.viewport),
                        "world", this.worldDimensions
                    );
                    
                owner.triggerEventOnChildren('camera-loaded', msg);

                msg.viewport.recycle();
                msg.recycle();
            }
            this.updateMovementMethods();
        },
        
        "pointerdown": function (event) {
            if (this.state === 'mouse-pan') {
                const
                    {viewport} = this.worldCamera;

                if (!this.mouseVector) {
                    this.mouseVector = Vector.setUp();
                    this.mouseWorldOrigin = Vector.setUp();
                }
                this.mouse = this.mouseVector;
                this.mouse.x = getClientX(event.event);
                this.mouse.y = getClientY(event.event);
                this.mouseWorldOrigin.x = viewport.x;
                this.mouseWorldOrigin.y = viewport.y;
                event.pixiEvent.stopPropagation();
            }
        },
        
        "pressmove": function (event) {
            if (this.mouse) {
                if (this.move(this.mouseWorldOrigin.x + ((this.mouse.x - getClientX(event.event)) * DPR) / this.world.worldTransform.a, this.mouseWorldOrigin.y + ((this.mouse.y - getClientY(event.event)) * DPR) / this.world.worldTransform.d)) {
                    this.viewportUpdate = true;
                    this.movedCamera = true;
                    event.pixiEvent.stopPropagation();
                }
            }
        },

        "pressup": function (event) {
            if (this.mouse) {
                this.mouse = null;
                if (this.movedCamera) {
                    this.movedCamera = false;
                    event.pixiEvent.stopPropagation();
                }
            }
        },
        
        "tick": function ({delta}) {
            if ((this.mode === 'following') && this.lockedFollow(this.cameras, delta)) {
                this.viewportUpdate = true;
            }
            
            // Need to update owner's size information for changes to canvas size
            if (this.canvas) {
                this.owner.width  = this.canvas.width;
                this.owner.height = this.canvas.height;
            }
            
            // Check for owner resizing
            if ((this.owner.width !== this.lastWidth) || (this.owner.height !== this.lastHeight)) {
                this.resize();
                this.lastWidth = this.owner.width;
                this.lastHeight = this.owner.height;
            }

            if (this.shakeIncrementor < this.shakeTime) {
                const viewport = this.worldCamera.viewport;

                this.viewportUpdate = true;
                this.shakeIncrementor += delta;
                this.shakeIncrementor = Math.min(this.shakeIncrementor, this.shakeTime);
                
                if (this.shakeIncrementor < this.xShakeTime) {
                    viewport.moveX(viewport.x + Math.sin((this.shakeIncrementor / this.xWaveLength) * (Math.PI * 2)) * this.xMagnitude);
                }
                
                if (this.shakeIncrementor < this.yShakeTime) {
                    viewport.moveY(viewport.y + Math.sin((this.shakeIncrementor / this.yWaveLength) * (Math.PI * 2)) * this.yMagnitude);
                }
            }

            this.updateViewport();
            
            if (this.container.zIndex !== this.z) {
                this.container.zIndex = this.z;
            }
        },
        
        /**
        * The camera listens for this event to change its world viewport size.
        *
        * @event platypus.Entity#resize-camera
        * @param {Object} [dimensions] List of key/value pairs describing new viewport size
        * @param {number} dimensions.width Width of the camera viewport
        * @param {number} dimensions.height Height of the camera viewport
        * @param {number} dimensions.time Time in millseconds over which to tween the scale change.
        * @param {Boolean} [forceUpdate] Whether to update graphics.
        **/
        "resize-camera": function (dimensions = {}, forceUpdate = false) {
            const
                {width, height, time, ease} = dimensions,
                forcedUpdate = forceUpdate || dimensions.forceUpdate;

            if (time) {
                const
                    tween = new TweenJS.Tween(this);
                
                tween.to({width, height}, time);
                if (ease) {
                    tween.easing(ease);
                }
                tween.onUpdate(() => {
                    this.resize();
                }).start();
            } else {
                if (width && height) {
                    this.width = dimensions.width;
                    this.height = dimensions.height;
                }
                if (this.canvas) {
                    this.owner.width  = this.canvas.width;
                    this.owner.height = this.canvas.height;
                }
                this.resize();
            }
            if (forcedUpdate) {
                this.updateViewport();

                /**
                 * Sends a 'handle-render' message to all the children in the Container. This bypasses a render pause value and is useful for resizes happening outside the game loop.
                 *
                 * @event platypus.Entity#render-update
                 * @param tick {Object} An object containing tick data.
                 */
                this.owner.triggerEvent('render-update');
            }
        },

        /**
         * The camera listens for this event to change its position in the world.
         *
         * @event platypus.Entity#relocate
         * @param {Vector|Object} location List of key/value pairs describing new location
         * @param {Number} location.x New position along the x-axis.
         * @param {Number} location.y New position along the y-axis.
         * @param {Number} [location.time] The time to transition to the new location.
         * @param {Function} [location.ease] The ease function to use. Defaults to a linear transition.
         */
        "relocate": function (location) {
            if (location.time) {
                const
                    worldVP = this.worldCamera.viewport,
                    v = Vector.setUp(worldVP.x, worldVP.y),
                    tween = new TweenJS.Tween(v);
                
                tween.to({x: location.x, y: location.y}, location.time);
                if (location.ease) {
                    tween.easing(location.ease);
                }
                tween.onUpdate(() => this.viewportUpdate |= !this.owner.destroyed && this.move(v.x, v.y)).onStop(() => v.recycle()).start();
            } else if (this.move(location.x, location.y)) {
                this.viewportUpdate = true;
            }
        },
        
        "follow": function (def) {
            this.follow(def);
        },
        
        /**
         * On receiving this message, the camera will shake around its target location.
         *
         * @event platypus.Entity#shake
         * @param {Object} shake
         * @param {number} [shake.xMagnitude] How much to move along the x axis.
         * @param {number} [shake.yMagnitude] How much to move along the y axis.
         * @param {number} [shake.xFrequency] How quickly to shake along the x axis.
         * @param {number} [shake.yFrequency] How quickly to shake along the y axis.
         * @param {number} [shake.time] How long the camera should shake.
         */
        "shake": function (def = {}) {
            const
                xMag    = def.xMagnitude || 0,
                yMag    = def.yMagnitude || 0,
                xFreq   = def.xFrequency || 0, //Cycles per second
                yFreq   = def.yFrequency || 0, //Cycles per second
                second  = 1000,
                time    = def.time || 0;
            
            this.viewportUpdate = true;
            
            this.shakeIncrementor = 0;
            
            this.xMagnitude = xMag;
            this.yMagnitude = yMag;
            
            if (xFreq === 0) {
                this.xWaveLength = 1;
                this.xShakeTime = 0;
            } else {
                this.xWaveLength = (second / xFreq);
                this.xShakeTime = Math.ceil(time / this.xWaveLength) * this.xWaveLength;
            }
            
            if (yFreq === 0) {
                this.yWaveLength = 1;
                this.yShakeTime = 0;
            } else {
                this.yWaveLength = (second / yFreq);
                this.yShakeTime = Math.ceil(time / this.yWaveLength) * this.yWaveLength;
            }
            
            this.shakeTime = Math.max(this.xShakeTime, this.yShakeTime);
        }
    },
    
    methods: {
        follow: function ({begin = 0, entity, mode, orientation = 0, time = 0, x, y} = {}) {
            if (begin || time) {
                platypus.debug.warn(`"begin" and "time" syntax has been deprecated. Use the "Timeline" component to set up transitional timings instead.`);
            }
            
            this.mode = getCameraMode(mode);
            
            switch (this.mode) {
            case 'following':
                this.addCamera(entity);
                break;
            case 'mouse-pan':
                if ((typeof x === 'number') && (typeof y === 'number')) {
                    this.move(x, y, orientation);
                    this.viewportUpdate = true;
                }
                break;
            default:
                if ((typeof x === 'number') && (typeof y === 'number')) {
                    this.move(x, y, orientation);
                    this.viewportUpdate = true;
                }
                break;
            }
        },

        addCamera (entity) {
            const
                needsAdding = this.cameras.indexOf(entity) === -1;

            if (needsAdding) {
                if (typeof entity.cameraFocus !== 'number') {
                    entity.addComponent(new EntityCamera(entity, {}));
                }

                this.cameras.push(entity);
                this.mode = 'following';
            }

            return needsAdding;
        },
        
        removeCamera (entity) {
            const
                index = this.cameras.indexOf(entity),
                canBeRemoved = index >= 0;

            if (canBeRemoved) {
                this.cameras.splice(index, 1);
                if (this.cameras.length === 0) {
                    this.mode = 'static';
                }
            }

            return canBeRemoved;
        },
        
        move: function (x, y, newOrientation) {
            const
                movedX = this.moveX(x),
                movedY = this.moveY(y),
                movedR = this.rotate && this.reorient(newOrientation || 0);

            return movedX || movedY || movedR;
        },
        
        moveX: doNothing,
        
        moveY: doNothing,
        
        reorient: function (newOrientation) {
            const
                errMargin = 0.0001,
                {worldCamera} = this;
            
            if (Math.abs(worldCamera.orientation - newOrientation) > errMargin) {
                worldCamera.orientation = newOrientation;
                return true;
            }
            return false;
        },
        
        lockedFollow (entities) {
            const
                {worldCamera: {viewport: currentBounds}, worldDimensions: {top, bottom, left, right, height, width, x, y}, unbounded} = this,
                sum = entities.reduce((prev, {cameraFocus}) => prev + cameraFocus, 0),
                useCurrentBounds = sum < 1,
                list = entities.filter(({cameraFocus}) => cameraFocus > 0),
                reduce = (property) => [({focus, result}, {cameraFocus, cameraBounds}) => {
                    const
                        value = cameraBounds[property];

                    if (cameraFocus > focus) {
                        cameraFocus = focus;
                    }
                    return {
                        focus: (cameraFocus <= focus) ? focus - cameraFocus : 0,
                        result: result + cameraFocus * value
                    };
                }, {
                    focus: sum,
                    result: useCurrentBounds ? currentBounds[property] * (1 - sum) : 0
                }],
                {result: t} = list.sort(({cameraBounds: {top: a}}, {cameraBounds: {top: b}}) => a - b).reduce(...reduce('top')),
                {result: b} = list.sort(({cameraBounds: {bottom: a}}, {cameraBounds: {bottom: b}}) => b - a).reduce(...reduce('bottom')),
                {result: l} = list.sort(({cameraBounds: {left: a}}, {cameraBounds: {left: b}}) => a - b).reduce(...reduce('left')),
                {result: r} = list.sort(({cameraBounds: {right: a}}, {cameraBounds: {right: b}}) => b - a).reduce(...reduce('right')),
                lastBounds = AABB.setUp(currentBounds);

            currentBounds.setBounds(l, t, r, b);
            if (this.width !== currentBounds.width || this.height !== currentBounds.height) {
                this.width = currentBounds.width;
                this.height = currentBounds.height;
                this.matchAspectRatio();
                this.resize();
            } else {
                this.matchAspectRatio();
            }

            if (!unbounded) {
                if (width) {
                    if (width < currentBounds.width) {
                        currentBounds.moveX(x);
                    } else if (left > currentBounds.left) {
                        currentBounds.moveX(left + currentBounds.halfWidth);
                    } else if (right < currentBounds.right) {
                        currentBounds.moveX(right - currentBounds.halfWidth);
                    }
                }
                if (height) {
                    if (height < currentBounds.height) {
                        currentBounds.moveY(y);
                    } else if (top > currentBounds.top) {
                        currentBounds.moveY(top + currentBounds.halfHeight);
                    } else if (bottom < currentBounds.bottom) {
                        currentBounds.moveY(bottom - currentBounds.halfHeight);
                    }
                }
            }

            if (lastBounds.equals(currentBounds)) {
                lastBounds.recycle();
                return false;
            } else {
                lastBounds.recycle();
                return true;
            }
        },

        matchAspectRatio () {
            const
                {height, owner, width, worldCamera} = this,
                {viewport} = worldCamera,
                worldAspectRatio = width / height,
                windowAspectRatio = owner.width / owner.height;
            
            if (windowAspectRatio > worldAspectRatio) {
                viewport.resize(height * windowAspectRatio, height);
            } else {
                viewport.resize(width, width / windowAspectRatio);
            }
        },
        
        clampAspectRatio () {
            const
                {height, owner, viewport, width} = this,
                worldAspectRatio = width / height,
                windowAspectRatio = owner.width / owner.height;
            
            if (windowAspectRatio > worldAspectRatio) {
                viewport.resize(viewport.height * worldAspectRatio, viewport.height);
            } else {
                viewport.resize(viewport.width, viewport.width / worldAspectRatio);
            }
        },
        
        resize: function () {
            const
                {container, height, owner, viewport, width, worldCamera} = this,
                worldAspectRatio = width / height,
                windowAspectRatio = owner.width / owner.height,
                {viewport: worldVP} = worldCamera;
            
            //The dimensions of the camera in the window
            viewport.setAll(owner.width / 2, owner.height / 2, owner.width, owner.height);
            
            if (windowAspectRatio > worldAspectRatio) {
                worldVP.resize(height * windowAspectRatio, height);
            } else {
                worldVP.resize(width, width / windowAspectRatio);
            }
            
            this.worldPerWindowUnitWidth  = worldVP.width  / viewport.width;
            this.worldPerWindowUnitHeight = worldVP.height / viewport.height;
            this.windowPerWorldUnitWidth  = viewport.width  / worldVP.width;
            this.windowPerWorldUnitHeight = viewport.height / worldVP.height;
            
            container.updateTransform({
                x: viewport.x - viewport.halfWidth,
                y: viewport.y - viewport.halfHeight
            });
            
            this.viewportUpdate = true;
            
            this.updateMovementMethods();
        },
        
        updateMovementMethods: (function () {
            // This is used to change movement modes as needed rather than doing a check every tick to determine movement type. - DDD 2/29/2016
            const
                centerX = function () {
                    const
                        {worldDimensions} = this;
                    
                    this.worldCamera.viewport.moveX(worldDimensions.width / 2 + worldDimensions.left);
                    this.moveX = doNothing;
                    return true;
                },
                centerY = function () {
                    const
                        {worldDimensions} = this;
                    
                    this.worldCamera.viewport.moveY(worldDimensions.height / 2 + worldDimensions.top);
                    this.moveY = doNothing;
                    return true;
                },
                containX = function (x) {
                    const
                        {worldDimensions, worldCamera} = this,
                        {viewport} = worldCamera,
                        {left, width} = worldDimensions;
                    
                    if (Math.abs(viewport.x - x) > this.threshold) {
                        if (x + viewport.halfWidth > width + left) {
                            viewport.moveX(width - viewport.halfWidth + left);
                        } else if (x < viewport.halfWidth + left) {
                            viewport.moveX(viewport.halfWidth + left);
                        } else {
                            viewport.moveX(x);
                        }
                        return true;
                    }
                    return false;
                },
                containY = function (y) {
                    const
                        {worldDimensions, worldCamera} = this,
                        {viewport} = worldCamera,
                        {height, top} = worldDimensions;
                    
                    if (Math.abs(viewport.y - y) > this.threshold) {
                        if (y + viewport.halfHeight > height + top) {
                            viewport.moveY(height - viewport.halfHeight + top);
                        } else if (y < viewport.halfHeight + top) {
                            viewport.moveY(viewport.halfHeight + top);
                        } else {
                            viewport.moveY(y);
                        }
                        return true;
                    }
                    return false;
                },
                allX = function (x) {
                    const
                        {threshold, worldCamera} = this,
                        {viewport} = worldCamera;
                    
                    if (Math.abs(viewport.x - x) > threshold) {
                        viewport.moveX(x);
                        return true;
                    }
                    return false;
                },
                allY = function (y) {
                    const
                        {threshold, worldCamera} = this,
                        {viewport} = worldCamera;
                    
                    if (Math.abs(viewport.y - y) > threshold) {
                        viewport.moveY(y);
                        return true;
                    }
                    return false;
                };
            
            return function () {
                const
                    {threshold, unbounded, worldCamera, worldDimensions} = this,
                    {viewport} = worldCamera,
                    {height, width} = worldDimensions;
                
                if (unbounded || !width) {
                    this.moveX = allX;
                } else if (width < viewport.width) {
                    this.moveX = centerX;
                } else {
                    this.moveX = containX;
                }

                if (unbounded || !height) {
                    this.moveY = allY;
                } else if (height < viewport.height) {
                    this.moveY = centerY;
                } else {
                    this.moveY = containY;
                }

                // Make sure camera is correctly contained:
                this.threshold = -1; // forces update
                this.moveX(viewport.x);
                this.moveY(viewport.y);
                this.threshold = threshold;
            };
        }()),

        updateViewport: function () {
            const
                {container, owner, stationary, viewportUpdate, windowPerWorldUnitHeight, windowPerWorldUnitWidth, world, worldCamera} = this,
                {viewport} = worldCamera,
                msg = Data.setUp(
                    "viewport", AABB.setUp(viewport),
                    "scaleX", windowPerWorldUnitWidth,
                    "scaleY", windowPerWorldUnitHeight,
                    "orientation", worldCamera.orientation,
                    "stationary", !viewportUpdate && !stationary,
                    "world", this.worldDimensions
                );
            
            if (viewportUpdate) {
                this.viewportUpdate = false;
                this.stationary = false;
                
                // Transform the world to appear within camera
                world.x = -viewport.x;
                world.y = -viewport.y;
                container.x = viewport.halfWidth * msg.scaleX;
                container.y = viewport.halfHeight * msg.scaleY;
                container.scale.x = msg.scaleX;
                container.scale.y = msg.scaleY;
                container.rotation = msg.orientation;
                container.visible = true;

                /**
                 * This component fires "camera-update" when the position of the camera in the world has changed. This event is triggered on both the entity (typically a layer) as well as children of the entity.
                 *
                 * @event platypus.Entity#camera-update
                 * @param message {Object}
                 * @param message.world {platypus.AABB} The dimensions of the world map.
                 * @param message.orientation {Number} Number describing the orientation of the camera.
                 * @param message.scaleX {Number} Number of window pixels that comprise a single world coordinate on the x-axis.
                 * @param message.scaleY {Number} Number of window pixels that comprise a single world coordinate on the y-axis.
                 * @param message.viewport {platypus.AABB} An AABB describing the world viewport area.
                 * @param message.stationary {Boolean} Whether the camera is moving.
                 **/
                owner.triggerEvent('camera-update', msg);
                if (owner.triggerEventOnChildren) {
                    owner.triggerEventOnChildren('camera-update', msg);
                }
            } else if (!stationary) {
                this.stationary = true;

                owner.triggerEvent('camera-update', msg);
                if (owner.triggerEventOnChildren) {
                    owner.triggerEventOnChildren('camera-update', msg);
                }
            }

            msg.viewport.recycle();
            msg.recycle();
        },
        
        destroy: function () {
            this.parentContainer.removeChild(this.container);
            this.parentContainer = null;
            this.container = null;
            if (this.mouseVector) {
                this.mouseVector.recycle();
                this.mouseWorldOrigin.recycle();
            }
            
            this.viewport.recycle();
            this.worldCamera.viewport.recycle();
            this.worldCamera.recycle();
            this.worldDimensions.recycle();
        }
    },

    publicMethods: {
        /**
         * Returns whether a particular display object intersects the camera's viewport on the canvas.
         *
         * @method platypus.components.Camera#isOnCanvas
         * @param bounds {PIXI.Rectangle|Object} The bounds of the display object.
         * @param bounds.height {Number} The height of the display object.
         * @param bounds.width {Number} The width of the display object.
         * @param bounds.x {Number} The left edge of the display object.
         * @param bounds.y {Number} The top edge of the display object.
         * @return {Boolean} Whether the display object intersects the camera's bounds.
         */
        isOnCanvas: function (bounds) {
            const
                {canvas} = this;

            return !bounds || !((bounds.x + bounds.width < 0) || (bounds.x > canvas.width) || (bounds.y + bounds.height < 0) || (bounds.y > canvas.height));
        },

        /**
         * Returns a world coordinate corresponding to a provided window coordinate.
         *
         * @method platypus.components.Camera#windowToWorld
         * @param windowVector {platypus.Vector} A vector describing a window position.
         * @param withOffset {Boolean} Whether to provide a world position relative to the camera's location.
         * @param vector {platypus.Vector} If provided, this is used as the return vector.
         * @return {platypus.Vector} A vector describing a world position.
         */
        windowToWorld: function (windowVector, withOffset, vector) {
            const
                {worldPerWindowUnitHeight, worldPerWindowUnitWidth, viewport, worldCamera: {viewport: wcViewport}} = this,
                worldVector = vector || Vector.setUp();
            
            worldVector.x = windowVector.x * worldPerWindowUnitWidth;
            worldVector.y = windowVector.y * worldPerWindowUnitHeight;
            
            if (withOffset !== false) {
                worldVector.x -= viewport.left * worldPerWindowUnitWidth - wcViewport.left;
                worldVector.y -= viewport.top * worldPerWindowUnitHeight - wcViewport.top;
            }

            return worldVector;
        },
        
        /**
         * Returns a window coordinate corresponding to a provided world coordinate.
         *
         * @method platypus.components.Camera#worldToWindow
         * @param worldVector {platypus.Vector} A vector describing a world position.
         * @param withOffset {Boolean} Whether to provide a window position relative to the camera's location.
         * @param vector {platypus.Vector} If provided, this is used as the return vector.
         * @return {platypus.Vector} A vector describing a window position.
         */
        worldToWindow: function (worldVector, withOffset, vector) {
            const
                {windowPerWorldUnitHeight, windowPerWorldUnitWidth, viewport, worldCamera: {viewport: wcViewport}} = this,
                windowVector = vector || Vector.setUp();

            windowVector.x = worldVector.x * windowPerWorldUnitWidth;
            windowVector.y = worldVector.y * windowPerWorldUnitHeight;
            
            if (withOffset !== false) {
                windowVector.x -= wcViewport.left * windowPerWorldUnitWidth - viewport.left;
                windowVector.y -= wcViewport.top * windowPerWorldUnitHeight - viewport.top;
            }

            return windowVector;
        }
    }
});