components/Camera.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';

export default (function () {
    var DPR = window.devicePixelRatio || 1,
        anchorBound = function (anchorAABB, entityOffsetX, entityOffsetY, entity) {
            var aabb = AABB.setUp(entity.x + entityOffsetX, entity.y + entityOffsetY, entity.width, entity.height),
                x = anchorAABB.x,
                y = anchorAABB.y;

            if (aabb.top < anchorAABB.top) {
                y -= (anchorAABB.top - aabb.top);
            } else if (aabb.bottom > anchorAABB.bottom) {
                y += (anchorAABB.bottom - aabb.bottom);
            }
            
            if (aabb.left < anchorAABB.left) {
                x -= (anchorAABB.left - aabb.left);
            } else if (aabb.right > anchorAABB.right) {
                x += (anchorAABB.right - aabb.right);
            }
            
            aabb.recycle();
            
            return this.move(x, y, 0);
        },
        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;
        };
    
    return createComponentClass(/** @lends platypus.components.Camera.prototype */{
        id: 'Camera',
        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 false
             */
            "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
             */
            "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,

            /**
             * 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) {
            var 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.message = Data.setUp(
                "viewport", AABB.setUp(),
                "scaleX", 0,
                "scaleY", 0,
                "orientation", 0,
                "stationary", false,
                "world", this.worldDimensions
            );
            this.cameraLoadedMessage = Data.setUp(
                "viewport", this.message.viewport,
                "world", this.worldDimensions
            );
    
            //Whether the map has finished loading.
            this.worldIsLoaded = false;
            
            this.following = null;
            this.state = 'static';//'roaming';
            if (this.mode === 'pan') {
                this.state = 'mouse-pan';
            }
            
            //FOLLOW MODE VARIABLES
            
            //--Bounding
            this.boundingBox = AABB.setUp(worldVP.x, worldVP.y, worldVP.width / 2, worldVP.height / 2);
            
            //Forward Follow
            this.lastX = worldVP.x;
            this.lastY = worldVP.y;
            this.lastOrientation = worldCamera.orientation;
            this.forwardX = 0;
            this.forwardY = 0;
            this.forwardAngle = 0;
            this.averageOffsetX = 0;
            this.averageOffsetY = 0;
            this.averageOffsetAngle = 0;
            this.offsetX = 0;
            this.offsetY = 0;
            this.offsetAngle = 0;
            this.forwardFollower = Data.setUp(
                "x", this.lastX,
                "y", this.lastY,
                "orientation", this.lastOrientation
            );
            
            this.lastFollow = Data.setUp(
                "entity", null,
                "mode", null,
                "offsetX", 0,
                "offsetY", 0,
                "begin", 0
            );
            
            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) {
                    /**
                     * 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', this.cameraLoadedMessage);
                }
            },

            "child-entity-updated": function (entity) {
                this.viewportUpdate = true;
                
                if (this.worldIsLoaded) {
                    entity.triggerEvent('camera-update', this.message);
                }
            },

            "world-loaded": function (values) {
                var msg = this.message;
                
                msg.viewport.set(this.worldCamera.viewport);
                this.worldDimensions.set(values.world);
                
                this.worldIsLoaded = true;
                if (values.camera) {
                    this.follow(values.camera);
                }
                if (this.owner.triggerEventOnChildren) {
                    this.owner.triggerEventOnChildren('camera-loaded', this.cameraLoadedMessage);
                }
                this.updateMovementMethods();
            },
            
            "pointerdown": function (event) {
                var worldVP = this.worldCamera.viewport;

                if (this.state === 'mouse-pan') {
                    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 = worldVP.x;
                    this.mouseWorldOrigin.y = worldVP.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.transform.worldTransform.a, this.mouseWorldOrigin.y + ((this.mouse.y - getClientY(event.event)) * DPR) / this.world.transform.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 (resp) {
                if ((this.state === 'following') && this.followingFunction(this.following, resp.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 += resp.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.lastFollow.begin) {
                    if (this.lastFollow.begin < Date.now()) {
                        this.follow(this.lastFollow);
                    }
                }

                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 () {
                var move = function (v) {
                        if (this.move(v.x, v.y)) {
                            this.viewportUpdate = true;
                        }
                    },
                    stop = function () {
                        this.recycle();
                    };

                return 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(move.bind(this, v)).onStop(stop.bind(v)).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 (shakeDef) {
                var def = shakeDef || {},
                    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 (def) {
                var portion = 0.1;
                
                if (def.time) { //save current follow
                    if (!this.lastFollow.begin) {
                        this.lastFollow.entity = this.following;
                        this.lastFollow.mode   = this.mode;
                        this.lastFollow.offsetX = this.offsetX;
                        this.lastFollow.offsetY = this.offsetY;
                    }
                    this.lastFollow.begin  = Date.now() + def.time;
                } else if (this.lastFollow.begin) {
                    this.lastFollow.begin = 0;
                }
                
                this.mode = def.mode;
                
                switch (def.mode) {
                case 'locked':
                    this.state = 'following';
                    this.following = def.entity;
                    this.followingFunction = this.lockedFollow;
                    this.offsetX = def.offsetX || 0;
                    this.offsetY = def.offsetY || 0;
                    this.offsetAngle = def.offsetAngle || 0;
                    break;
                case 'forward':
                    this.state = 'following';
                    this.followFocused   = false;
                    this.following       = def.entity;
                    this.lastX           = def.entity.x - def.offsetX || 0;
                    this.lastY           = def.entity.y - def.offsetY || 0;
                    this.lastOrientation = def.entity.orientation || 0;
                    this.forwardX  = def.movementX || (this.transitionX * portion);
                    this.forwardY  = def.movementY || (this.transitionY * portion);
                    this.averageOffsetX = 0;
                    this.averageOffsetY = 0;
                    this.averageOffsetAngle = 0;
                    this.offsetX = def.offsetX || 0;
                    this.offsetY = def.offsetY || 0;
                    this.offsetAngle = def.offsetAngle || 0;
                    this.followingFunction = this.forwardFollow;
                    break;
                case 'bounding':
                    this.state = 'following';
                    this.following = def.entity;
                    this.offsetX = def.offsetX || 0;
                    this.offsetY = def.offsetY || 0;
                    this.offsetAngle = def.offsetAngle || 0;
                    this.boundingBox.setAll(def.x, def.y, def.width, def.height);
                    this.followingFunction = this.boundingFollow;
                    break;
                case 'anchor-bound':
                    this.state = 'following';
                    this.following = def.entity;
                    this.followingFunction = anchorBound.bind(this, def.anchorAABB, def.offsetX || 0, def.offsetY || 0);
                    break;
                case 'pan':
                    this.state = 'mouse-pan';
                    this.following = null;
                    this.followingFunction = null;
                    if (def && (typeof def.x === 'number') && (typeof def.y === 'number')) {
                        this.move(def.x, def.y, def.orientation || 0);
                        this.viewportUpdate = true;
                    }
                    break;
                default:
                    this.state = 'static';
                    this.following = null;
                    this.followingFunction = null;
                    if (def && (typeof def.x === 'number') && (typeof def.y === 'number')) {
                        this.move(def.x, def.y, def.orientation || 0);
                        this.viewportUpdate = true;
                    }
                    break;
                }
                
                if (def.begin) { // get rid of last follow
                    def.begin = 0;
                }

            },
            
            move: function (x, y, newOrientation) {
                var moved = this.moveX(x);
                moved = this.moveY(y) || moved;
                if (this.rotate) {
                    moved = this.reorient(newOrientation || 0) || moved;
                }
                return moved;
            },
            
            moveX: doNothing,
            
            moveY: doNothing,
            
            reorient: function (newOrientation) {
                var errMargin = 0.0001,
                    worldCamera = this.worldCamera;
                
                if (Math.abs(worldCamera.orientation - newOrientation) > errMargin) {
                    worldCamera.orientation = newOrientation;
                    return true;
                }
                return false;
            },
            
            lockedFollow: (function () {
                var min = Math.min,
                    getTransitionalPoint = function (a, b, ratio) {
                        // Find point between two points according to ratio.
                        return ratio * b + (1 - ratio) * a;
                    },
                    getRatio = function (transition, time) {
                        // Look at the target transition time (in milliseconds) and set up ratio accordingly.
                        if (transition) {
                            return min(time / transition, 1);
                        } else {
                            return 1;
                        }
                    };
                
                return function (entity, time) {
                    var worldCamera = this.worldCamera,
                        worldVP = worldCamera.viewport,
                        x = getTransitionalPoint(worldVP.x, entity.x + this.offsetX, getRatio(this.transitionX, time)),
                        y = getTransitionalPoint(worldVP.y, entity.y + this.offsetY, getRatio(this.transitionY, time));

                    if (this.rotate) { // Only run the orientation calculations if we need them.
                        return this.move(x, y, getTransitionalPoint(worldCamera.orientation, -(entity.orientation || 0), getRatio(this.transitionAngle, time)));
                    } else {
                        return this.move(x, y, 0);
                    }
                };
            }()),
            
            forwardFollow: function (entity, time) {
                var avgFraction = 0.9,
                    avgFractionFlip = 1 - avgFraction,
                    ff = this.forwardFollower,
                    moved  = false,
                    ms = 15,
                    standardizeTimeDistance = ms / time, //This allows the camera to pan appropriately on slower devices or longer ticks
                    worldCamera = this.worldCamera,
                    worldVP = worldCamera.viewport,
                    x = entity.x + this.offsetX,
                    y = entity.y + this.offsetY,
                    a = (entity.orientation || 0) + this.offsetAngle;
                
                if (this.followFocused && (this.lastX === x) && (this.lastY === y)) {
                    return this.lockedFollow(ff, time);
                } else {
                    // span over last 10 ticks to prevent jerkiness
                    this.averageOffsetX *= avgFraction;
                    this.averageOffsetY *= avgFraction;
                    this.averageOffsetX += avgFractionFlip * (x - this.lastX) * standardizeTimeDistance;
                    this.averageOffsetY += avgFractionFlip * (y - this.lastY) * standardizeTimeDistance;

                    if (Math.abs(this.averageOffsetX) > (worldVP.width / (this.forwardX * 2))) {
                        this.averageOffsetX = 0;
                    }
                    if (Math.abs(this.averageOffsetY) > (worldVP.height / (this.forwardY * 2))) {
                        this.averageOffsetY = 0;
                    }
                    
                    if (this.rotate) {
                        this.averageOffsetAngle *= avgFraction;
                        this.averageOffsetAngle += avgFractionFlip * (a - this.lastOrientation) * standardizeTimeDistance;
                        if (Math.abs(this.averageOffsetAngle) > (worldCamera.orientation / (this.forwardAngle * 2))) {
                            this.averageOffsetAngle = 0;
                        }
                    }

                    ff.x = this.averageOffsetX * this.forwardX + x;
                    ff.y = this.averageOffsetY * this.forwardY + y;
                    ff.orientation = this.averageOffsetAngle * this.forwardAngle + a;
                    
                    this.lastX = x;
                    this.lastY = y;
                    this.lastOrientation = a;
                    
                    moved = this.lockedFollow(ff, time);

                    if (!this.followFocused && !moved) {
                        this.followFocused = true;
                    }
                    
                    return moved;
                }
                
                
            },
            
            boundingFollow: function (entity, time) {
                var x = 0,
                    y = 0,
                    ratioX  = (this.transitionX ? Math.min(time / this.transitionX, 1) : 1),
                    iratioX = 1 - ratioX,
                    ratioY  = (this.transitionY ? Math.min(time / this.transitionY, 1) : 1),
                    iratioY = 1 - ratioY,
                    worldVP = this.worldCamera.viewport;
                
                this.boundingBox.move(worldVP.x, worldVP.y);
                
                if (entity.x > this.boundingBox.right) {
                    x = entity.x - this.boundingBox.halfWidth;
                } else if (entity.x < this.boundingBox.left) {
                    x = entity.x + this.boundingBox.halfWidth;
                }
                
                if (entity.y > this.boundingBox.bottom) {
                    y = entity.y - this.boundingBox.halfHeight;
                } else if (entity.y < this.boundingBox.top) {
                    y = entity.y + this.boundingBox.halfHeight;
                }
                
                if (x !== 0) {
                    x = this.moveX(ratioX * x + iratioX * worldVP.x);
                }
                
                if (y !== 0) {
                    y = this.moveY(ratioY * y + iratioY * worldVP.y);
                }
                
                return x || y;
            },
            
            resize: function () {
                var worldAspectRatio = this.width / this.height,
                    windowAspectRatio = this.owner.width / this.owner.height,
                    worldVP = this.worldCamera.viewport;
                
                //The dimensions of the camera in the window
                this.viewport.setAll(this.owner.width / 2, this.owner.height / 2, this.owner.width, this.owner.height);
                
                if (!this.stretch) {
                    if (windowAspectRatio > worldAspectRatio) {
                        if (this.overflow) {
                            worldVP.resize(this.height * windowAspectRatio, this.height);
                        } else {
                            this.viewport.resize(this.viewport.height * worldAspectRatio, this.viewport.height);
                        }
                    } else if (this.overflow) {
                        worldVP.resize(this.width, this.width / windowAspectRatio);
                    } else {
                        this.viewport.resize(this.viewport.width, this.viewport.width / worldAspectRatio);
                    }
                }
                
                this.worldPerWindowUnitWidth  = worldVP.width  / this.viewport.width;
                this.worldPerWindowUnitHeight = worldVP.height / this.viewport.height;
                this.windowPerWorldUnitWidth  = this.viewport.width  / worldVP.width;
                this.windowPerWorldUnitHeight = this.viewport.height / worldVP.height;
                
                this.container.setTransform(this.viewport.x - this.viewport.halfWidth, this.viewport.y - this.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
                var doNot = doNothing,
                    centerX = function () {
                        var world = this.worldDimensions;
                        
                        this.worldCamera.viewport.moveX(world.width / 2 + world.left);
                        this.moveX = doNot;
                        return true;
                    },
                    centerY = function () {
                        var world = this.worldDimensions;
                        
                        this.worldCamera.viewport.moveY(world.height / 2 + world.top);
                        this.moveY = doNot;
                        return true;
                    },
                    containX = function (x) {
                        var aabb = this.worldCamera.viewport,
                            d = this.worldDimensions,
                            w = d.width,
                            l = d.left;
                        
                        if (Math.abs(aabb.x - x) > this.threshold) {
                            if (x + aabb.halfWidth > w + l) {
                                aabb.moveX(w - aabb.halfWidth + l);
                            } else if (x < aabb.halfWidth + l) {
                                aabb.moveX(aabb.halfWidth + l);
                            } else {
                                aabb.moveX(x);
                            }
                            return true;
                        }
                        return false;
                    },
                    containY = function (y) {
                        var aabb = this.worldCamera.viewport,
                            d = this.worldDimensions,
                            h = d.height,
                            t = d.top;
                        
                        if (Math.abs(aabb.y - y) > this.threshold) {
                            if (y + aabb.halfHeight > h + t) {
                                aabb.moveY(h - aabb.halfHeight + t);
                            } else if (y < aabb.halfHeight + t) {
                                aabb.moveY(aabb.halfHeight + t);
                            } else {
                                aabb.moveY(y);
                            }
                            return true;
                        }
                        return false;
                    },
                    allX = function (x) {
                        var aabb = this.worldCamera.viewport;
                        
                        if (Math.abs(aabb.x - x) > this.threshold) {
                            aabb.moveX(x);
                            return true;
                        }
                        return false;
                    },
                    allY = function (y) {
                        var aabb = this.worldCamera.viewport;
                        
                        if (Math.abs(aabb.y - y) > this.threshold) {
                            aabb.moveY(y);
                            return true;
                        }
                        return false;
                    };
                
                return function () {
                    var threshold = this.threshold,
                        worldVP = this.worldCamera.viewport,
                        world = this.worldDimensions,
                        w = world.width,
                        h = world.height;
                    
                    if (!w) {
                        this.moveX = allX;
                    } else if (w < worldVP.width) {
                        this.moveX = centerX;
                    } else {
                        this.moveX = containX;
                    }

                    if (!h) {
                        this.moveY = allY;
                    } else if (h < worldVP.height) {
                        this.moveY = centerY;
                    } else {
                        this.moveY = containY;
                    }

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

            updateViewport: function () {
                const
                    msg       = this.message,
                    viewport  = msg.viewport,
                    worldCamera = this.worldCamera;
                
                if (this.viewportUpdate) {
                    this.viewportUpdate = false;
                    this.stationary = false;
                    msg.stationary = false;
                    
                    viewport.set(worldCamera.viewport);

                    // Set up the rest of the camera message:
                    msg.scaleX         = this.windowPerWorldUnitWidth;
                    msg.scaleY         = this.windowPerWorldUnitHeight;
                    msg.orientation    = worldCamera.orientation;
                    
                    // Transform the world to appear within camera
                    this.world.setTransform(-viewport.x, -viewport.y, 1, 1, 0);
                    this.container.setTransform(viewport.halfWidth * msg.scaleX, viewport.halfHeight * msg.scaleY, msg.scaleX, msg.scaleY, msg.orientation);
                    this.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.
                     **/
                    this.owner.triggerEvent('camera-update', msg);
                    if (this.owner.triggerEventOnChildren) {
                        this.owner.triggerEventOnChildren('camera-update', msg);
                    }
                } else if (!this.stationary) {
                    this.stationary = true;
                    msg.stationary = true;

                    this.owner.triggerEvent('camera-update', msg);
                    if (this.owner.triggerEventOnChildren) {
                        this.owner.triggerEventOnChildren('camera-update', msg);
                    }
                }
            },
            
            destroy: function () {
                this.parentContainer.removeChild(this.container);
                this.parentContainer = null;
                this.container = null;
                if (this.mouseVector) {
                    this.mouseVector.recycle();
                    this.mouseWorldOrigin.recycle();
                }
                
                this.boundingBox.recycle();
                this.viewport.recycle();
                this.worldCamera.viewport.recycle();
                this.worldCamera.recycle();
                this.message.viewport.recycle();
                this.message.recycle();
                this.cameraLoadedMessage.recycle();
                this.worldDimensions.recycle();
    
                this.forwardFollower.recycle();
                this.lastFollow.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) {
                var canvas = this.canvas;

                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) {
                var worldVector = vector || Vector.setUp();
                
                worldVector.x = windowVector.x * this.worldPerWindowUnitWidth;
                worldVector.y = windowVector.y * this.worldPerWindowUnitHeight;
                
                if (withOffset !== false) {
                    worldVector.x += this.worldCamera.viewport.left;
                    worldVector.y += this.worldCamera.viewport.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) {
                var windowVector = vector || Vector.setUp();

                windowVector.x = worldVector.x * this.windowPerWorldUnitWidth;
                windowVector.y = worldVector.y * this.windowPerWorldUnitHeight;
                
                if (withOffset !== false) {
                    windowVector.x += this.viewport.x;
                    windowVector.y += this.viewport.y;
                }

                return windowVector;
            }
        }
    });
}());