Create Enemies with AI

In this tutorial, we will add enemies with artificial intelligence to the dungeon crawler.

Create the enemy

For the enemies, we will just have a regular game object with a sprite attached. The enemies will be placed throughout the dungeon, and when the player gets close enough, the enemy will track and follow the player. If the enemy collides with the player, the player will take damage and lose health.

Let's start by creating an enemy object. Go into the 2d characters family folder, then the Sprites folder. We will use the Blue and Red Knights as enemies so open either of those sprite sheets to see all the individual sprites.

Then select the forward facing sprite and drag it into the Hierarchy Window to create a new game object that will be the enemy. Rename this to Red Knight or Blue Knight depending on which you chose and move it away from the player.



Now we need to give the enemy some AI so that it can track and follow the player.

On way to do this is to write a simple script that tells the enemy to move in a straight line towards the player position every frame. However the problem with this is that the enemy will only go in a straight line so it will be blocked by walls and not know how to walk around them.

Instead, we can use a pathfinding algorithm. This is just some code that will calculate a path to the player position, taking into account obstacles that are in the way. A common algorithm to do this in games is called the A* Pathfinding algorithm.

Understanding and writing the algorithm from scratch can be a bit complicated and is outside the scope of this course. Instead we will download a package that allows us to apply the A* algorithm to objects in Unity.

Go to arongranberg.com/astar/download to download the free Astar Pathfinding Project package by arongranberg. Once it is downloaded, you can double click on the download and it will automatically open in the package importer in the open Unity project.

Then you can just click Import to import the package into the dungeon crawler project.



Now the package is installed, we can use all of the scripts and components within it.

First create an Empty Game Object, name it A* and reset the Transform. Then in the Inspector Window for this object, add a new component named Pathfinder.

This will allow us to generate a special graph that defines which areas the AI can navigate.



On the Pathfinder component, click on Graphs to open the Graph settings.

Then under Add New Graph select Grid Graph which will generate a grid over the level that checks each grid cell to see if there is an object there.

Since the game is 2D, check the box for 2D.



You will now see a Grid Graph is the Scene view. Let's make this bigger using the Scale Tool.

Click and drag on the the blue icons on the edges to increase the size. Make sure the size of the grid is covers the whole level.



Now back in the Pathfinder settings check the box for Use 2D Physics so that it detects 2D collisions.

Leave the collider type as circle so the Pathfinder will overlay a circle over each point in the path to see if it overlaps with anything. However, increase the Diameter to 1.3 so the circles are slightly bigger to avoid the enemy clipping into walls.

We also need to specify an Obstacle Layer Mask so that the Pathfinder knows which objects it cannot pass through. To do this, click on the Walls Tilemap and in the Inspector, open the Layer dropdown and select Add Layer...

Now in the Tags & Layers settings, under Layers in one of the empty spaces, type Obstacle and press Enter to create a new Layer named Obstacle. Go back into the Inspector for the Walls Tilemap and one again click the Layer dropdown to set the Layer for the Tilemap to Obstacle.

Finally, go back into the Inspector for the A* object and set the Obstacle Layer Mask field to Obstacle, which is the Layer we just created.



Now if you hit the Scan button at the bottom of the Pathfinder component, A* will generate a navigation grid.

You will see this grid in the Scene Window, where the blue areas are places that the AI can go through and areas with red squares are obstacles i.e. walls.



The graph can be distracting when working with other objects in the game. Therefore you can toggle the graph view by unchecking or checking the the Show Graphs setting just above the Scan button.

Alternatively you can click the eye icon in the top right of the Grid Graph settings.



Now we just need to setup the enemy to track the player and move around using the Pathfinder.

To do this, go into the Inspector Window for the enemy and add a new component called AIPath (2D,3D). This will add a Seeker script which generates a path to the target that we want to object to move to. It will also add a AI Path script which will control the object and move it along the path generated by the Seeker.

We can keep most of the default settings but we need to change Orientation setting to be YAxisForward(for 2D games). This way the AI knows which axis to navigate on.

You will then see a circle around the enemy that will be used to check for obstacle collisions along the path.



