Torque 2D/GenreTutorials/PlatformerEnemies

From TDN

Contents

Introduction


In this tutorial, we are going to concentrate on getting enemies to think somewhat intelligently. Specifically, we will be giving them the ability to patrol an area or chase after the player. Because we have already covered animation in the previous tutorial, and it is not integral to the implementation of AI, I am going to teach the AI concepts, but leave the animation of the enemies up to you. Adding the animations to the enemies is done almost identically to the player depending on your specific requirements.





Creating Enemies


Our first step will be to create an enemy. This is done very similarly to creating the player. We need to create the actual image, set up collision and physics properties, and give the enemy some attributes. Once again, I am going to use the GarageGames logo as a placeholder image that you can replace with animations of your own. All of the enemy related code is going to go in a new file called enemy.cs. Create this now and add the exec line to initializeT2D.cs:

   exec("./enemy.cs");

Now we need to set up a couple of other things in gameData.cs. Add a new collision group for the enemies after the player’s collision group at the top of the file, like so:

$enemyGroup = 2;

And a layer to place the enemies on:

$enemyLayer = 2;

At the end of the createLevel function, add this:

   createEnemy("50 0", 30, 16, 11, 35, 100, 150, -30, 50);

Here we are calling the as of now undefined function that will create an enemy. The various parameters will be explained when we define it.

The enemies are going to act much like the player in terms of colliding, so we need to define a callback for them as well. Open game.cs. In the onCollision callback, add this after the $playerGroup case in the switch statement:

      case $enemyGroup:
         switch (%dstObj.getGraphGroup())
         {
            case $platformGroup:
               collideEnemyPlatform(%srcObj, %dstObj, %normal);
         }

To avoid confusion, the whole function should look like this:

function t2dSceneObject::onCollision(%srcObj, %dstObj, %srcRef,
   %dstRef, %time, %normal, %contactCount, %contacts)
{
   switch (%srcObj.getGraphGroup())
   {
      case $playerGroup:
         switch (%dstObj.getGraphGroup())
         {
            case $platformGroup:
               collidePlayerPlatform(%normal);
         }
         
      case $enemyGroup:
         switch (%dstObj.getGraphGroup())
         {
            case $platformGroup:
               collideEnemyPlatform(%srcObj, %dstObj, %normal);
         }
   }
}

Anytime a collision occurs in which a member of $enemyGroup collides with a member of $platformGroup, collideEnemyPlatform will be called. We are passing the function the enemy that is doing the colliding (%srcObj), the platform it collided with (%dstObj), and the collision normal (%normal). We will define this function shortly.

And finally, before the call to updatePlayer in the onUpdateScene callback, add this:

      for (%i = 0; %i < EnemyGroup.getCount(); %i++)
      {
         updateEnemy(EnemyGroup.getObject(%i));
      }

All this does is loop through all of the enemies and update each one with the updateEnemy function. I will go into more detail on this when we create EnemyGroup.

Our last set up task is to create the enemy's image map. Open datablocks.cs and add this:

new t2dImageMapDatablock(enemyImageMap)
{
   imageMode = "full";
   imageName = "~/data/images/gglogo";
};

This should be familiar by now. We created an image map by the name of enemyImageMap and assigned it the gglogo texture. Now the good stuff.

Open up the newly created enemy.cs. We have three functions we need to define. createEnemy, collideEnemyPlatform, and updateEnemy. First, createEnemy. Add this to enemy.cs:

new SimGroup(EnemyGroup);

function createEnemy(%pos, %speed, %airSpeed, %jumpHeight,
   %maxRunSurfaceAngle, %chase, %stopChase, %patrolMin, %patrolMax)
{
   %enemy = new t2dStaticSprite()
   {
      scenegraph = t2dscene;
   };
   %enemy.setImageMap(enemyImageMap);
   
   %enemy.setPosition(%pos);
   %enemy.setConstantForce(0 SPC $gravity, true);
   
   %enemy.setCollisionActive(true, true);
   %enemy.setCollisionPhysics(true, false);
   %enemy.setCollisionCallback(true);
   %enemy.setCollisionResponse(CLAMP);
   %enemy.setCollisionMaxIterations(2);
   %enemy.setCollisionPolyPrimitive(8);
   
   %enemy.setLayer($enemyLayer);
   %enemy.setGraphGroup($enemyGroup);
   %enemy.setCollisionMasks(BIT($platformGroup), BIT($platformLayer));
   
   %enemy.runSpeed = %speed;
   %enemy.airSpeed = %airSpeed;
   %enemy.jumpHeight = %jumpHeight;
   %enemy.maxRunSurfaceAngle = %maxRunSurfaceAngle;
   %enemy.chaseDistance = %chase;
   %enemy.stopChaseDistance = %stopChase;
   %enemy.patrolMin = %patrolMin;
   %enemy.patrolMax = %patrolMax;
   %enemy.move = 1;
   
   EnemyGroup.add(%enemy);
}	

