Create from Scratch a Away3D Shoot’em’Up Game: Part 4
by 28 July, 2009 1:00 pm4
Now that we have the player and some enemies we need for them to be able to interact. This is done through collision detection which you will learn in this fourth part.
Result
Requirements
Pre-Requisites
You should read the previous articles in this series.
Adding collisions
Last time we saw how to add a player and some enemies to the level. While they could both move around they did not interact with each other as you would expect; namely by crashing. This is because the neither the player nor the enemies had any idea where the other was. In order for the ships to start colliding we need a way to define the space that they are currently taking up. This is done in the MeshObject class.
MeshObject.as
package { import away3d.core.base.Object3D; internal class MeshObject extends BaseObject { public var model:Object3D = null; public var collisionName:String = CollisionIdentifiers.NONE; public function MeshObject() { super(); } public function startupMeshObject(engineManager:EngineManager, model:Object3D):MeshObject { super.startupBaseObject(engineManager); this.model = model; this.engineManager.view.scene.addChild(model); return this; } public override function shutdown():void { if (model != null) engineManager.view.scene.removeChild(model); model = null; super.shutdown(); } public function intersects(other:MeshObject):Boolean { // Use up to 6 separating planes if ( this.model.maxX + this.model.x < other.model.minX + other.model.x ) return false; if ( this.model.maxY + this.model.y < other.model.minY + other.model.y ) return false; if ( this.model.maxZ + this.model.z < other.model.minZ + other.model.z ) return false; if ( this.model.minX + this.model.x > other.model.maxX + other.model.x ) return false; if ( this.model.minY + this.model.y > other.model.maxY + other.model.y ) return false; if ( this.model.minZ + this.model.z > other.model.maxZ + other.model.z) return false; // otherwise, must be intersecting return true; } } }
We have added a property called collisionName, which identifies the type of object this is to the collision system (which will be implemented in the EngineManager class later on). This will allow us to specify which objects should collide – like players with enemies, enemies with the players bullets, the player with enemy bullets etc.
There is also a new function called intersects, which is used to find out of two MeshObjects are currently colliding. It does this by using the bounding box defined by the minX, maxX, minY, maxY, minZ and maxZ properties of the Away3D Object3D class. If the two objects are intersecting (or colliding) true is returned, otherwise false is returned.
CollisionIdentifiers.as
package { public class CollisionIdentifiers { public static const NONE:String = "None"; public static const PLAYER:String = "Player"; public static const PLAYERWEAPON:String = "PlayerWeapon"; public static const ENEMYWEAPON:String = "EnemyWeapon"; public static const ENEMY:String = "Enemy"; public static const POWERUP:String = "Powerup"; } }
Even though the collisionName property of the MeshObject is a string, it is handy to define these values as constants so the Flex compiler can pick up any spelling mistakes, instead of having to track down a misspelled collisionName yourself.
EngineManager.as
package { import away3d.cameras.Camera3D; import away3d.containers.View3D; import away3d.core.math.Number3D; import away3d.core.render.Renderer; import away3d.events.*; import flash.display.*; import flash.events.*; import flash.utils.*; import mx.collections.*; import mx.core.*; public class EngineManager extends UIComponent { public static const version:String = "1.0.0"; protected static const MEASURED_MIN_WIDTH:int = 25; protected static const MEASURED_MIN_HEIGHT:int = 25; protected static const MEASURED_WIDTH:int = 100; protected static const MEASURED_HEIGHT:int = 100; // Away3D view internal var view:View3D = null; // Away3D camera internal var cam:Camera3D = null; // a collection of the BaseObjects protected var baseObjects:ArrayCollection = new ArrayCollection(); // a collection where new BaseObjects are placed, to avoid adding items // to baseObjects while in the baseObjects collection while it is in a loop protected var newBaseObjects:ArrayCollection = new ArrayCollection(); // a collection where removed BaseObjects are placed, to avoid removing items // to baseObjects while in the baseObjects collection while it is in a loop protected var removedBaseObjects:ArrayCollection = new ArrayCollection(); // the last frame time protected var lastFrame:Date; // the application manager protected var applicationManager:ApplicationManager = null; // true if we need to shutdown the engine during the next frame and drop back to the main menu protected var myNextStateChange:String = ""; // collision mappings protected var collisionMap:Dictionary = new Dictionary(); internal function get MyApplicationManager():ApplicationManager { return applicationManager; } public function EngineManager() { super(); addEventListener(Event.REMOVED_FROM_STAGE, this.shutdown); addEventListener(Event.ADDED_TO_STAGE, this.initializeEngine); } override protected function measure():void { super.measure(); // set a bunch of predefined sizes this.measuredMinWidth = MEASURED_MIN_WIDTH; this.measuredMinHeight = MEASURED_MIN_HEIGHT; this.measuredHeight = MEASURED_HEIGHT; this.measuredWidth = MEASURED_WIDTH; } override protected function updateDisplayList(unscaledWidth:Number, unscaledHeight:Number):void { super.updateDisplayList(unscaledWidth, unscaledHeight); if (view != null) { // place the viewport in the middle of the control if(this.width / 2 != this.view.x) this.view.x = this.width / 2; if(this.height / 2 != this.view.y) this.view.y = this.height / 2; } } public function set nextStateChange(value:String):void { myNextStateChange = value; } protected function initializeEngine(event:Event):void { if (!view) { myNextStateChange = ""; cam = new Camera3D({x:0, y:0, z:-100, lookat:new Number3D(0, 0, 0)}); view = new View3D({x:stage.width/2, y:stage.height/2, camera:cam}); view.renderer = Renderer.BASIC; view.mouseEnabled = true; addChild(view); addEventListener(Event.ENTER_FRAME, enterFrame); addEventListener(MouseEvent.MOUSE_DOWN, this.mouseDown); addEventListener(MouseEvent.MOUSE_UP, this.mouseUp); addEventListener(MouseEvent.MOUSE_MOVE, this.mouseMove); addEventListener(MouseEvent.CLICK, this.click); // set the initial frame time lastFrame = new Date(); // start the application manager applicationManager = new ApplicationManager().startupApplicationManager(this); } } protected function shutdown(event:Event):void { if (view) { applicationManager.shutdown(); shutdownAll(); removeChild(view); removeEventListener(Event.ENTER_FRAME, enterFrame); removeEventListener(MouseEvent.MOUSE_DOWN, this.mouseDown); removeEventListener(MouseEvent.MOUSE_UP, this.mouseUp); removeEventListener(MouseEvent.MOUSE_MOVE, this.mouseMove); removeEventListener(MouseEvent.CLICK, this.click); applicationManager = null; view = null; cam = null; myNextStateChange = ""; } } protected function enterFrame(event:Event):void { if (view != null) { // Calculate the time since the last frame var thisFrame:Date = new Date(); var seconds:Number = (thisFrame.getTime() - lastFrame.getTime())/1000.0; lastFrame = thisFrame; // sync the baseObjects collection with any BaseObjects created or removed during the // render loop removeDeletedBaseObjects(); insertNewBaseObjects(); // allow each BaseObject to update itself for each (var baseObject:BaseObject in baseObjects) if (baseObject.inuse) baseObject.enterFrame(seconds); checkCollisions(); // render the 3D scene view.render(); } // set the currentState to MainMenu, which drops us back to the menu if (myNextStateChange != "") { Application.application.currentState = myNextStateChange; myNextStateChange = ""; } } public function addBaseObject(baseObject:BaseObject):void { newBaseObjects.addItem(baseObject); } public function removeBaseObject(baseObject:BaseObject):void { removedBaseObjects.addItem(baseObject); } protected function insertNewBaseObjects():void { for each (var baseObject:BaseObject in newBaseObjects) baseObjects.addItem(baseObject); newBaseObjects.removeAll(); } protected function removeDeletedBaseObjects():void { for each (var removedObject:BaseObject in removedBaseObjects) { var i:int = 0; for (i = 0; i < baseObjects.length; ++i) { if (baseObjects.getItemAt(i) == removedObject) { baseObjects.removeItemAt(i); break; } } } removedBaseObjects.removeAll(); } public function shutdownAll():void { // don't dispose objects twice for each (var baseObject:BaseObject in baseObjects) { var found:Boolean = false; for each (var removedObject:BaseObject in removedBaseObjects) { if (removedObject == baseObject) { found = true; break; } } if (!found) baseObject.shutdown(); } removeDeletedBaseObjects(); } public function click(event:MouseEvent):void { for each (var gameObject:BaseObject in baseObjects) if (gameObject.inuse) gameObject.click(event); } public function mouseDown(event:MouseEvent):void { for each (var gameObject:BaseObject in baseObjects) if (gameObject.inuse) gameObject.mouseDown(event); } public function mouseUp(event:MouseEvent):void { for each (var gameObject:BaseObject in baseObjects) if (gameObject.inuse) gameObject.mouseUp(event); } public function mouseMove(event:MouseEvent):void { for each (var gameObject:BaseObject in baseObjects) if (gameObject.inuse) gameObject.mouseMove(event); } public function addCollidingPair(collider1:String, collider2:String):void { if (collisionMap[collider1] == null) collisionMap[collider1] = new Array(); if (collisionMap[collider2] == null) collisionMap[collider2] = new Array(); if ((collisionMap[collider1] as Array).indexOf(collider2) == -1) collisionMap[collider1].push(collider2); if ((collisionMap[collider2] as Array).indexOf(collider1) == -1) collisionMap[collider2].push(collider1); } protected function checkCollisions():void { for (var i:int = 0; i < baseObjects.length; ++i) { var gameObjectI:MeshObject = baseObjects.getItemAt(i) as MeshObject; if (gameObjectI) { for (var j:int = i + 1; j < baseObjects.length; ++j) { var gameObjectJ:MeshObject = baseObjects.getItemAt(j) as MeshObject; if (gameObjectJ) { // early out for non-colliders var collisionNameNotNothing:Boolean = gameObjectI.collisionName != CollisionIdentifiers.NONE; // objects can still exist in the baseObjects collection after being disposed, so check var bothInUse:Boolean = gameObjectI.inuse && gameObjectJ.inuse; // make sure we have an entry in the collisionMap var collisionMapEntryExists:Boolean = collisionMap[gameObjectI.collisionName] != null; // make sure the two objects are set to collide var testForCollision:Boolean = collisionMapEntryExists && collisionMap[gameObjectI.collisionName].indexOf(gameObjectJ.collisionName) != -1 if ( collisionNameNotNothing && bothInUse && collisionMapEntryExists && testForCollision) { if (gameObjectI.intersects(gameObjectJ)) { gameObjectI.collision(gameObjectJ); gameObjectJ.collision(gameObjectI); } } } } } } } } }
The actual detection of the collisions is done by the EngineManager.
The addCollidingPair function maps the collisionName properties of the MeshObject against each other. This mapping tells the EngineManager that these two types of MeshObjects should be tested for collisions against each other.
The checkCollisions function is where the collisions are actually determined and any colliding MeshObjects notified via their collision function.
for (var i:int = 0; i < baseObjects.length; ++i) { var gameObjectI:MeshObject = baseObjects.getItemAt(i) as MeshObject; if (gameObjectI) { for (var j:int = i + 1; j < baseObjects.length; ++j) { var gameObjectJ:MeshObject = baseObjects.getItemAt(j) as MeshObject; if (gameObjectJ) { ... } } } }
The baseObjects collection is looped through twice in a way that ensures that every MeshObject is tested against every other MeshObject once.
// early out for non-colliders var collisionNameNotNothing:Boolean = gameObjectI.collisionName != CollisionIdentifiers.NONE; // objects can still exist in the baseObjects collection after being disposed, so check var bothInUse:Boolean = gameObjectI.inuse && gameObjectJ.inuse; // make sure we have an entry in the collisionMap var collisionMapEntryExists:Boolean = collisionMap[gameObjectI.collisionName] != null; // make sure the two objects are set to collide var testForCollision:Boolean = collisionMapEntryExists && collisionMap[gameObjectI.collisionName].indexOf(gameObjectJ.collisionName) != -1
Next we need to make sure that the two MeshObjects we are looking at should be able to collide with each other. This is where the mappings set up in the addCollidingPair function are used.
if ( collisionNameNotNothing && bothInUse && collisionMapEntryExists && testForCollision) { if (gameObjectI.intersects(gameObjectJ)) { gameObjectI.collision(gameObjectJ); gameObjectJ.collision(gameObjectI); } }
Assuming all the necessary conditions have been met, the intersects method is used to determine if the two MeshObjects are actually colliding. If so they are both notified through their collision function.
ApplicationManager.as
package { import away3d.primitives.Cube; import mx.core.Application; public class ApplicationManager extends BaseObject { protected static const TIME_BETWEEN_BUILDINGS:Number = 2; protected static const TIME_BETWEEN_ENEMIES:Number = 1; protected static const TIME_TO_LEVEL_END:Number = 1; protected var timeToEndGame:Number = TIME_TO_LEVEL_END; protected var timeToNextBuilding:Number = 0; protected var timeToNextEnemy:Number = TIME_BETWEEN_ENEMIES; protected var levelHasEnded:Boolean = false; public function ApplicationManager() { super(); } public function startupApplicationManager(engineManager:EngineManager):ApplicationManager { this.startupBaseObject(engineManager); this.engineManager.addCollidingPair(CollisionIdentifiers.ENEMY, CollisionIdentifiers.PLAYER); return this; } public function startLevel1():void { timeToNextBuilding = 0; new BackgroundPlane().startupBackgroundPlane(engineManager); new Player().startupPlayer(engineManager); // prepopulate the city for (var i:int = 0; i < 5; ++i) { var building:BackgroundBuilding = new BackgroundBuilding(); building.startupBackgroundBuilding(engineManager); building.enterFrame((i + 1) * 2); } } public function levelEnded():void { levelHasEnded = true; } public override function shutdown():void { super.shutdown(); } public override function enterFrame(dt:Number):void { timeToNextBuilding -= dt; if (timeToNextBuilding <= 0) { timeToNextBuilding = TIME_BETWEEN_BUILDINGS; new BackgroundBuilding().startupBackgroundBuilding(engineManager); } timeToNextEnemy -= dt; if (timeToNextEnemy <= 0) { timeToNextEnemy = TIME_BETWEEN_ENEMIES; new Enemy().startupBasicEnemy(engineManager); } if (levelHasEnded) { timeToEndGame -= dt; if (timeToEndGame <= 0) { engineManager.nextStateChange = "MainMenu"; } } } } }
The ApplicationManager is used to specify which MeshObjects should collide with which.
this.engineManager.addCollidingPair(CollisionIdentifiers.ENEMY, CollisionIdentifiers.PLAYER);
Here, in the startupApplicationmanager function, a call to the EngineManagers addCollidingPair function is made specifying that enemies and players should collide with each other.
if (levelHasEnded) { timeToEndGame -= dt; if (timeToEndGame <= 0) { engineManager.nextStateChange = "MainMenu"; } }
We also want to be able to drop back to the main menu once the player has died, but we don’t want this to happen instantly. So instead we define a new proeprty called levelHasEnded, which will be set to true by the player dies via the levelEnded function. When the player has signalled that the level should end the timeToEndGame property starts counting down, and after a period of time the EngineManagers nextStateChange property is set the ‘MainMenu’ to indicate that on the next frame the state should be changed.
Enemy.as
package { import away3d.materials.BitmapMaterial; import away3d.primitives.Plane; public class Enemy extends MeshObject { protected static const ENEMY_X_LIMIT:Number = 70; protected static const ENEMY_Y_LIMIT:Number = 60; protected static const ENEMY_X_START:Number = 65; protected static const ENEMY_Y_START:Number = 55; protected static const ENEMY_WIDTH:Number = 16; protected static const ENEMY_HEIGHT:Number = 5; protected static const BASIC_ENEMY_SPEED:Number = 50; protected var logic:Function = null; public function Enemy() { super(); } public override function shutdown():void { super.shutdown(); this.logic = null; } protected function startupEnemy(engineManager:EngineManager, material:BitmapMaterial):Enemy { var plane:Plane = new Plane( {material:material, width:ENEMY_WIDTH, height:ENEMY_HEIGHT, yUp:false}); super.startupMeshObject(engineManager, plane); this.collisionName = CollisionIdentifiers.ENEMY; return this; } public function startupBasicEnemy(engineManager:EngineManager):Enemy { this.startupEnemy(engineManager, ResourceManager.Enemy_Tex); this.logic = basicEnemyLogic; this.model.x = ENEMY_X_LIMIT; this.model.y = MathUtils.randRange(-ENEMY_Y_START, ENEMY_Y_START); return this; } public override function enterFrame(dt:Number):void { if (logic != null) logic(dt); if (this.model.x > ENEMY_X_LIMIT || this.model.x < -ENEMY_X_LIMIT || this.model.y > ENEMY_Y_LIMIT || this.model.y < -ENEMY_Y_LIMIT) this.shutdown(); } protected function basicEnemyLogic(dt:Number):void { this.model.x -= BASIC_ENEMY_SPEED * dt; } public override function collision(other:BaseObject):void { this.shutdown(); } } }
We only need to make two changes to the Enemy class.
this.collisionName = CollisionIdentifiers.ENEMY;
The first is to specify the collision name, which identifies this MeshObject as an enemy to the collision system.
public override function collision(other:BaseObject):void { this.shutdown(); }
The second is to override the collision function, and call the shutdown function when a collision has been detected.
Player.as
package { import away3d.core.math.Number3D; import away3d.events.MouseEvent3D; import away3d.primitives.*; import flash.events.*; import flash.geom.*; import flash.media.*; import mx.core.*; public class Player extends MeshObject { protected static const PLAYER_X_LIMIT:Number = 52; protected static const PLAYER_Y_LIMIT:Number = 38; protected static const PLAYER_WIDTH:Number = 16; protected static const PLAYER_HEIGHT:Number = 5; protected static const COLLISION_PLANE_WIDTH:Number = 150; protected static const COLLISION_PLANE_HEIGHT:Number = 100; protected var collisionPlane:MeshObject = null; public function Player() { super(); } public function startupPlayer(engineManager:EngineManager):Player { var plane:Plane = new Plane( {material:ResourceManager.Player_Tex, width:PLAYER_WIDTH, height:PLAYER_HEIGHT, yUp:false}); super.startupMeshObject(engineManager, plane); var collisionPlaneMesh:Plane = new Plane( {material:new NullMaterial(), width:COLLISION_PLANE_WIDTH, height:COLLISION_PLANE_HEIGHT, yUp:false}); collisionPlane = new MeshObject().startupMeshObject(engineManager, collisionPlaneMesh); collisionPlaneMesh.addEventListener(MouseEvent3D.MOUSE_MOVE, this.mouseMove3D); this.collisionName = CollisionIdentifiers.PLAYER; return this; } override public function shutdown():void { collisionPlane.model.removeEventListener(MouseEvent3D.MOUSE_MOVE, this.mouseMove3D); collisionPlane.shutdown(); collisionPlane = null; super.shutdown(); } override public function enterFrame(dt:Number):void { super.enterFrame(dt); if (this.model.x > PLAYER_X_LIMIT) this.model.x = PLAYER_X_LIMIT; if (this.model.x < -PLAYER_X_LIMIT) this.model.x = -PLAYER_X_LIMIT; if (this.model.y > PLAYER_Y_LIMIT) this.model.y = PLAYER_Y_LIMIT; if (this.model.y < -PLAYER_Y_LIMIT) this.model.y = -PLAYER_Y_LIMIT; } public function mouseMove3D(event:MouseEvent3D):void { if (this.model) { this.model.x = event.sceneX; this.model.y = event.sceneY; } } public override function collision(other:BaseObject):void { engineManager.MyApplicationManager.levelEnded(); this.shutdown(); } } }
The same two changes are made to the Player class. However, before the shutdown function is called (in the collision function), we first notify the Applicationmanager that the current level should end by calling the levelEnded function.
Collisions.mxml
<?xml version="1.0" encoding="utf-8"?> <mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" layout="absolute" xmlns:ns1="*" width="600" height="400" currentState="MainMenu" frameRate="100" backgroundGradientAlphas="[1.0, 1.0]" backgroundGradientColors="[#040522, #162654]"> <mx:states> <mx:State name="MainMenu"> <mx:AddChild position="lastChild"> <mx:Button x="10" y="368" label="Start" id="btnStart" click="{Application.application.currentState = 'Game';}"/> </mx:AddChild> <mx:AddChild position="lastChild"> <mx:Image x="281" y="10" source="@Embed(source='../media/frontscreen.png')"/> </mx:AddChild> <mx:AddChild position="lastChild"> <mx:Image x="465" y="265" source="@Embed(source='../media/thetechlabs_logosmall.png')"/> </mx:AddChild> <mx:AddChild position="lastChild"> <mx:Text x="10" y="10" text="Away3D Shoot'em'Up tutorial" color="#FFFFFF" fontSize="16"/> </mx:AddChild> <mx:AddChild position="lastChild"> <mx:Text x="10" y="42" text="Brought to you by The Tech Labs" color="#FFFFFF" fontSize="16"/> </mx:AddChild> <mx:AddChild position="lastChild"> <mx:Text x="10" y="74" text="Written by Matthew Casperson" color="#FFFFFF" fontSize="16"/> </mx:AddChild> </mx:State> <mx:State name="Game"> <mx:enterState> <![CDATA[ engineManager.MyApplicationManager.startLevel1(); Mouse.hide(); ]]> </mx:enterState> <mx:exitState> <![CDATA[ Mouse.show(); ]]> </mx:exitState> <mx:AddChild> <ns1:EngineManager x="0" y="0" width="100%" height="100%" id="engineManager"/> </mx:AddChild> </mx:State> </mx:states> </mx:Application>
One final change has to be made to the MXML file. When the level is started (because the Game state has been activated) we want to hide the mouse cursor by calling Mouse.hide(). Then when the main menu is shown (because the MainMenu state has been activated) we want to show the mouse cursor by calling Mouse.show().
With collision detection enabled we can detected when the player and an enemy collide, remove both from the game, and end the level (after a short delay). We can also use the same collision detection routines to implement weapons, which we will do in the next article.