We can also change the Max Speed that the enemy can move so let's set this to 3 for now. Additionally, we don't want the enemy to fall due to gravity so we can set the Gravity field to None.



Finally, we need to tell the AI what target to move towards.

We can do this by adding another component called AI Destination Setter which will allow us to specify a target for the AI.

Here you will see a Target field where you can just drag in the Player object. Then if you play the game, you will see that the enemy will move towards the player and go around walls correctly.



As you can see, the enemy rotates as it moves towards the player. However, we do not want the enemy to rotate.

We can easily fix this by going back to the AIPath component and unchecking Enable Rotation.



You may also notice that the enemy can also walk through the player. However, we just want the enemy to bump into the player.

To fix this, we can add a Capsule Collider 2D component to the enemy and edit the collider shape to match the shape of the enemy.



Great! Now we have an enemy with good AI so it can track and follow the player, even around obstacles.

Add a Proximity Trigger

Currently if we had 10 enemies in a level, they would all track and move towards the player at once. This would be very difficult to deal with and not very fun.

Instead we want to only have the enemy start following the player when the player gets close enough. This is called a proximity trigger.

Start by creating a new Empty game object as a child of the enemy object. Rename the empty object to Detection Range and reset the Transform.



Now add a Circle Collider 2D component to the object and check Is Trigger. This collider will be used to check if the player is in range.

Increase the collider radius to however far you want the enemy to be able to detect and start following the player e.g. 5.



Go into the Scripts folder and create a new script named DetectionController. Then attach this script to the Detection Range object.



Open the script and remove the Start() and Update() methods as these are not needed.

In this script we want to check if the player collides with the detection range, then enable the enemy's AIPath script (which will be disabled by default).

To do this, we first need to import the Pathfinding namespace so that we can use its classes.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Pathfinding;

public class DetectionController : MonoBehaviour
{

}
          

Create a GameObject variable named enemy and give it the [SerializeField] attribute so it will appear in the inspector.

public class DetectionController : MonoBehaviour
{
    [SerializeField] GameObject enemy;
}
          

Now create a private void OnTriggerEnter2D() method that takes in a Collider2D named collision.

Within this method we can use an if statement to check if the collision object has the Player Tag.

If it does (meaning the player is in range) then we want to get the enemy's AIPath component and enable it by setting the enabled attribute to true.

public class DetectionController : MonoBehaviour
{
    [SerializeField] GameObject enemy;

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.CompareTag("Player"))
        {
            enemy.GetComponent<AIPath>().enabled = true;
        }
    }
}
          

Save the script and go back into Unity Editor.

Now drag the enemy object into the Enemy field on the Detection Controller component of the Detection Range object.

Then in the Inspector of the enemy object, uncheck the checkbox for the AI Path component to disable it by default.

Play the game and you will see that the enemy does not start following the player until the player moves close enough.



Add Enemy Attack

Even though the enemy can follow the player, it is not really a threat. Let's give the enemy the ability to attack the player when they collide.

First we need to give the player health so that they can actually take damage. To do this, open GameManager script.

Start by creating a new int variable named health and set its initial value to 100. Also add the [SerializeField] attribute so it appears in the Inspector.

We are creating health variable and methods in the GameManager script because we want the player's health to be persistent between levels.

public class GameManager : MonoBehaviour
{
    public static GameManager Instance;
    private int gems = 0;
    [SerializeField] int health = 100;
          

Now create two new methods similar to the gems methods.

The first method, public int GetHealth(), will return the health value. The second method, public void ReduceHealth(int damage) will reduce the health value by the amount of damage taken.

    public void AddGems(int amount)
    {
        gems += amount;
    }

    public int GetHealth()
    {
        return health;
    }

    public void ReduceHealth(int damage)
    {
        health -= damage;
    }
}
          

Now that we have the player health setup, we need to actually display the health in the HUD UI.

Save the script and go back into Unity Editor. Then double click on the HUD game object to see the HUD UI in the Scene Window.

Create a new Text object in the HUD and name it Health Text. Change the text to say HEALTH: 100, edit the size and color of the text, then move and anchor the text to the top left of the HUD Panel.