First, we create a SimGroup called EnemyGroup. A SimGroup is basically a container that you can put objects in to store them in an organized fashion. This SimGroup will hold all of the enemies we create. By storing them like this, we can easily get references to our enemies if we ever need them. For example, in the onUpdateScene function we looped through all of the enemies in EnemyGroup so that we could update them. SimGroups and there less restrictive counterpart, SimSets, are very useful constructs. There are other tutorials that cover them in detail, so I won’t belabor it here, but I would recommend getting a good understanding of them as they can be extremely useful in certain situations.

In the createEnemy function, we do much the same as we did with the player. Create it as a static sprite, set the image map, apply gravity, setup collision and physics properties, and set some attributes. The attributes in this case are passed in to the create function so you can create different enemies by simply passing different values to the function. runSpeed, airSpeed, jumpHeight, and maxRunSurfaceAngle are the same here as they are for the player. The last five will be detailed when I cover the AI implementation.

The very last step is to add the enemy to the previously mentioned EnemyGroup.

Now we will define the collideEnemyPlatform function. Here it is:

function collideEnemyPlatform(%enemy, %platform, %normal)
{
  %enemy.runSurface = isRunSurface(%normal, %enemy.maxRunSurfaceAngle);
}

All we do here is determine if the enemy is on a surface that it is allowed to move on. This is exactly the same as it was for the player.

Finally, we need to update the enemies. Here is the updateEnemy function:

function updateEnemy(%enemy)
{
   doAI(%enemy);
   
   if (%enemy.runSurface > 0)
   {
      %enemy.setLinearVelocityX(%enemy.move * %enemy.runSpeed);
   }
   else if (%enemy.runSurface < 0)
   {
      %speed = %enemy.getLinearVelocityX();
      if (%enemy.move)
      {
         if (((%speed * %enemy.move) <= 0) || (mAbs(%speed) < %enemy.airSpeed))
            %enemy.setLinearVelocityX(%enemy.move * %enemy.airSpeed);
      }
   }
   
   if (%enemy.jump)
      %enemy.setLinearVelocityY(-10 * %enemy.jumpHeight);
   
   %enemy.runSurface = false;
}

This is also very similar to that of the player. The first line calls a function we will define that will determine the actions of the enemy. The enemies are capable of the same actions as our player, running and jumping, so here it will decide which of these it wants to do. The enemy’s moves are handled identically to the movement of the player. %enemy.move stores the direction the enemy wants to move exactly as $moveLeft and $moveRight do for the player. %enemy.jump stores whether or not the enemy wants to jump. If it does, we give it an upward velocity based on its jumpHeight.

Now we have abstracted the movement of the enemy in such a way that all we have to do is figure out what to make the enemies do. This is a very good way to set up AI because it works in much the same way as the human player. In writing the AI, we have no details to worry about. It is purely an artificial thinking engine.






Patrolling


The first type of AI we are going to implement is patrolling. This is a very simple process, and probably not complex enough to meet any real AI demands. With this, you will end up with something like the enemies from the NES Mario games. The enemies will walk back and forth with no knowledge of the player.

Within this, there are two types of patrolling. You can either have the enemies walk one direction until they hit something and turn around, or you can define an area in which they walk back and forth. I will implement the second in this tutorial because it is the slightly more complex of the two.

I’m going to revisit the createEnemy function to further explain the last four parameters, %chase, %stopChase, %patrolMin, and %patrolMax. %chase and %stopChase are irrelevant for now, but will come in to play when we implement chasing of the player. %chase will be the distance from the player the enemy must be before it can chase, and %stopChase is the distance at which the enemy stops chasing and returns to its patrol. %patrolMin and %patrolMax are the x values in the level that the enemy will walk back and forth between. Those we will use here.

The basic implementation of this is ridiculously simple. Here is the complete doAI function, called from updateEnemy, which will make the enemy patrol an area. Add it to enemy.cs:

