Colony tech update 1: Creating a game camera, part 1
For my current project, Colony, I found myself needing a game camera. I've never actually needed one for a personal project before, so it was a good opportunity to write up the system and release the code here. This is the first part in a eight-part series on making a game camera and the related controllers.
What's in a camera?
A camera is simply an object that exists in memory, and that defines a view rect that we use to position our different layers. This implementation is rendering-agnostic; meaning you can use it for a DisplayObject
-, blitting-, or Starling-rendered game. So, the list of things that I wanted:
- As the game is destined for desktop and mobile, it should be able to support a number of control methods, from mouse, to keys, to touch
- It should be able to scroll smoothly, also mimicing the control of mobile games (flick to scroll with velocity)
- It should be able to zoom, and zoom smoothly at that (see the section on zooming for an explanation on this)
- It should have bounds, so we don't move forever
- It should support parallax scrolling for different layers, to give the impression of depth
- It should be able to control object visibility (hide objects outside of the camera view rect)
Camera.x vs. cameraX
The x
, y
, width
and height
of the camera define its screen space bounds. X
and y
, we use to "position" the camera on the stage
- for the most part these are 0,0
as your camera will typically take up the entire screen, but sometimes you may wish to move it, if you have a GUI in the way, for example.
CameraX
and cameraY
on the other hand, specify the virtual position of your camera in the game world. In screen space, these are bang in the middle of your camera screen rect, and don't move, however, in world space, these dictate where your camera is currently looking. When we "move" the camera, these are the coordinates moved. Setting cameraX
and cameraY
to the x
and y
values of a DisplayObject
in your layer will center that object on the screen.
The following image illustrates the point:
A point on zooming
Zooming is pretty simple: we just take the camera zoom and set it as the scale of the layers that the camera is controlling. The problem comes in scaling smoothly.
Lets say we want our camera to be able to zoom in and out by a factor of 10
. This would mean our normal zoom level is 1.0
, while our min zoom is 0.1
and our max zoom is 10.0
. Now lets say we want to do a linear zoom from our min to our max. See the problem yet? Zooming out is considered to be between 0.1
and 1.0
, while zooming in is considered to be between 1.0
and 10.0
. So for the same relative zoom, zooming out takes 1/10th of the time.
Each dot represents a step in time. As you can see, going from min zoom to normal takes about 1/10th of the time as from normal to max zoom. If we were to give zooming in the same time as zooming out, we'd end up with a graph like this:
Hm, that looks suspiciously like a logarithmic graph :)
In order to get a smooth scale, from min to normal to max, we need to take this fact into account. Internally, the min and max zoom are turned into their Math.log()
equivalents, -2.3025850929940455
and 2.302585092994046
for 0.1
and 10.0
respectively. Then, we do our linear scale between these two values, and use Math.exp()
to get the actual zoom value, resulting in a scale like this:
So we have a nice linear scale, yet going from min zoom to normal takes the same amount of time as going from normal to max.
Layers
Our camera needs to actually control something (or multiple things), so this is where the ICameraLayer
interface comes in. It's a pretty simple interface, just declaring properties and functions necessary for the camera to work properly. If your layer is a Sprite
or Bitmap
, you're already about 60% covered. This class is also available below.
package
{
import flash.geom.Point;
import flash.geom.Rectangle;
/**
* The interface that describes the layers that we add to our camera
* @author Damian Connolly
*/
public interface ICameraLayer
{
/*******************************************************************************************/
/**
* The x position of the layer
*/
function get x():Number;
function set x( n:Number ):void;
/**
* The y position of the layer
*/
function get y():Number;
function set y( n:Number ):void;
/**
* The x scale of the layer
*/
function get scaleX():Number;
function set scaleX( n:Number ):void;
/**
* The y scale of the layer
*/
function get scaleY():Number;
function set scaleY( n:Number ):void;
/**
* Should this layer be scaled when the camera is zoomed?
*/
function get isZoomEnabled():Boolean;
/*******************************************************************************************/
/**
* Converts the Point coordinates from stage space to local space
* @param point A Point with coordinates declared in stage space
* @return A Point, where our coordinates have been translated to local space
*/
function globalToLocal( point:Point ):Point
/**
* Checks the visibility of the children based on the camera view rect
* @param viewRect The view rect of the camera, in layer space
*/
function checkChildrenVisibility( viewRect:Rectangle ):void;
/**
* Sorts all the children
* @param compareFunction The function to use to sort the children; works similar to Vector or Array sort
*/
function sortChildren( compareFunction:Function ):void;
/**
* Called when there's a click on the layer
* @param localX The x position of the click, in layer space
* @param localY The y position of the click, in layer space
*/
function onClick( localX:Number, localY:Number ):void;
}
}
checkChildrenVisibility()
and sortChildren()
are called every frame by the camera. It's up to you to decide whether that's too much or too little. As Colony is a space game, there's generally not going to be hundreds of units on screen at once, so this is not a problem for me.
The code
For the camera to work properly, it'll need to be added to an update loop, or at the very least, have update()
called every frame.
The main properties of interest are cameraX
, cameraY
, and zoom
. You can set these directly or change them via a tween. They stop any current movement and update immediately (i.e. in the next call to update()
). You can also use the moveCameraTo()
and moveCameraBy()
functions to move in one call, or by relative amounts.
For moving/zooming using velocity, which is where the fun is, you can use setMoveVelociyTo()
, setMoveVelocityBy
, setZoomVelocityTo
, and setZoomVelocityBy
. The different velocities will fade off depending on the deceleration and cutoff properties set.
To actually add a layer to control, you use the amazingly-named addLayerToControl():
public function addLayerToControl( layer:ICameraLayer, speedFactor:Number = 1.0, offset:Point = null ):void
{
...
}
The speedFactor
param is there to enable parallax scrolling. Yep, it really is that easy. Parallax is just different layers moving at different speeds. So if you had layerA
with a speedFactor
of 1.0
and layerB
with a speedFactor
of 0.5
, then when the camera moved, layerB
would move at half the speed of layerA
, thus looking like it's far away. Fill it with distant graphics and set it behind everything else, and the illusion is complete. The offset
is just to provide a fixed offset for the layer when the camera moves, in case all your layers aren't aligned.
setBounds()
limits where the camera can move to, while onClick()
will simply call onClick()
in each of the layers, transforming the click coordinates into local space at the same time. You can use this to, say, select object, tiles, set destinations, or anything else where clicking comes in useful.
Below is the full code for the camera, or you can also download the file below. Note that it uses the internal class CameraLayerRef class; this is just to keep track of the layer specific parameters. The Camera
class seems more complicated that it really is, but about half of it is support for moving/zooming using velocity :) (NOTE: keep scrolling for an actual example)
package
{
import flash.display.DisplayObject;
import flash.geom.Point;
import flash.geom.Rectangle;
/**
* A camera that we can use to move around our game
* @author Damian Connolly
*/
public class Camera implements IUpdateObj
{
/*******************************************************************************************/
/**
* The max move velocity that we can have
*/
public var maxMoveVel:Number = 50.0;
/**
* The max zoom velocity that we can have
*/
public var maxZoomVel:Number = 0.5;
/**
* Should we snap to the nearest pixel when moving our objects?
*/
public var shouldPixelSnap:Boolean = true;
/**
* How much to decelerate the move velocity by, every frame
*/
public var moveVelDecel:Number = 0.9;
/**
* How much to decelerate the zoom velocity by, every frame
*/
public var zoomVelDecel:Number = 0.8;
/**
* The cutoff for the move velocity, before we set it to 0.0
*/
public var moveVelCutoff:Number = 1.0;
/**
* The cutoff for the zoom velocity, before we set it to 0.0
*/
private var zoomVelCutoff:Number = 0.05;
/*******************************************************************************************/
private var m_displayRect:Rectangle = null; // our display rect; x, y, width and height
private var m_viewRect:Rectangle = null; // our view rect, in layer coords (used for child visibility)
private var m_viewPoint:Point = null; // a helper point, when calculating the view rect
private var m_clickPoint:Point = null; // a helper point, when clicking the layers
private var m_layers:Vector.<CameraLayerRef> = null; // the references to the layers that we're rendering
private var m_bounds:Rectangle = null; // the bounds for the camera
private var m_cameraPos:Point = null; // the camera center position
private var m_moveVel:Point = null; // the camera velocity
private var m_isActive:Boolean = false;// is the camera active?
private var m_currZoom:Number = 1.0; // our current zoom
private var m_currZoomLog:Number = 0.0; // our current zoom (in our log scale)
private var m_zoomBoundsLog:Bounds = null; // our bounds for our zoom (in our log scale)
private var m_zoomVel:Number = 0.0; // our zoom velocity
/*******************************************************************************************/
/**
* Returns true if the camera is active
*/
[Inline] public final function get active():Boolean { return true; }
/**
* The camera's display x position
*/
[Inline] public final function get x():Number { return this.m_displayRect.x; }
[Inline] public final function set x( n:Number ):void
{
this.m_displayRect.x = n;
this.m_isActive = true;
}
/**
* The camera's display y position
*/
[Inline] public final function get y():Number { return this.m_displayRect.y; }
[Inline] public final function set y( n:Number ):void
{
this.m_displayRect.y = n;
this.m_isActive = true;
}
/**
* The camera's display width
*/
[Inline] public final function get width():Number { return this.m_displayRect.width; }
[Inline] public final function set width( n:Number ):void
{
this.m_displayRect.width = ( n < 0.0 ) ? 0.0 : n;
this.m_isActive = true;
}
/**
* The camera's display height
*/
[Inline] public final function get height():Number { return this.m_displayRect.height; }
[Inline] public final function set height( n:Number ):void
{
this.m_displayRect.height = ( n < 0.0 ) ? 0.0 : n;
this.m_isActive = true;
}
/**
* The camera's center x position
*/
[Inline] public final function get cameraX():Number { return this.m_cameraPos.x; }
[Inline] public final function set cameraX( n:Number ):void
{
this.m_cameraPos.x = n;
this.m_isActive = true;
// stop our camera so we don't keep moving
this.stop();
}
/**
* The camera's center y position
*/
[Inline] public final function get cameraY():Number { return this.m_cameraPos.y; }
[Inline] public final function set cameraY( n:Number ):void
{
this.m_cameraPos.y = n;
this.m_isActive = true;
// stop our camera so we don't keep moving
this.stop();
}
/**
* The current zoom level of the camera
*/
[Inline] public final function get zoom():Number { return this.m_currZoom; }
[Inline] public final function set zoom( n:Number ):void
{
var logN:Number = Math.log( ( n < 0.1 ) ? 0.1 : n );
this.m_currZoomLog = ( logN < this.m_zoomBoundsLog.min ) ? this.m_zoomBoundsLog.min : ( logN > this.m_zoomBoundsLog.max ) ? this.m_zoomBoundsLog.max : logN;
this.m_currZoom = Math.exp( this.m_currZoomLog );
this.m_isActive = true;
// kill our velocity so we don't keep zooming
this.m_zoomVel = 0.0;
}
/**
* The min zoom level of the camera. When set, this will clamp to maxZoom if high enough
*/
[Inline] public final function get minZoom():Number { return Math.exp( this.m_zoomBoundsLog.min ); }
[Inline] public final function set minZoom( n:Number ):void
{
this.m_zoomBoundsLog.min = Math.log( ( n < 0.1 ) ? 0.1 : n );
this.m_isActive = true;
}
/**
* The max zoom level of the camera. When set, this will clamp to the minZoom if low enough
*/
[Inline] public final function get maxZoom():Number { return Math.exp( this.m_zoomBoundsLog.max ); }
[Inline] public final function set maxZoom( n:Number ):void
{
this.m_zoomBoundsLog.max = Math.log( n );
this.m_isActive = true;
}
/*******************************************************************************************/
/**
* Creates a new Camera
* @param width The camera's display width
* @param height The camera's display height
*/
public function Camera( width:Number, height:Number )
{
// create our objects
this.m_displayRect = new Rectangle;
this.m_viewRect = new Rectangle;
this.m_viewPoint = new Point;
this.m_clickPoint = new Point;
this.m_bounds = new Rectangle;
this.m_cameraPos = new Point;
this.m_moveVel = new Point;
this.m_layers = new Vector.<CameraLayerRef>;
this.m_zoomBoundsLog = new Bounds( Math.log( 0.1 ), Math.log( 10.0 ) );
this.m_currZoomLog = Math.log( this.m_currZoom );
// set our display width and height
this.width = width;
this.height = height;
}
/**
* Destroys the Camera and clears it for garbage collection
*/
public function destroy():void
{
// update - NOTE: it still needs to be removed from the Update class
this.m_isActive = false;
// clear our vector
for each( var ref:CameraLayerRef in this.m_layers )
ref.destroy();
this.m_layers.length = 0;
// null our properties
this.m_bounds = null;
this.m_cameraPos = null;
this.m_moveVel = null;
this.m_displayRect = null;
this.m_viewRect = null;
this.m_viewPoint = null;
this.m_clickPoint = null;
this.m_layers = null;
this.m_zoomBoundsLog = null;
}
/**
* Adds a layer to be controlled by the camera
* @param layer The ICameraLayer that we want the camera to control
* @param speedFactor The speed factor for this object (e.g. for implementing parallax)
* @param offset The offset for this ICameraLayer; used in the final positioning. If null, then
* no offset is used, and when the cameraX/camerY is 0, the (0,0) of the ICameraLayer will correspond
* with the center of the camera
*/
public function addLayerToControl( layer:ICameraLayer, speedFactor:Number = 1.0, offset:Point = null ):void
{
var ref:CameraLayerRef = new CameraLayerRef( layer );
ref.speedFactor = speedFactor;
ref.origScale.x = layer.scaleX;
ref.origScale.y = layer.scaleY;
if ( offset != null )
ref.offset = offset;
this.m_layers.push( ref );
this.m_isActive = true;
}
/**
* Removes a layer from the camera's control
* @param layer The ICameraLayer to remove from our camera
*/
public function removeLayerFromControl( layer:ICameraLayer ):void
{
for ( var i:int = this.m_layers.length - 1; i >= 0; i-- )
{
if ( this.m_layers[i].layer == layer )
{
this.m_layers[i].destroy();
this.m_layers.splice( i, 1 );
// if this is the last object, kill our velocity
if ( this.m_layers.length == 0 )
this.stop();
// activate to update
this.m_isActive = true;
return;
}
}
trace( "[Camera] Can't remove " + layer + " from the camera, as we're not controlling it" );
}
/**
* Sets the offset for one of the layers under the camera's control. This offset
* will be used when positioning the layer
* @param layer The ICameraLayer under the camera's control
* @param offset The offset for this layer
*/
public function setLayerOffset( layer:ICameraLayer, offset:Point ):void
{
// set the offset on the right objects
for each( var ref:CameraLayerRef in this.m_layers )
{
if ( ref.layer == layer )
{
ref.offset = offset;
this.m_isActive = true;
return;
}
}
// we don't have it
trace( "[Camera] Can't set the offset for " + layer + ", as we're not controlling it" );
}
/**
* Moves the camera center position to a specific position
* @param x The x position to move to
* @param y The y position to move to
*/
public function moveCameraTo( x:Number, y:Number ):void
{
this.cameraX = x;
this.cameraY = y;
}
/**
* Moves the camera center position by a specific amount
* @param x The x amount to move by
* @param y The y amount to move by
*/
public function moveCameraBy( x:Number, y:Number ):void
{
this.cameraX += x;
this.cameraY += y;
}
/**
* Sets the move velocity of the camera to a specific amount
* @param x The camera move x velocity
* @param y The camera move y velocity
*/
public function setMoveVelocityTo( x:Number, y:Number ):void
{
// set it and clamp if necessary
this.m_moveVel.setTo( x, y );
if ( this.m_moveVel.length > this.maxMoveVel )
this.m_moveVel.normalize( this.maxMoveVel );
this.m_isActive = true;
}
/**
* Sets the camera move velocity by a specific amount
* @param x The camera move x velocity difference
* @param y The camera move y velocity difference
*/
public function setMoveVelocityBy( x:Number, y:Number ):void
{
this.setMoveVelocityTo( this.m_moveVel.x + x, this.m_moveVel.y + y );
}
/**
* Sets the camera zoom using a logarithmic scale - this will give smoother results
* than just setting the zoom directly
* @param n The amount that we want to zoom the camera by
*/
public function zoomCameraLogarithmicallyBy( n:Number ):void
{
// set our current log zoom, clear our zoom velocity and set that we're active
this.m_currZoomLog += n;
this.m_zoomVel = 0.0; // no velocity
this.m_isActive = true;
}
/**
* Sets the camera zoom velocity to a specific amount
* @param n The camera zoom velocity
*/
public function setZoomVelocityTo( n:Number ):void
{
this.m_zoomVel = ( n < -this.maxZoomVel ) ? -this.maxZoomVel : ( n > this.maxZoomVel ) ? this.maxZoomVel : n;
this.m_isActive = true;
}
/**
* Set the camera zoom velocity by a specific amount
* @param n The camera zoom velocity difference
*/
public function setZoomVelocityBy( n:Number ):void
{
this.setZoomVelocityTo( this.m_zoomVel + n );
}
/**
* Stops the camera from moving and zooming
*/
public function stop():void
{
// NOTE: don't set active to false as we might have been stopped because we set the
// camera position directly, and we still need it to update
// NOTE: don't kill the zoomVel as stop() is called by the cameraX/cameraY setters,
// which are used in some controllers (moveTo()), such as the follow controller. If
// we zero out the zoomVel, then we won't be able to zoom while the camera is
// tracking an object
this.m_moveVel.setTo( 0.0, 0.0 );
}
/**
* Sets the bounds for the camera
* @param x The x bounds for the camera
* @param y The y bounds for the camera
* @param w The width for the bounds
* @param h The height for the bounds
*/
public function setBounds( x:Number, y:Number, w:Number, h:Number ):void
{
// make sure our width and height are good
w = ( w < 0 ) ? 0 : w;
h = ( h < 0 ) ? 0 : h;
// set our bounds
this.m_bounds.setTo( x, y, w, h );
this.m_isActive = true;
}
/**
* Goes through and clicks each of the layers that we control
* @param stageX The x position of the click, in global space
* @param stageY The y position of the click, in global space
*/
public function onClick( stageX:Number, stageY:Number ):void
{
// stop the camera from moving
this.stop();
// notify our layers
this.m_clickPoint.x = stageX;
this.m_clickPoint.y = stageY;
for each( var ref:CameraLayerRef in this.m_layers )
{
var localPos:Point = ref.layer.globalToLocal( this.m_clickPoint );
ref.layer.onClick( localPos.x, localPos.y );
}
}
/**
* Updates the Camera so that we render our view objects etc
* @param dt The delta time since the last update
*/
public function update( dt:Number ):void
{
// only update the camera position etc if we're active
if ( this.m_isActive )
{
// update our zoom based on our vel and clamp it.
// NOTE: as scale is logarithmic, we're using Math.log() and Math.exp()
// to get the final value
this.m_currZoomLog += this.m_zoomVel;
if ( this.m_currZoomLog < this.m_zoomBoundsLog.min )
{
this.m_currZoomLog = this.m_zoomBoundsLog.min;
this.m_zoomVel = 0.0;
}
else if ( this.m_currZoomLog > this.m_zoomBoundsLog.max )
{
this.m_currZoomLog = this.m_zoomBoundsLog.max;
this.m_zoomVel = 0.0;
}
this.m_currZoom = Math.exp( this.m_currZoomLog );
// update our position
this.m_cameraPos.x += this.m_moveVel.x;
this.m_cameraPos.y += this.m_moveVel.y;
// clamp it to our bounds
if ( this.m_bounds.width > 0 || this.m_bounds.height > 0 )
{
var invZoom:Number = ( this.m_currZoom == 0.0 ) ? 1.0 : 1.0 / this.m_currZoom;
var hw:Number = this.m_displayRect.width * 0.5 * invZoom;
var hh:Number = this.m_displayRect.height * 0.5 * invZoom;
// horizontal
if ( this.m_cameraPos.x < this.m_bounds.x + hw )
{
this.m_cameraPos.x = this.m_bounds.x + hw;
this.m_moveVel.x = 0.0; // stop moving
}
else if ( this.m_cameraPos.x > this.m_bounds.x + this.m_bounds.width - hw )
{
this.m_cameraPos.x = this.m_bounds.x + this.m_bounds.width - hw;
this.m_moveVel.x = 0.0; // stop moving
}
// vertical
if ( this.m_cameraPos.y < this.m_bounds.y + hh )
{
this.m_cameraPos.y = this.m_bounds.y + hh;
this.m_moveVel.y = 0.0; // stop moving
}
else if ( this.m_cameraPos.y > this.m_bounds.y + this.m_bounds.height - hh )
{
this.m_cameraPos.y = this.m_bounds.y + this.m_bounds.height - hh;
this.m_moveVel.y = 0.0; // stop moving
}
}
// update all our controlled objects
for each( var ref:CameraLayerRef in this.m_layers )
{
// update the zoom
var zoom:Number = ( ref.layer.isZoomEnabled ) ? this.m_currZoom : 1.0;
var scaleX:Number = zoom * ref.origScale.x;
var scaleY:Number = zoom * ref.origScale.y;
if ( ref.layer.isZoomEnabled && ( ref.layer.scaleX != scaleX || ref.layer.scaleY != scaleY ) )
{
ref.layer.scaleX = scaleX;
ref.layer.scaleY = scaleY;
}
// NOTE: the camera position is inversed, because, as the camera moves right, the object should move left
var x:Number = this.m_displayRect.x + ( this.m_displayRect.width * 0.5 ) - ( this.m_cameraPos.x * ref.speedFactor * scaleX ) + ( ref.offset.x * scaleX );
var y:Number = this.m_displayRect.y + ( this.m_displayRect.height * 0.5 ) - ( this.m_cameraPos.y * ref.speedFactor * scaleY ) + ( ref.offset.y * scaleY );
ref.layer.x = ( this.shouldPixelSnap ) ? Math.round( x ) : x;
ref.layer.y = ( this.shouldPixelSnap ) ? Math.round( y ) : y;
}
// slow down
this.m_moveVel.x *= this.moveVelDecel;
this.m_moveVel.y *= this.moveVelDecel;
this.m_zoomVel *= this.zoomVelDecel;
if ( this.m_moveVel.x < this.moveVelCutoff && this.m_moveVel.x > -this.moveVelCutoff )
this.m_moveVel.x = 0.0;
if ( this.m_moveVel.y < this.moveVelCutoff && this.m_moveVel.y > -this.moveVelCutoff )
this.m_moveVel.y = 0.0;
if ( this.m_zoomVel < this.zoomVelCutoff && this.m_zoomVel >- this.zoomVelCutoff )
this.m_zoomVel = 0.0;
}
// we need to update the children visibility and sort on our layers every frame, as even if
// we're not moving, they could be
this.m_viewPoint.x = this.m_displayRect.x;
this.m_viewPoint.y = this.m_displayRect.y;
for each( ref in this.m_layers )
{
// get our view rect in local space
var localPos:Point = ref.layer.globalToLocal( this.m_viewPoint );
this.m_viewRect.x = localPos.x;
this.m_viewRect.y = localPos.y;
// get our view rect size
var invScaleX:Number = ( this.m_currZoom == 0.0 || ref.origScale.x == 0.0 ) ? 1.0 : 1.0 / ( this.m_currZoom * ref.origScale.x );
var invScaleY:Number = ( this.m_currZoom == 0.0 || ref.origScale.y == 0.0 ) ? 1.0 : 1.0 / ( this.m_currZoom * ref.origScale.y );
this.m_viewRect.width = this.m_displayRect.width * invScaleX;
this.m_viewRect.height = this.m_displayRect.height * invScaleY;
// update children visibility
ref.layer.checkChildrenVisibility( this.m_viewRect );
// sort them
ref.layer.sortChildren( this._sortChildren );
}
// if our velocity is zero, stop update
if ( this.m_moveVel.x == 0.0 && this.m_moveVel.y == 0.0 && this.m_zoomVel == 0.0 )
this.m_isActive = false;
}
/*******************************************************************************************/
// the function we use to sort all the children in a layer
private function _sortChildren( a:DisplayObject, b:DisplayObject ):int
{
return ( ( a.x + a.y ) < ( b.x + b.y ) ) ? -1 : 1;
}
}
}
Example
To see what all this produces, this is an example of the classes in action. The stars and planets make up different layers, and the camera moves/zooms randomly. In the next parts in this series, we'll get around to controlling the camera ourselves. Click on the stage to reset the camera to the center.
Colony mailing list
If you'd like to hear more about Colony, both game and tech updates, you can sign up for the mailing list below. I promise I won't do anything naughty with your email address.
Comments
Submit a comment