Just like with the Gems Text, we need to program the HUD to update the Health Text with the actual health value.

Open the HUDController script and create a new Text variable named healthText. Make sure to give it the [SerializeField] attribute so it appears in the Inspector.

public class HUDController : MonoBehaviour
{
    [SerializeField] Text gemsText;
    [SerializeField] Text healthText;

    // Update is called once per frame
    void Update()
    {
        gemsText.text = "GEMS: " + GameManager.Instance.GetGems();
    }
}
          

Now in the Update() method we can set the healthText.text attribute to HEALTH: + GameManager.Instance.GetHealth()

public class HUDController : MonoBehaviour
{
    [SerializeField] Text gemsText;
    [SerializeField] Text healthText;

    // Update is called once per frame
    void Update()
    {
        gemsText.text = "GEMS: " + GameManager.Instance.GetGems();
        healthText.text = "HEALTH: " + GameManager.Instance.GetHealth();
    }
}
          

Save the script and go back into Unity Editor.

The player health is all setup so now we can give the enemy an attack.

In the Scripts folder, create a new script named EnemyController and attach it to the enemy object.



Open the script and delete the Start() and Update() methods as these are not needed.

Instead create a new int variable named damage and set its initial value to 20.

Also give it the [SerializeField] attribute so it appears in the Inspector.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyController : MonoBehaviour
{
    [SerializeField] int damage = 20;
}
          

Then create a new method: private void Attack().

In this method, we will simply call the GameManager.Instance.ReduceHealth() method passing in the damage as an argument.

public class EnemyController : MonoBehaviour
{
    [SerializeField] int damage = 20;

    private void Attack()
    {
        GameManager.Instance.ReduceHealth(damage);
    }
}
          

We want to call this method when the enemy collides with the player. Therefore create a private void OnCollisionEnter2D method that takes in a Collision2D named collision.

Inside the method, we will have an if statement that checks if the collision object has the Player Tag.

public class EnemyController : MonoBehaviour
{
    [SerializeField] int damage = 20;

    private void Attack()
    {
        GameManager.Instance.ReduceHealth(damage);
    }

    private void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.gameObject.CompareTag("Player"))
        {   

        }
    }
}
          

If the collision object does have the Player Tag, then we want to call the Attack() method. However, if we just call the Attack() method, then the enemy will only attack once, when the collision first happens.

We want the enemy to keep attacking as long as it stays in contact with the Player. Therefore we can use the InvokeRepeating() method which takes in a function and repeatedly calls the method.

InvokeRepeating() takes in 3 arguments:
  1. The name of the function to repeat
  2. The number of seconds to wait before running the function the first time
  3. The number of seconds to wait before running the function again after the last call

public class EnemyController : MonoBehaviour
{
    [SerializeField] int damage = 20;

    private void Attack()
    {
        GameManager.Instance.ReduceHealth(damage);
    }

    private void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.gameObject.CompareTag("Player"))
        {   
            InvokeRepeating("Attack", 0, 1);
        }
    }
}
        

Here we tell it to run the Attack method, initially after 0 seconds, then every 1 second after that.

However, now we have caused this function to repeat forever. We need to cancel the repeating attack whenever the enemy is no longer in contact with the player.

We can do this using the OnCollisionExit2D() method which runs when two colliders stop colliding. Then we will also use the CancelInvoke() method which takes in a function to stop repeating.

public class EnemyController : MonoBehaviour
{
    [SerializeField] int damage = 20;

    private void Attack()
    {
        GameManager.Instance.ReduceHealth(damage);
    }

    private void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.gameObject.CompareTag("Player"))
        {
          InvokeRepeating("Attack", 0, 1);
        }
    }

    private void OnCollisionExit2D(Collision2D collision)
    {
        if (collision.gameObject.CompareTag("Player"))
        {
            CancelInvoke("Attack");
        }
    }
}
        

Save the script and go back into Unity Editor.

First we need to assign the Health Text object to the Health Text field of the HUD Controller component on the HUD object.