function doAI(%enemy)
{
   if (%enemy.getPositionX() < %enemy.patrolMin)
      %enemy.move = 1;
   else if (%enemy.getPositionX() > %enemy.patrolMax)
      %enemy.move = -1;

If you remember, %enemy.move stores the direction the enemy wants to move. If it is positive the enemy will move right, and negative the enemy will move left. So, if the enemy goes too far to the left (past the value in %enemy.patrolMin), it will start moving right, and vice versa.





Seeing the Level


This works just fine in some cases, but you may want your enemy to patrol across gaps or up a ledge. To do this, we need to provide the enemies with a way to see what is around them in the level. Obviously, the enemy cannot actually see, and it is much more complex than we need to simulate actual sight. Instead, we are going to have the level tell the enemy about the surrounding area. Conveniently, tile maps have functionality we can use to accomplish this.

Each tile in a T2D tile map has a custom data field. We can use this field to tell enemies what is around the tile. For instance, we could put a value in the field that specifies it as the last tile before a gap. When the enemy “sees” this, it will jump so as to not fall in this gap. So, how do you do this? As with many things, T2D makes it pretty simple. Open up the tile editor again (F12 or Launch->Tile Editor from the menu). Now, control click on any tiles that are adjacent to a gap or a ledge that needs to be jumped from. For those that are to the left of the gap or ledge, type “jumpRight” in the Custom Data field of the tile properties dialog. For those that are to the right, type “jumpLeft”.

Alright, so how do we get access to this data? Open up enemy.cs and add this to the collideEnemyPlatform function:

   if (%enemy.runSurface)
   {
      %pos = %enemy.getPositionX() SPC getWord(%enemy.getAreaMax(), 1);
      %tile = %platform.pickTile(%pos);
      %enemy.groundDetails = %platform.getTileCustomData(%tile);
   }

First we make sure we are on a runSurface, then we get the tile at the position below the player, and use this tile to grab the custom data from the tile layer and store it in %enemy.groundDetails.

Now, at the end of the updateEnemy function, we have to add the following line to reset the groundDetails variable.

   %enemy.groundDetails = "";

And now we need to use these new ground details. Add this to the beginning of the doAI function:

   if ((%enemy.groundDetails $= "jumpLeft") && (%enemy.move < 0))
      %enemy.jump = 1;
   else if ((%enemy.groundDetails $= "jumpRight") && (%enemy.move > 0))
      %enemy.jump = 1;
   else
      %enemy.jump = 0;

If the enemy is moving to the left and is being told to jump to the left by the level, then we tell it to jump. If the enemy is moving right and being told to jump right, we also tell it to jump.

If you run the game now, the enemy should walk around your level within the constraints of the %patrolMin and %patrolMax variables you passed to createEnemy and jump when it hits any tiles that have jumpLeft or jumpRight as their custom data.





Chasing


A real chasing algorithm is going to involve some complicated path finding routines and is really overkill for what we need. There may be some situations where you want extensive path finding, but that is past the scope of this tutorial and should really be done in the engine code anyway.

The chasing method we will implement here is not perfect, but it works in the majority of cases, is very easy to implement, and very easy on the processor.

The first thing we need to do is create another simple state machine. This will hold the current state of the AI. Add these four variable definitions at the beginning of enemy.cs:

$aiPatrolState = 0;
$aiChaseState = 1;
$aiFindUpState = 2;
$aiFindDownState = 3;

I will explain these states later. We need to set an initial state for the AI. In the createEnemy function, add this:

   %enemy.aiState = $aiPatrolState;

And all that’s left is the doAI function. Change it to this:

function doAI(%enemy)
{
   %yDistance = $player.getPositionY() - %enemy.getPositionY();
   %distance = t2dVectorLength(t2dVectorSub(%enemy.getPosition(), $player.getPosition()));
   
   // Determine state
   switch (%enemy.aiState)
   {
      case $aiPatrolState:
         if (mAbs(%distance) <= %enemy.chaseDistance)
            %enemy.aiState = $aiChaseState;
      
      case $aiChaseState:
         if (mAbs(%distance) > %enemy.stopChaseDistance)
            %enemy.aiState = $aiPatrolState;
         else if (%yDistance > $player.getHeight())
            %enemy.aiState = $aiFindDownState;
         else if (%yDistance < -$player.getHeight())
            %enemy.aiState = $aiFindUpState;
      
      case $aiFindUpState:
         if (%yDistance > -$player.getHeight())
            %enemy.aiState = $aiChaseState;
      
      case $aiFindDownState:
         if (%yDistance < $player.getHeight())
            %enemy.aiState = $aiChaseState;
      
   }
   
   if ((%enemy.groundDetails $= "jumpLeft") && (%enemy.move < 0))
      %enemy.jump = 1;
   else if ((%enemy.groundDetails $= "jumpRight") && (%enemy.move > 0))
      %enemy.jump = 1;
   else
      %enemy.jump = 0;
   
   switch (%enemy.aiState)
   {
      case $aiPatrolState:
         if (%enemy.getPositionX() < %enemy.patrolMin)
            %enemy.move = 1;
         else if (%enemy.getPositionX() > %enemy.patrolMax)
            %enemy.move = -1;
            
      case $aiChaseState:
         if ($player.getPositionX() < %enemy.getPositionX())
            %enemy.move = -1;
         else
            %enemy.move = 1;
      
      case $aiFindUpState:
         if (%enemy.groundDetails $= "upLeft")
         {
            %enemy.move = -1;
            %enemy.jump = 1;
         }
         else if (%enemy.groundDetails $= "upRight")
         {
            %enemy.move = 1;
            %enemy.jump = 1;
         }
         else if ((%enemy.groundDetails $= "edgeLeft") && (%enemy.move < 0))
         {
            %enemy.move = 1;
         }
         else if ((%enemy.groundDetails $= "edgeRight") && (%enemy.move > 0))
         {
            %enemy.move = -1;
         }
      
      case $aiFindDownState:
         if ((%enemy.groundDetails $= "edgeLeft") && (%enemy.move < 0))
         {
            %enemy.move = 1;
         }
         else if ((%enemy.groundDetails $= "edgeRight") && (%enemy.move > 0))
         {
            %enemy.move = -1;
         }
   }
}

The first two lines grab the vertical distance the enemy is from the player and the total distance the enemy is from the player. The switch statement is the implementation of the state machine. If the enemy is in the patrol state and is within chaseDistance of the player, it goes to the chase state. If it’s in the chase state and is farther away from the player than stopChaseDistance, it goes back to patrolling. If the player is above or below the enemy, it goes into the find up or find down state respectively. When in the find up or down state, the enemy will search for a way to get higher or lower in the level. If the enemy is in the up or down state, it goes back to the chase state if it is at about the same height as the player.

You can probably see a couple of problems with this implementation. For instance, there may be ramps in the level so the enemy will try to find a way up when, in fact, all it has to do is walk towards the enemy. It turns out, though, that in practice this works pretty well. Because the direction of movement isn’t changed when the enemy goes into the find up or down state, it still will be heading toward the player.

The only time this will fail is if the player is to the left of the enemy, but the way up is to the right, or something similar. You can avoid this by designing your levels with this in mind, or by ignoring the rare situations that it happens. I should mention, though, that the enemy will eventually find the correct path if one exists and you set up the tile custom data correctly. It just may not find it right away.

The next couple of lines are the same jump determination as before. And the last part is the move determination. If the enemy is in the patrol state, we do just as we did before. If it’s in the chase state, we select the direction that is toward the player.

For the find up and find down states we need to add some more custom data to our tile map. At any tile that the enemy either can’t walk past (for example it’s next to a wall) or shouldn’t walk past (the edge of a gap that can’t be jumped), add “edgeLeft” or “edgeRight” as custom data depending on the side of the player the edge is. At any tile that the enemy has the option to either jump to a higher ledge or continue walking, add “upLeft” or “upRight” as custom data. The tile that has the data is the tile the enemy will jump from, so you may have to set the custom data on a tile that is slightly offset. Here is a picture of a tile map I created and the contents of the custom data fields.

Image:platformerTileMapCustomData.png

With this custom data, you can now see in the code that when in the find up or find down state, the enemy will change direction when it gets to an edge tile, and when in the find up state, the enemy will jump when it hits an “up” tile.

That’s it. Your enemies should now try to chase around the player. To test it out, try placing your enemies and the player at opposite sides of the level and setting the enemies chase distance (parameters six and seven of the createEnemy function) really high. The enemy should find itself a path and eventually make it up to the player. If you want to watch the enemy’s path, you can call this from the console:

$camera.setTarget(EnemyGroup.getObject(0));

That will set the camera to follow the first enemy you created.

So, now the enemies will chase after the player or patrol an area depending on what you want them to do, but they don’t do anything to the player except maybe get in the way. We will cover this in the next tutorial.