It’s finally time to get into the good stuff! In this post we’ll be looking at enemies and how they get set up, run through, and knocked down. We’ll look at how I’ve created some JSON metadata in the map JSON file to set up the types of enemies the map will contain, the sounds to use for those enemies, the different enemy groups that can be in a single wave, how those groups are defined, and how individual enemies are created inside those groups. Let’s take a quick high-level view at how I’ve structured my data and my classes. We’ll get to the actual JSON in a sec.
[toc]
Before you start, feel free to check out the finished product of my AS3 Starling TD Demo.
You can also find all of the code used in srcview.
Or you can download the whole project zipped up.
Or check out the repo on Bitbucket
JSON Data Structure
I’m going to hit EnemyWaves first, since the other two relate more to each other. Enemy Wave data here doesn’t pertain to an actual class file in the project, but it is used by the classes as a sort of map/hash set. For example, when the user starts the game and the first wave tile at the bottom of the screen “spawns”, that wave tile has an id of “wave1”. So the EnemyManager class will go through and say, “Ok, ‘wave1’ needs to spawn, which groups should I use?”
{
"id": "wave1",
"groups":"group1",
},
When ‘wave1’ spawns, the EnemyManager will check those “groups” which correspond to the ids of the EnemyGroups below. So ‘wave1’ spawns and EnemyManager goes to find EnemyGroup id ‘group1’ and tells it to begin spawning.
An EnemyGroup is a collection of Enemy classes (and subclasses) that all share a common set of Waypoints for that map. If you have one road on a map, you’ve probably just got one set of waypoint data, “All enemies walk this path from (10,10) to (100,10) to…” then the above JSON data would be fine for your enemyWaves groups definition. But lets say you’ve got two roads on your map, and so there are two different sets of waypoints, one set is for the group that spawns on the left side of the screen, one set is for the group that spawns at the top of the screen.
{
"id": "wave1",
"groups":"group1a,group1b",
},
In Map 2 of my demo there are two roads, and two separate enemy groups. And the above JSON code is used to spawn the first wave. The EnemyGroup designated with an id “group1a” and “group1b” will be spawned when the wave “wave1” is spawned.
So we’ve got enemyTypes that correspond to com.zf.objects.enemy.EnemyType.as, enemyGroups that correspond to com.zf.objects.enemy.EnemyEnemyGroup.as, and inside of enemyGroups, enemies that correspond to com.zf.objects.enemy.Enemy.as as a base class. Let’s look at an enemy type excerpt from the JSON.
{
"id": "enemyA",
"name": "Enemy A",
"klass": "EnemyA",
"sounds": [
{
"state": "onEscape",
"soundId": "escape1"
},
{
"state": "onBanish",
"soundId": "banish1"
}
]
},
Think of an EnemyType as the metadata outlining a specific sub-class of enemies. All enemies inherit from the base Enemy class. However, the above “enemyA” is going to use a specific sub-class of Enemy, com.zf.objects.enemy.types.EnemyA.as to be specific. Since that’s the “klass” attribute, we’re going to use the EnemyA class instead of the boring, old Enemy.as class. Though EnemyA extends Enemy, so we very much use most all of that class. We’ll see later.
So, an EnemyType definition lets us specify an internal id for the type, a name to display to the user for that type of enemy, a class to use for that type, and different sounds which consist of sound states. For example, as we’ll see, at the base Enemy class, when the Enemy escapes/leaks from the map, the base Enemy class will call a function that basically says, “play the ‘onEscape’ sound state if it exists.” So, in the case of enemyA, we’ve defined “onEscape” to be “escape1” so when enemyA escapes from the map, escape1 will play. If I did not include an “onEscape” state for the enemy type, then nothing would play. You’re right though, I could certainly define a base set of sounds for the base Enemy class to default to, but I did not for this tutorial.
Let’s look at the enemyGroups definitions:
{
"id": "group1",
"name": "Group 1",
"spawnDelay": "800",
"wave": "wave1",
"waypointGroup": "wpGroup1",
"enemies": [
{
"typeId": "enemyA",
"level": 1
},
{
"typeId": "enemyB",
"level": 1
},
{ "typeId": "enemyB", "level": 1 },
{ "typeId": "enemyA", "level": 1 },
{ "typeId": "enemyB", "level": 1 },
{ "typeId": "enemyA", "level": 1 }
]
},
- Line 2 – so this defines EnemyGroup “group1”
- Line 3 – it has a name of “Group 1”, super creative, I know…
- Line 4 – spawnDelay is the time in milliseconds to wait between spawning each Enemy in the enemies array. So this would spawn an Enemy every 0.8 seconds.
- Line 5 – wave is the wave id that corresponds to something we haven’t seen yet called “enemyWaveData” that primarily deals with the enemy wave tiles at the bottom of the screen. This wave id allows those wave tile classes the ability to get at this data so we don’t have to duplicate this info. Think of this as a foreign key for the wave tiles to use to get access to a specific EnemyGroup name, number of enemies, etc.
- Line 6 – waypointGroup corresponds to the map tile data we saw in an earlier post. It means that this EnemyGroup (set of enemies that are spawning from a single point, and all following the same set of waypoint data) requires the set of waypoints created for “wpGroup1”.
- Line 7 – enemies is our array of individual enemies to define. Each Object listed in this array will be an actual Enemy that shows up on the screen for this group. Each Object consists of a “typeId” and a “level”. Honestly, I don’t know why I have the “level” param in here. In this demo it doesn’t do anything, but if you build on this, this is a way you can create the same type of enemies but have some enemies be tougher/harder/higher-level than other enemies in the group.
- Line 9 – use this “typeId” which here is “enemyA” and go look above to the EnemyTypes section for the id “enemyA”. This is saying this Enemy should use all the type data defined in the EnemyType “enemyA” data.
- Line 10 – use level 1 data for this guy
- Line 13-14 – the next Enemy to spawn will be an EnemyType “enemyB” (not shown above but exists in the full map JSON data file)
- Line 16-19 – I go on to define 4 more enemies in this EnemyGroup. In the actual map JSON file, enemies are defined on a single line like this to save space.
So this EnemyGroup consists of 6 total enemies that will be spawning 0.8 seconds apart from each other. If you wanted a “swarm” level, you may have 30 enemies defined with a spawnDelay of “100” or less! You may want all of your groups to be of the same EnemyType, or mixed types… however you want to do it. The beauty of having all that information in this JSON file is that your designers, or your level editors, or your playtesters can open this file in a text editor and change the number of enemies, spawn delay, types of enemies, etc in every wave. Then they save the file and refresh their browser. This will save them YEARS of time so they can actually tweak these settings themselves and find the right balance. This saves you YEARS of time from having this crap hardcoded and mister level editor has to have you recompile a new SWF every time he wants the enemies to spawn “just a little faster”. You don’t know if that’s 200ms faster or 500ms faster so you split the difference at 375ms faster and he really meant a second faster. You find that out 4 builds later when he just says “can you just make it one second faster?” With the data in the file, everyone can take care of their own tweaking.
ALRIGHT! Enough about JSON data nonsense. Let’s get to the AS3! That’s what we all came here for anyways…
Enemy.as
My enemy graphics were taken from a spritesheet that can be found here. I have contacted Flipz, the artist who created the tilesheet, and he was cool enough to let me borrow his images.
This is the entire base Enemy class file. We’ll go through it step by step below. I’m going to try to cut it to ~100-line chunks to make it easier to digest and please remember my formatting is jacked up so we can see more of each line, and my doc blocks have been removed to save space.
package com.zf.objects.enemy
{
import com.zf.core.Assets;
import com.zf.core.Config;
import com.zf.core.Game;
import com.zf.states.Play;
import com.zf.ui.healthBar.HealthBar;
import org.osflash.signals.Signal;
import starling.display.MovieClip;
import starling.display.Sprite;
public class Enemy extends Sprite
{
public static const ENEMY_DIR_UP:String = 'enemyUp';
public static const ENEMY_DIR_RIGHT:String = 'enemyRight';
public static const ENEMY_DIR_DOWN:String = 'enemyDown';
public static const ENEMY_DIR_LEFT:String = 'enemyLeft';
public static const ENEMY_SND_STATE_BANISH:String = "onBanish";
public static const ENEMY_SND_STATE_ESCAPE:String = "onEscape";
// set default speed to 1
public var speed:Number = 1;
public var maxHP:Number = 10;
public var currentHP:Number = 10;
public var reward:int = 5;
public var damage:int = 1;
public var isEscaped:Boolean = false;
public var isBanished:Boolean = false;
public var willBeBanished:Boolean = false;
public var onDestroy:Signal;
public var onBanished:Signal;
public var type:String = "Enemy";
public var uid:int;
public var totalDist:Number;
protected var _animState:String;
protected var _animData:Object;
protected var _animTexturesPrefix:String;
protected var _soundData:Object;
protected var _distToNext:Number;
protected var _currentWPIndex:int;
protected var _waypoints:Array = [];
protected var _healthBar:HealthBar;
// enemy speed * currentGameSpeed
protected var _enemyGameSpeed:Number;
protected var _enemyGameSpeedFPS:int;
protected var _enemyBaseFPS:int = 12;
/**
* This is an Enemy's "currentHP" factoring in all bullets currently
* flying towards it. So if this is <= 0, the enemy may still be alive,
* but bullets have already been spawned with it's name on it
*/
protected var _fluxHP:Number;
public function Enemy()
{
uid = Config.getUID();
_setInternalSpeeds();
_setupAnimData();
_changeAnimState(ENEMY_DIR_RIGHT);
pivotX = width >> 1;
pivotY = height >> 1;
_soundData = {};
onDestroy = new Signal(Enemy);
onBanished = new Signal(Enemy);
// let enemy listen for game speed change
Config.onGameSpeedChange.add(onGameSpeedChange);
}
public function init(wps:Array, dist:Number):void {
isEscaped = false;
isBanished = false;
willBeBanished = false
_waypoints = wps;
totalDist = dist;
_distToNext = _waypoints[0].distToNext;
// clear the old animState
_animState = '';
// set new animState
_changeAnimState(_waypoints[0].nextDir);
// reset WP index
_currentWPIndex = 0;
x = _waypoints[0].centerPoint.x;
y = _waypoints[0].centerPoint.y;
// reset current and _flux back to maxHP
currentHP = _fluxHP = maxHP;
_healthBar = new HealthBar(this, currentHP, maxHP, 30);
_healthBar.x = -20;
_healthBar.y = -10;
addChild(_healthBar);
}
- Line 16-19 – define constants for the four directions of an enemy state, then enemy can be facing up, down, left, or right
- Line 21-22 – defines the constants for the possible sound states. I only have two here. You could easily add an onSpawn, onDamage, etc and match those keys “onBanish” up with the metadata discussed earlier in the EnemyType “sounds” section.
- Line 25-32 – different public stats about this enemy
- Line 34 – onDestroy is a Signal that dispatches when this Enemy is destroyed in the “removed from the stage, disposed, garbage collected” sense
- Line 35 – onBanished is a Signal that is dispatched when this Enemy gets killed, before it is removed from the stage.
- Line 65 – gives this Enemy class a unique ID, REALLY helpful when debugging and you need a specific instance name to log who got shot by which bullet.
- Line 66 – sets internal speeds used later by animation properties and x/y movement
- Line 68 – sets up the animation data for this enemy
- Line 69 – change the animation state of this enemy to “enemyRight” which is just an arbitrary default I set.
- Line 70-71 – set the pivots of this Enemy to width / 2, height / 2
- Line 75-76 – set up the Signals to be dispatching an Enemy object type
- Line 79 – adds a listener to Config’s onGameSpeedChange Signal so the enemy knows if the game speed changes
- Line 82 – init() looks a lot like the constructor, but this is called right before an enemy is spawned so we can pass in an Array of waypoints and the total distance of the waypoint route
- Line 83-85 – reset booleans back to defaults
- Line 86-87 – initialize _waypoints and totalDist with the passed in params
- Line 88 – set the first “distance to next waypoint” as the distance to waypoint [0]
- Line 97-98 – set this Enemy’s x/y points to the first waypoint’s x/y
- Line 101-106 – set currentHP, fluxHP, and maxHP to the same max HP, then set up the HealthBar (com.zf.ui.healthBar.HealthBar.as) that I wrote
Continuing Enemy.as
public function update():void {
_distToNext -= _enemyGameSpeed;
totalDist -= _enemyGameSpeed;
if(_distToNext <= 0) {
_getNextWaypoint();
}
switch(_animState) {
case ENEMY_DIR_UP:
y -= _enemyGameSpeed;
break;
case ENEMY_DIR_RIGHT:
x += _enemyGameSpeed;
break;
case ENEMY_DIR_DOWN:
y += _enemyGameSpeed;
break;
case ENEMY_DIR_LEFT:
x -= _enemyGameSpeed;
break;
}
}
public function takeDamage(dmgAmt:Number):void {
Config.log('Enemy', 'takeDamage', uid + " is taking " + dmgAmt + " damage");
if(!isEscaped) {
Config.totals.totalDamage += dmgAmt;
currentHP -= dmgAmt;
if(_healthBar) {
_healthBar.takeDamage(dmgAmt);
}
if(currentHP <= 0) {
_handleBanished();
}
Config.log('Enemy', 'takeDamage', "Enemy " + uid + " has " + currentHP + " hp remaining");
}
}
public function onGameSpeedChange():void {
_removeAnimDataFromJuggler();
_setInternalSpeeds();
_setupAnimData();
_changeAnimState(_animState, true);
}
public function willDamageBanish(dmg:Number):Boolean {
// deal damage to _fluxHP to see if this
// damage amount will banish enemy
_fluxHP -= dmg;
if(_fluxHP <= 0) {
willBeBanished = true;
}
Config.log('Enemy', 'willDamageBanish', "Enemy " + uid + " _fluxHP " + _fluxHP + " and willBeBanished is " + willBeBanished);
return willBeBanished;
}
public function setSoundData(soundData:Array):void {
var len:int = soundData.length;
for(var i:int = 0; i < len; i++) {
_soundData[soundData[i].state] = soundData[i].soundId;
}
}
protected function _getNextWaypoint():void {
_currentWPIndex++;
if(_currentWPIndex < _waypoints.length - 1) {
_changeAnimState(_waypoints[_currentWPIndex].nextDir);
_distToNext = _waypoints[_currentWPIndex].distToNext;
} else {
_handleEscape();
}
}
protected function _handleEscape():void {
isEscaped = true;
_playIfStateExists(ENEMY_SND_STATE_ESCAPE);
}
protected function _handleBanished():void {
Config.log('Enemy', '_handleBanished', "Enemy " + uid + " is below 0 health and isBanished = true");
isBanished = true;
onBanished.dispatch(this);
_playIfStateExists(ENEMY_SND_STATE_BANISH);
}
- Line 1 - update gets called every tick and this updates the enemy's position and other vars
- Line 2 - subtract next speed increment from distance to next
- Line 4 - subtract next speed increment from total distance
- Line 6 - if we reached the next waypoint
- Line 7 - get the next WP
- Line 10-26 - check which animation state we're in, and update x or y accordingly. For example, if we're going right (ENEMY_DIR_RIGHT) then we want to add enemyGameSpeed (seen later) to x so we move that many pixels to the right
- Line 29 - takeDamage gets called when this Enemy gets hit by a bullet. The damage amount is passed in...
- Line 31 - if this Enemy has already escaped, ignore the damage
- Line 32 - if not, add the damage amount to our totals.totalDamage stat we're keeping track of
- Line 34 - remove the damage amount from our current hitpoints
- Line 35 - if we have a healthBar (sometimes this was null when an enemy was taking damage but was also being removed from play, didn't track down this bug but wrapped an if around it)
- Line 36 - call the takeDamage function of the healthBar which we'll look at later and pass it the damage amount
- Line 39 - if our current hitpoints are at or below zero...
- Line 40 - call _handleBanished() function to handle the fact that this sucka just got banished
- Line 47 - onGameSpeedChange() function gets called when the game speed changes, the comments should help explain the lin
- Line 48 - remove old data from juggler
- Line 50 - reset internal speeds
- Line 52 - reset animation data
- Line 54 - reset animation state passing in the true as the second param to make it force clear the animation state
- Line 60-65 - subtract the damage from _fluxHP then see if it is at or below zero to see if the next shot will damage this Enemy. If so, send back true.
- Line 69 - remember the "sounds" array from the EnemyType JSON data at the top? Here is where we process that, setting _soundData with the state as the key, and the sound ID as the value.
- Line 75 - gets the next waypoint
- Line 76 - increment the current waypoint index
- Line 78 - if the current wp index is less than the length of the waypoint array, this Enemy is still in play
- Line 79 - change animation states to the waypoint's nextDir property. These sync up with the waypoint direction constants at the top of the Enemy.as file.
- Line 80 - set the distance to next as the distToNext of the new waypoint
- Line 82 - if the Enemy has reached the end of the waypoints array, call _handleEscape()
- Line 87 - set isEscaped to true as this guy is gone
- Line 88 - since this Enemy escaped, if we have defined an "onEscape" sound state for this guy, then play it.
- Line 93 - set isBanished to true as this guy has been roasted and toasted by our insanely mad defense skills
- Line 94 - dispatch that this Enemy has be banished and send this Enemy as the data for that Signal so other classes know exactly which enemy died and they won't have to waste time checking obituaries.
- Line 95 - if we have an "onBanish" sound state defined, then play it
Finishing up Enemy.as...
protected function _playIfStateExists(state:String):void {
if(_soundData.hasOwnProperty(state)) {
Game.soundMgr.playFx(_soundData[state], Config.sfxVolume);
}
}
protected function _setupAnimData():void {
//_animState = ''; // removed due to a speed change bug
_animData = {};
_animData[ENEMY_DIR_UP] = new MovieClip(Assets.ta.getTextures(_animTexturesPrefix + '_t_'), _enemyGameSpeedFPS);
_animData[ENEMY_DIR_RIGHT] = new MovieClip(Assets.ta.getTextures(_animTexturesPrefix + '_r_'), _enemyGameSpeedFPS);
_animData[ENEMY_DIR_DOWN] = new MovieClip(Assets.ta.getTextures(_animTexturesPrefix + '_b_'), _enemyGameSpeedFPS);
_animData[ENEMY_DIR_LEFT] = new MovieClip(Assets.ta.getTextures(_animTexturesPrefix + '_l_'), _enemyGameSpeedFPS);
}
protected function _changeAnimState(newState:String, forceChange:Boolean = false):void {
// make sure they are different states before removing and adding MCs
// unless foreceChange is true
if(_animState != newState || forceChange) {
// _animState == '' on subsequent play throughs since init doesn't get called again
if(_animState != '') {
_removeAnimDataFromJuggler();
}
_animState = newState;
_addAnimDataToJuggler()
}
}
protected function _removeAnimDataFromJuggler():void {
// remove the old MovieClip from juggler
Play.zfJuggler.remove(_animData[_animState]);
// remove the old MovieClip from this Sprite
removeChild(_animData[_animState]);
}
protected function _addAnimDataToJuggler():void {
// add the new MovieClip to the Juggler
Play.zfJuggler.add(_animData[_animState]);
// add the new MovieClip to the Sprite
addChild(_animData[_animState]);
}
protected function _setInternalSpeeds():void {
_enemyGameSpeed = Config.currentGameSpeed * speed;
_enemyGameSpeedFPS = int(Config.currentGameSpeed * _enemyBaseFPS);
// make sure _enemyGameSpeedFPS is at least 1
if(_enemyGameSpeedFPS < 1) {
_enemyGameSpeedFPS = 1;
}
}
protected function _updateSpeed(spd:Number):void {
// change speed from child classes
speed = spd;
// Call _setInternalSpeeds to reset internal speeds
_setInternalSpeeds();
}
public function destroy():void {
onDestroy.dispatch(this);
onDestroy.removeAll();
_removeAnimDataFromJuggler();
removeChild(_healthBar);
_healthBar = null;
removeFromParent(true);
Config.log('Enemy', 'destroy', "+ + " + uid + " destroyed");
}
}
}
- Line 2 - if our _soundData object has this state defined...
- Line 3 - call our SoundManager class's playFX function passing the sound id to play and the current sfxVolume from Config to actually play the sound.
- Line 7 - this sets up our animation data. This, again, is another technique I found off of someone's site! I wish I had bookmarked all of these things so I could give credit!
- Line 8-9 - clear state and animation data
- Line 11 - _animData[ENEMY_DIR_UP] = new MovieClip(Assets.ta.getTextures(_animTexturesPrefix + '_t_'), _enemyGameSpeedFPS);
- Line 11(again) - so we're filling _animData Object with a series of MovieClips by the direction state key. I've named my files very specifically for this exact setup here. For example, all of the enemy-moving-towards-the-Top-of-the-screen animations are labeled: "enemies/enemyA_t_01", "enemies/enemyA_t_02", "enemies/enemyA_t_03", and "enemies/enemyA_t_04" so that I can do exactly what we see here. I should've done a better job of pointing this out, but, _animTexturesPrefix is actually not instantiated in this class. It is up to the child classes to set that as we'll see in the next file. This was good OOP in theory, but poor OOP in execution. EnemyA.as sets "_animTexturesPrefix = 'enemies/enemyA';" so you can see that EnemyA would call the enemies/enemyA part and this would add the _t_ so all 4 of those files would get picked up by Assets.ta.getTextures. Then I'm passing in _enemyGameSpeedFPS to the MovieClip for the FPS property. This part I added because if that _enemyGameSpeedFPS is a constant, when you speed the game up to double time, and the animation is still looping at 6 fps, it looks weird moving at twice the speed. So later I'll point out setting that.
- Line 17 - called when we need to change the animation state, this handles changing and sorting the juggler and animations
- Line 20 - if we try to change the state to the exact same state that currently exists, we don't want to waste cycles pull stuff from the juggler and adding stuff in if it's exactly the same. However, if we pass in forceChange == true, then we want to change out the data no matter what.
- Line 23 - _animState will be '' if it has not gone through these functions and set itself up, so if _animState == '' then there will be nothing to remove from the Juggler.
- Line 24 - otherwise remove the animation data from the Juggler
- Line 26 - set the animation state to the new state
- Line 28 - add the new animation data to the Juggler
- Line 33-36 - the comments sum it up, remove the old animation data from the juggler, then remove that MovieClip from the stage
- Line 40-43 - the comments sum it up, add the new animation data to the juggler, then add that MovieClip to the stage
- Line 47 - set _enemyGameSpeed (used to add/subtract from x/y to actually move the Sprite) to the currentGameSpeed (0.5, 1, 2) times our Enemy's speed attribute
- Line 48 - set _enemyGameSpeedFPS (used to set MovieClip speed) to the currentGameSpeed (0.5, 1, 2) times our Enemy's base FPS attribute and make sure it's an even integer
- Line 50-52 - if the FPS is somehow less than 1, set it to 1
- Line 55 - updateSpeed gets called from child classes. For example, EnemyA.as calls _updateSpeed(1.2) so the child class can set the parent class' speed property, then call setInternalSpeeds so that value propagates out to the FPS speed and such. I could've also done this just calling speed from the child and then calling _setInternalSpeeds() but I just wanted one function to handle both.
- Line 62 - finally we're destroying the enemy
- Line 63 - dispatch that we're destroying this enemy
- Line 64 - remove any listeners to the Signal
- Line 65-69 - remove the animation data, remove the health bar, remove this
So now that we've discussed the base Enemy class, let's look at how freakin simple it is to make more enemy classes.
*OOP Note* in this example tutorial/demo, I've created a subclass and all it does is change some properties of the parent class. This is pretty bad OOP. If all I am doing in a subclass (in this tutorial) is changing the speed of the Enemy, and the texture prefix, I should just pass those in to the parent class, Enemy's constructor.
var enemy:Enemy = new Enemy(speed, texturePrefix); and then do things that way. However, I chose to go the subclass route because I'm already working on more in-depth actual subclasses with new functionality in my other personal game I'm working on. So... just wanted to get that out there that I realize this is an excessive/unnecessary use of hierarchy for this specific demo.
EnemyA.as
package com.zf.objects.enemy.types {
import com.zf.objects.enemy.Enemy;
public class EnemyA extends Enemy {
public function EnemyA() {
super();
// speed for EnemyA
_updateSpeed(1.2);
}
override protected function _setupAnimData():void {
_animTexturesPrefix = 'enemies/enemyA';
super._setupAnimData();
}
}
}
Yup. That's it! All of our Enemy subclasses can be that short of a file. Thanks, OOP!
- Line 8 - call Enemy's constructor
- Line 10 - call _updateSpeed() from Enemy.as and pass it 1.2
- Line 14 - I've overridden _setupAnimData() function for the sole reason of changing/setting the _animTexturesPrefix var and then I call the parent _setupAnimData() to actually do all the work.
That's it! All the movement code, the damage taking, the dispatching Signals when the Enemy gets killed or gets added, or reaches a waypoint and needs to change directions, all of that data has already been done in Enemy.as. Just for fun let's look at our second Enemy subclass... EnemyB (com.zf.objects.enemy.types.EnemyB.as)
EnemyB.as
package com.zf.objects.enemy.types {
import com.zf.objects.enemy.Enemy;
public class EnemyB extends Enemy {
public function EnemyB() {
super();
}
override protected function _setupAnimData():void {
_animTexturesPrefix = 'enemies/enemyB';
super._setupAnimData();
}
}
}
I do even less in this file. EnemyB just inherits Enemy's default speed of 1.0. This makes EnemyA slightly faster than EnemyB.
With the Enemy class (and subclasses) out of the way, let's look at the individual pieces that come together to help Enemy. We're going to look at EnemyType. and EnemyGroup.as. After we finish both of those files, we'll look at EnemyManager.as to tie everything together, then we'll head out for a beer.
EnemyType.as
EnemyType.as (com.zf.objects.enemy.EnemyType.as) is a fairly small file that from the surface looks like just a data object. But there is a very significant reason this is here and with the way I am loading classes from a String in metadata, the game would not be possible without this...
package com.zf.objects.enemy
{
import com.zf.objects.enemy.types.EnemyA;
import com.zf.objects.enemy.types.EnemyB;
public class EnemyType
{
public var id:String;
public var name:String;
public var fullClass:String;
public var soundData:Array;
public function EnemyType(i:String, n:String, fC:String, sD:Array) {
id = i;
name = n;
fullClass = 'com.zf.objects.enemy.types.' + fC;
soundData = sD;
}
// create dummy versions of each enemy type, these will never be used
private var _dummyEnemyA:EnemyA;
private var _dummyEnemyB:EnemyB;
}
}
- Line 14-17 - pretty standard setting of params
- Line 16 - heads up... I've just passed fullClass the fully qualified class name for this EnemyType.
- Line 21-22 - PROTIP! Here is the key to allowing you to create classes from Strings in JSON data. Flash's compiler is so selective of files to include that even if you've included EnemyA.as in the project file structure, and even if you explicitly type "import com.zf.objects.enemy.types.EnemyA;" into 50 files, unless there is at least 1 actual class defined as type EnemyA, it will never include your file and you will run into errors at run time that Flash doesn't know what class you're talking about. But you've imported the file? Flash doesn't care. So in one place in your game, you have to define a variable with your enemy type and Flash will include those classes. If I add 10 more Enemy types... EnemyC, Enemy10, EnemyFlying, etc, I will always need to come back to this file and add a "private var _whatever:EnemySomeType;" so that Flash knows I mean business when I'm importing that class. I'm not just importing enemy classes all willy-nilly slapdash. We're game developers! Trim that code up!
EnemyGroup.as
EnemyGroup.as (com.zf.objects.enemy.EnemyGroup.as) is a bit more involved, but still under 100 lines of code.
package com.zf.objects.enemy
{
import com.zf.core.Config;
import com.zf.utils.GameTimer;
import flash.events.TimerEvent;
import org.osflash.signals.Signal;
public class EnemyGroup
{
public var id:String;
public var name:String;
public var spawnDelay:Number;
public var wave:String;
public var waypointGroup:String;
public var enemies:Array;
public var enemyObjects:Array;
public var spawnTimer:GameTimer;
public var onSpawnTimerTick:Signal;
public var onSpawnTimerComplete:Signal;
public var isFinished:Boolean;
public function EnemyGroup(i:String, n:String, wpGroup:String, sD:Number, w:String, e:Array)
{
id = i;
name = n;
waypointGroup = wpGroup;
spawnDelay = sD;
wave = w;
enemies = e;
enemyObjects = [];
isFinished = false;
spawnTimer = new GameTimer(id, spawnDelay, enemies.length);
spawnTimer.addEventListener(TimerEvent.TIMER, _onSpawnTimer);
spawnTimer.addEventListener(TimerEvent.TIMER_COMPLETE, _onSpawnTimerComplete);
// dispatches the enemy being spawned
onSpawnTimerTick = new Signal();
onSpawnTimerComplete = new Signal();
}
public function startGroup():void {
spawnTimer.start();
}
public function pauseGroup():void {
if(!isFinished && spawnTimer.running) {
spawnTimer.pause();
}
}
public function resumeGroup():void {
if(!isFinished && spawnTimer.paused) {
spawnTimer.start();
}
}
private function _onSpawnTimer(evt:TimerEvent):void {
onSpawnTimerTick.dispatch(enemyObjects.pop(), waypointGroup);
}
private function _onSpawnTimerComplete(evt:TimerEvent):void {
isFinished = true;
}
public function destroy():void {
Config.log('EnemyGroup', 'destroy', "+++ EnemyGroup Destroying");
spawnTimer.removeEventListener(TimerEvent.TIMER, _onSpawnTimer);
spawnTimer.removeEventListener(TimerEvent.TIMER_COMPLETE, _onSpawnTimerComplete);
spawnTimer = null;
var len:int = enemyObjects.length;
for(var i:int = 0; i < len; i++) {
enemyObjects[i].destroy();
}
enemies = null;
enemyObjects = null;
Config.log('EnemyGroup', 'destroy', "--- EnemyGroup Destroyed");
}
}
}
- Line 26-31 - setting up class vars from the JSON metadata passed in
- Line 32 - this will hold all of my Enemy Objects, for now make it an empty Object
- Line 33 - this group hasn't even started yet, set isFinished to false
- Line 35 - create a new GameTimer Timer object that I added that can be paused/resumed. This is from someone else's blog post. I got this years ago but I'm pretty sure it's based on LocalToGlobal's ExtendedTimer here. I'm giving it a timer id, passing in my spawnDelay, and setting the times to repeat the timer by the number of enemies in my array
- Line 36-37 - listen for the standard timer events
- Line 40-41 - ready some Signals for when the GameTimer ticks and when it is complete
- Line 45 - when this group is ready to begin spawning, this function gets called, which begins the spawnTimer for this group
- Line 49-51 - if this timer isn't finished and the spawnTimer is running, tell the spawnTimer to pause
- Line 55-57 - if this timer isn't finished and the spawnTimer is paused, tell the spawnTimer to start again (resume)
- Line 61 - when the spawn timer ticks, dispatch an event that it's time to spawn a new Enemy, then pass it the enemy pop()'ed from the enemyObjects and also pass along it's waypointGroup info. Little TimmyEnemy is ready for the bunny slopes and needs his pass... send him on his way.
- Line 65 - the spawnTimer has finished and is complete, set isFinished = true; If your game was set up so that as soon as one wave was finished spawning, you could spawn the next wave, that logic might go here... another signal to dispatch enabling the Next Wave mechanism or something.
- Line 68 - destroying this enemy group
EnemyManager.as
package com.zf.managers
{
import com.zf.core.Config;
import com.zf.core.Game;
import com.zf.objects.enemy.Enemy;
import com.zf.objects.enemy.EnemyGroup;
import com.zf.objects.enemy.EnemyType;
import com.zf.states.Play;
import com.zf.utils.GameTimer;
import flash.events.TimerEvent;
import flash.utils.getDefinitionByName;
import org.osflash.signals.Signal;
import starling.display.Sprite;
import starling.events.Event;
import starling.extensions.PDParticleSystem;
public class EnemyManager implements IZFManager
{
public var play:Play;
public var onEnemyAdded:Signal;
public var onEnemyRemoved:Signal;
public var endOfEnemies:Signal;
public var enemiesLeft:int;
public var activeEnemies:int;
public var delayCount:int = 0;
public var onSpawnWave:Signal;
private var _enemies:Array;
private var _canvas:Sprite;
// Holds the current map's enemy groups
private var _enemyGroups:Object;
// Holds the current map's enemy types
private var _enemyTypes:Object;
private var _enemyWaves:Object;
public function EnemyManager(playState:Play)
{
play = playState;
_canvas = play.enemyLayer;
_enemies = [];
activeEnemies = 0;
onEnemyAdded = new Signal(Enemy);
onEnemyRemoved = new Signal(Enemy);
endOfEnemies = new Signal();
onSpawnWave = new Signal(String);
_enemyGroups = {};
_enemyTypes = {};
_enemyWaves = {};
}
public function update():void {
if(_enemies.length > 0) {
var e:Enemy;
var len:int = _enemies.length;
for(var i:int = len - 1; i >= 0; i--) {
e = _enemies[i];
e.update();
if(e.isEscaped) {
_handleEnemyEscaped(e);
}
}
}
}
public function onGamePaused():void {
_pauseGroups();
}
public function onGameResumed():void {
_resumeGroups();
}
private function _pauseGroups():void {
for each(var group:EnemyGroup in _enemyGroups) {
group.pauseGroup();
}
}
private function _resumeGroups():void {
for each(var group:EnemyGroup in _enemyGroups) {
group.resumeGroup();
}
}
- Line 44 - grab our local reference to the Play State
- Line 45 - set our _canvas to be Play's enemyLayer Sprite
- Line 46 - set _enemies to be an empty array
- Line 47 - this is the constructor, there are no activeEnemies on stage yet, set this to 0
- Line 49-52 - set up our Signals for when we add an Enemy to stage, when we remove an Enemy from stage, when we've hit the end of all of our enemies, and when we're spawning a new wave
- Line 59 - called during the game's update tick
- Line 60 - if there are any enemies in our enemies array
- Line 63 - loop through all enemies backwards because the ones in the lower indexes of the enemies array will be more likely to have escaped, so we're trying to sprint through all the easy stuff then we'll get to the if isEscaped cases. Seems as good as any explanation to me. I don't think this really matters much if you loop backwards or forwards. Maybe some loop performance can be done here
- Line 65 - call Enemy.update() for each Enemy in the array
- Line 66 - if the Enemy isEscaped then handle that escaped Enemy
- Line 74 - called when the game is paused...
- Line 78 - called when the game is resumed...
- Line 83 - this pauses all group spawn timers
- Line 89 - this resumes all group spawn timers
Continuing EnemyManager.as
public function destroyEnemy(e:Enemy):void {
var len:int = _enemies.length;
for(var i:int = 0; i < len; i++) {
if(e == _enemies[i]) {
Config.log('Enemy', 'destroyEnemy', "Destroying Enemy " + e.uid);
_enemies.splice(i, 1);
e.destroy();
e.removeFromParent(true);
}
}
}
private function _spawn(e:Enemy, wpGroup:String):void {
var waypoints:Array = play.wpMgr.getWaypointsByGroup(wpGroup);
var totalDist:Number = play.wpMgr.getRouteDistance(wpGroup);
Config.totals.enemiesSpawned++;
e.init(waypoints, totalDist);
e.onBanished.add(handleEnemyBanished);
_enemies.push(e);
activeEnemies++;
_canvas.addChild(e);
onEnemyAdded.dispatch(e);
}
public function spawnWave(waveId:String):void {
Game.soundMgr.playFx('ding1', Config.sfxVolume);
onSpawnWave.dispatch(waveId);
Config.changeCurrentWave(1);
for each(var groupName:String in _enemyWaves[waveId]) {
_enemyGroups[groupName].startGroup();
}
}
private function _handleEnemyEscaped(e:Enemy):void {
enemiesLeft--;
activeEnemies--;
Config.totals.enemiesEscaped++;
Config.changeCurrentHP(-e.damage);
destroyEnemy(e)
onEnemyRemoved.dispatch(e);
if(enemiesLeft <= 0) {
endOfEnemies.dispatch();
}
}
public function handleEnemyBanished(e:Enemy):void {
Config.log('EnemyManager', 'handleEnemyBanished', 'Enemy ' + e.uid + " is being destroyed");
enemiesLeft--;
activeEnemies--;
Config.totals.enemiesBanished++;
Config.changeCurrentGold(e.reward);
onEnemyRemoved.dispatch(e);
destroyEnemy(e);
if(enemiesLeft <= 0) {
endOfEnemies.dispatch();
}
}
- Line 1 - gets called when we need to destroy an Enemy
- Line 2-8 - get the length of our enemies array, loop through enemies looking for the Enemy instance that was passed in, when we find it, splice it out of the array, destroy it, remove it from parent, stake it through the heart, leave it in the sunlight... I dunno, whatever destroys the Enemy for good!
- Line 13 - called when it's time to spawn an Enemy! Passing in the Enemy to spawn and the waypoint group id
- Line 14-15 - get the waypoints and the total distance from Play State's WaypointManager by passing it the waypoint group id
- Line 17 - increment the enemiesSpawned total
- Line 19 - call Enemy.init() and pass in the waypoints and distance
- Line 21 - add a callback listener to Enemy's onBanished Signal
- Line 23 - add our Enemy to the enemies array
- Line 25 - increment our activeEnemies counter
- Line 27 - add the enemy to the stage
- Line 29 - dispatch the onEnemyAdded signal that we spawned a new enemy and it is active on the stage
- Line 32 - spawnWave actually gets called from way over in HUDManager as a callback function when a WaveTile finishes its journey to the left of the screen "counted down to spawn the next wave"
- Line 33 - play the ding1 sound for when a new wave is spawning.
- Line 35 - dispatch that we spawned a wave
- Line 37 - increment the wave counter
- Line 40 - loop over all groups in this wave and start the group spawn timer
- Line 44 - anything we need to do when the enemy escapes, before we call destroyEnemy() like deduct hitpoints and things happens here
- Line 45-46 - decrement enemiesLeft and activeEnemies counters
- Line 48 - increment the totals enemiesEscaped counter
- Line 50 - change our currentHP by the amount of damage the Enemy does
- Line 51 - finally destroy the enemy
- Line 52 - dispatch the Signal that the Enemy has been destroyed
- Line 55 - if enemiesLeft is at or below zero, dispatch that we've hit the end of our enemies
- Line 59-73 - looks very similar to handleEnemyEscaped except we're adding to the enemiesBanished and updating our current gold
And finally, to finish EnemyManager.as
public function handleNewMapData(data:Object):void {
var type:Object,
group:Object,
enemyType:EnemyType,
enemy:*;
// Create all enemy types
for each(type in data.enemyTypes) {
_enemyTypes[type.id] = new EnemyType(type.id, type.name, type.klass, type.sounds);
}
Config.maxWave = 0;
// Create enemy wave mappings
for each(var wave:Object in data.enemyWaves) {
_enemyWaves[wave.id] = [];
if(wave.groups.indexOf(',') != -1) {
var groups:Array = wave.groups.split(',');
_enemyWaves[wave.id] = groups;
} else {
_enemyWaves[wave.id].push(wave.groups);
}
Config.maxWave++;
}
// Create all enemy groups
for each(group in data.enemyGroups) {
_enemyGroups[group.id] = new EnemyGroup(group.id, group.name, group.waypointGroup, group.spawnDelay, group.wave, group.enemies);
_enemyGroups[group.id].onSpawnTimerTick.add(onGroupSpawnTimerTick);
_enemyGroups[group.id].onSpawnTimerComplete.add(onGroupSpawnTimerComplete);
}
// Create all actual enemies
for each(group in _enemyGroups) {
for each(var enemyObj:Object in group.enemies) {
// get the enemyType
enemyType = _enemyTypes[enemyObj.typeId];
// Creates a new enemy type from the fullClass name
var newEnemy:Enemy = new (getDefinitionByName(enemyType.fullClass) as Class)();
newEnemy.setSoundData(enemyType.soundData);
enemiesLeft++;
// push new enemy onto object array
group.enemyObjects.push(newEnemy);
}
// reverse array
group.enemyObjects.reverse();
}
}
public function onGroupSpawnTimerTick(e:Enemy, wpGroup:String):void {
Config.log('EnemyManager', 'onGroupSpawnTimerTick', "onGroupSpawnTimerTick: " + e);
_spawn(e, wpGroup);
}
public function onGroupSpawnTimerComplete(e:Enemy):void {
Config.log('EnemyManager', 'onGroupSpawnTimerComplete', "onGroupSpawnTimerComplete: " + e);
}
public function destroy():void {
Config.log('EnemyManager', 'destroy', "EnemyManager Destroying");
_enemyTypes = null;
var group:Object;
for each(group in _enemyGroups) {
group.destroy();
}
_enemyGroups = null;
var len:int = _enemies.length;
for(var i:int = 0; i < len; i++) {
_enemies[i].destroy();
}
_enemies = null;
Config.log("EnemyManager", "destroy", "EnemyManager Destroyed");
}
}
}
- Line 1 - called before the map starts so the EnemyManager can handle the new map data
- Line 9 - looping over all "enemyTypes" from the JSON data and creating a new EnemyType and storing it in our _enemyTypes Object
- Line 12 - set maxWave to zero as we're about to increment it based on the data
- Line 16 - looping over all "enemyWaves" and creating a new entry in our Object for each wave id
- Line 17 - if the "groups" param contains a comma (",") then we need to split that groups into an array and add it
- Line 21 - otherwise just push the groups name into the wave id
- Line 23 - increment maxWave
- Line 27 - loops through "enemyGroups" and creates all the EnemyGroup objects and adds listeners for Signals
- Line 33 - loops through every enemyGroup's enemies array creating every Enemy that will exist for this map
- Line 39 - var newEnemy:Enemy = new (getDefinitionByName(enemyType.fullClass) as Class)();
- Line 39 - enemyType.fullClass looks like this "com.zf.objects.enemy.types.EnemyA". This line takes that full class path/name and passes it into Flash's getDefinitionByName() as a Class, we enclose it in parens and then call the function (); at the end. This is the sorcery that allows us to use Strings for class names and Flash will be cool and create the Class for us.
- Line 40 - set sound data
- Line 42 - increment enemiesLeft
- Line 45 - push the new Enemy to the enemyObjects array for that EnemyGroup
- Line 49 - then reverse the enemyObjects array so the last created enemy is actually last in the array.
- Line 55 - when a group spawntimer ticks, spawn the new Enemy
- Line 62 - destroy the enemy manager and any remaining enemies and such
There we go... That's the full run-down on Enemies in my code! Don't forget to check out the HealthBar class I made! It's pretty simple, just some rectangle drawing code.
As always, feel free to check out the finished product of my AS3 Starling TD Demo.
You can also find all of the code used in srcview.
Or you can download the whole project zipped up.
Or check out the repo on Bitbucket
Thanks for reading, I hope this was helpful!
Travis
1 Comment