Now play the game and walk into the enemy.

Every second that you are in contact with the enemy, you will take damage and your health will be reduced.



Everything works as expected. However, when the player health hits 0, nothing happens. Let's make it so that when the player health is 0, the level restarts.

Open the GameManager script and import the UnityEngine.SceneManagement namespace so we can use its classes.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class GameManager : MonoBehaviour
{
        

Now in the ReduceHealth() method, we can use an if statement to check if the health is less than or equal to 0. If that is the case, then we want to restart the level.

We can simulate restarting the level by setting the health back to 100 and then reloading the same scene.

    public void ReduceHealth(int damage)
    {
        health -= damage;

        if (health <= 0)
        {
            health = 100;
            SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
        }
    }
        

If we leave it like this then the player will still have all their gems when they die and be able to recollect the same ones in the level, which can be abused. However, we do not want to reset the gems back to 0 because the player may be on a much later level and losing all gems would be unfair.

Instead we will create a new variable called savedGems so that when the player goes to a new level, all the gems they collected will be saved in this variable.

That way we can reset the normal gems variable to 0 and still keep the gems collected from previous levels.

public class GameManager : MonoBehaviour
{
    public static GameManager Instance;
    private int gems = 0;
    private int savedGems = 0;
    [SerializeField] int health = 100;
        

Now when the player dies and the level is reset, we can just set gems to savedGems as this is the number of gems the player had when they first went into the level.

We will also create a new method called SaveGems() which will simply set savedGems to gems. This method will be called whenever the player goes to a new level.

    public void ReduceHealth(int damage)
    {
        health -= damage;

        if (health <= 0)
        {
            health = 100;
            gems = savedGems;
            SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
        }
    }

    public void SaveGems()
    {
        savedGems = gems;
    }
        

Finally we need to actually call the SaveGems() method in the ExitController script just before the new level is loaded.

Save the GameManager script and open the ExitController script.

Then call the GameManager.Instance.SaveGems() method in the if statement, just before the LoadScene() method.

public class ExitController : MonoBehaviour
{
    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.CompareTag("Player"))
        {
            GameManager.Instance.SaveGems();
            SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex + 1);
        }
    }
}
        

Save the script and go back into Unity Editor.

Play the game, collect the gem, then lose all of your health. You will see that the level restarts, the health is reset to 100 and the gems are reset to 0.

However, if you collect the gem and live to go to the next level, you will see that the gems are retained.



There is still one more thing we need to fix.

You may notice an error in the console saying NullReferenceException. This is because the Health Text UI that we need to display the health does not exist in the second level. We can fix this by packing our assets into prefabs.

Prefabs


There are many objects in the game that all levels should have. Therefore we will create a prefab of these objects so they can be reused. A prefab is just a saved object that we can reuse multiple times. Prefabs are useful because if we change one object instance of a prefab or the original prefab itself, then we can update all other instances of the prefab at the same time.

First create a new folder within the Assets folder and name it Prefabs. Then drag in the all the objects that we want to reuse in other scenes. These objects are:
  1. Player
  2. Diamond
  3. Canvas
  4. EventSystem
  5. Exit
  6. Knight (Enemy)
  7. A*



Now go into Level2 and delete all the regular objects except for the Grid and GameManager. Then use drag in the prefabs you created to different parts of the level. E.g. add the enemies, diamonds, exit etc.



Remember to also edit the A* object by editing the size of the grid to fit the level, and then scanning to generate new paths. You will also need to set the Layer for the Walls Tilemap to Walls.



You will also need to drag the Player object into the Target field of the AI Destination Setter on each enemy object.



As you can see, it is much easier to just drag in the prefabs to create multiple enemies or gems as opposed to duplicating each object.

This way we only need to keep track of the original prefab to make major changes which will be applied to all instances. Then we can edit individual instances as needed without changing the others.

Save Level2 and go back into Level1 to test the full game.



Now that we have a functional Dungeon Crawler, feel free to continue adding more levels to the game. Make sure that each level has an exit so players can move from one level to the next.

You can also search the store for other sprites that you might want to add or swap out with what you have.