Basic AI and Enemy Behavior

Virtual scenarios need conflicts, consequences, and potential rewards to feel real. Without these three things, there's no incentive for the player to care about what happens to their in-game character, much less continue to play the game. And while there are plenty of game mechanics that deliver on one or more of these conditions, nothing beats an enemy that will seek you out and try to end your session.

Programming an intelligent enemy is no easy task, and often goes hand in hand with long working hours and frustration. However, Unity has built-in features, components, and classes we can use to design and implement AI systems in a more user-friendly way. These tools will push the first playable iteration of Hero Born over the finish line and provide a springboard for more advanced C# topics.

In this chapter, we'll focus on the following topics:

  • The Unity navigation system
  • Static objects and navigation meshes
  • Navigation agents 
  • Procedural programming and logic
  • Taking and dealing damage
  • Adding a loss condition
  • Refactoring techniques 

Let's get started!

Navigating in Unity

When we talk about navigation in real life, it's usually a conversation about how to get from point A to point B. Navigating around virtual 3D space is largely the same, but how do we account for the experiential knowledge we humans have accumulated since the day we first started crawling? Everything from walking on a flat surface to climbing stairs and jumping off of curbs is a skill we learned by doing; how can we possibly program all that into a game without going insane?

Before you can answer any of these questions, you'll need to know what navigation components Unity has to offer.

Navigation components

The short answer is that Unity has spent a lot of time perfecting their navigation system and delivering components that we can use to govern how playable and non-playable characters can get around. Each of the following components comes standard with Unity and has complex features already built-in:

  • A NavMesh is essentially a map of the walkable surfaces in a given level; the NavMesh component itself is created from the level geometry in a process called baking. Baking a NavMesh into your level creates a unique project asset that holds the navigation data.
  • If NavMesh is the level map, then a NavMeshAgent is the moving piece on the board. Any object with a NavMeshAgent component attached will automatically avoid other agents or obstacles it comes into contact with. 
  • The navigation system needs to be aware of any moving or stationary objects in the level that could cause a NavMeshAgent to alter their route. Adding NavMeshObstacle components to those objects lets the system know that they need to be avoided.

While this description of the Unity navigation system is far from complete, it's enough for us to move forward with our enemy behavior. For this chapter, we'll be focusing on adding a NavMesh to our level, setting up the Enemy prefab as a NavMeshAgent, and getting the Enemy prefab to move along a predefined route in a seemingly intelligent way.

We'll only be using the NavMesh and NavMeshAgent components in this chapter, but if you want to spice up your level, take a look at how to create obstacles here: https://docs.unity3d.com/Manual/nav-CreateNavMeshObstacle.html.

Your first task in setting up an "intelligent" enemy is to create a NavMesh over the arena's walkable areas.

Time for action setting up the NavMesh

Let's set up and configure our level's NavMesh:

  1. Select the Environment GameObject, click on the arrow icon next to Static in the Inspector window, and choose Navigation Static:

  1. Click Yes, change children when the dialogue window pops up to set all the Environment child objects to Navigation Static.
  1. Go to Window | AI | Navigation and select the Bake tab. Leave everything set to their default values and click Bake:

Every object in our level is now marked as Navigation Static, which means that our newly-baked NavMesh has evaluated their accessibility based on its default NavMeshAgent settings. Everywhere you can see a light blue overlay in the preceding screenshot is a perfectly walkable surface for any object with a NavMeshAgent component attached, which is your next task.

Time for action  setting up enemy agents

Let's register the Enemy prefab as a NavMeshAgent:

  1. Select the Enemy prefab, click Add Component in the Inspector window and search for NavMesh Agent. Make sure the Enemy prefab is in the scene is updated:

  1. Click Create | Create Empty from the Hierarchy window and name the GameObject Patrol Route
    • Select Patrol Route, click Create | Create Empty to add a child GameObject, and name it Location 1. Position Location 1 in one of the corners of the level:

  1. Create three more empty child objects in Patrol Route, name them Location 2Location 3, and Location 4, respectively, and position them in the remaining corners of the level to form a square:

Adding a NavMeshAgent component to the Enemy tells the NavMesh component to take notice and register it as an object that has access to its autonomous navigation features. Creating the four empty game objects in each corner of the level lays out the simple route we want our enemies to eventually patrol; grouping them in an empty parent object makes it easier to reference them in code and makes for a more organized Hierarchy window. All that's left is the code to make the enemy walk the patrol route, which you'll add in the next section. 

Moving enemy agents

Our patrol locations are set and the Enemy prefab has a NavMeshAgent component, but now we need to figure out how to reference those locations and get the enemy moving on its own. To do that, we'll first need to talk about an important concept in the world of software development: procedural programming.

Procedural programming

Even though it's in the name, the idea behind procedural programming can be elusive until you get your head around it; once you do, you'll never see a code challenge the same way.

Any task that executes the same logic on one or more sequential objects is the perfect candidate for procedural programming. You already did a little procedural programming when you debugged arrays, lists, and dictionaries with for and foreach loops. Each time those looping statements executed, you performed the same call to Debug.Log(), iterating over each item sequentially. The idea now is to use that skill to get a more useful outcome.

One of the most common uses of procedural programming is adding items from one collection to another, often modifying them along the way. This works great for our purposes since we want to reference each child object in the empty patrolRoute parent and store them in a list.

Time for action  referencing the patrol locations 

Now that we understand the basics of procedural programming, it's time to get a reference to our patrol locations and assign them to a usable list:

  1. Add the following code to EnemyBehavior:
public class EnemyBehavior : MonoBehaviour 
{
// 1
public Transform patrolRoute;

// 2
public List<Transform> locations;

void Start()
{
// 3
InitializePatrolRoute();
}

// 4
void InitializePatrolRoute()
{
// 5
foreach(Transform child in patrolRoute)
{
// 6
locations.Add(child);
}
}

void OnTriggerEnter(Collider other)
{
// ... No changes needed ...
}

void OnTriggerExit(Collider other)
{
// ... No changes needed ...
}
}
  1. Select Enemy and drag the Patrol Route object from the Hierarchy window onto the Patrol Route variable in EnemyBehavior:

  1. Hit the arrow icon next to the Locations variable in the Inspector window and run the game to see the list populate:

Let's break down the code:

  1. First, it declares a variable for storing the patrolRoute empty parent GameObject.
  2. Then, it declares a List variable to hold all the child Transform components in patrolRoute.
  3. After that, it uses Start() to call the InitializePatrolRoute() method when the game begins.
  4. Next, it creates InitializePatrolRoute() as a private utility method to procedurally fill locations with Transform values:
    • Remember that not including an access modifier makes variables and methods private by default.
  5. Then, we use a foreach statement to loop through each child GameObject in patrolRoute and reference its Transform component:
    • Each Transform component is captured in the local child variable declared in the foreach loop.
  1. Finally, we add each sequential child Transform to the list of locations using the Add() method as we loop through the child objects in patrolRoute:
    • This way, no matter what changes we make in the Hierarchy window, locations will always be filled in with all the child objects under the patrolRoute parent.

While we could have assigned each location GameObject to locations by dragging and dropping them directly from the Hierarchy window into the Inspector window, it's easy to lose or break these connections; making changes to the location object names, object additions or deletions, or project updates can all throw a wrench into a classes' initialization. It's much safer, and more readable, to procedurally fill GameObject lists or arrays in the Start() method.

Due to that reasoning, I also tend to use GetComponent() in the Start() method to find and store component references attached to a given class instead of assigning them in the Inspector window.

Now, we need the enemy object to follow the patrol route we laid out, which is your next task.

Time for action  moving the enemy

With a list of patrol locations initialized on Start(), we can grab the enemy NavMeshAgent component and set its first destination.

Update EnemyBehavior with the following code and hit Play:

 // 1
using UnityEngine.AI;

public class EnemyBehavior : MonoBehaviour
{
public Transform patrolRoute;
public List<Transform> locations;

// 2
private int locationIndex = 0;

// 3
private NavMeshAgent agent;

void Start()
{
// 4
agent = GetComponent<NavMeshAgent>();

InitializePatrolRoute();

// 5
MoveToNextPatrolLocation();
}

void InitializePatrolRoute()
{
// ... No changes needed ...
}

void MoveToNextPatrolLocation()
{
// 6
agent.destination = locations[locationIndex].position;
}

void OnTriggerEnter(Collider other)
{
// ... No changes needed ...
}

void OnTriggerExit(Collider other)
{
// ... No changes needed ...
}
}

Let's break down the code:

  1. First, it adds the UnityEngine.AI using directive so that EnemyBehavior has access to Unity's navigation classes; in this case, NavMeshAgent.
  2. Then, it declares a variable to keep track of which patrol location the enemy is currently walking toward. Since List items are zero-indexed, we can have the Enemy prefab move between patrol points in the order they are stored in locations.
  3. Next, it declares a variable to store the NavMeshAgent component attached to the Enemy GameObject. This is private because no other classes should be able to access or modify it.
  4. After that, it uses GetComponent() to find and return the attached NavMeshAgent component to the agent.
  5. Then, it calls the MoveToNextPatrolLocation() method on Start().
  6. Finally, it declares MoveToNextPatrolLocation() as a private method and sets agent.destination:
    • destination is a Vector3 position in 3D space.
    • locations[locationIndex] grabs the Transform item in locations at a given index.
    • Adding .position references the Transform component's Vector3 position.

Now when our scene starts, locations are filled with patrol points and MoveToNextPatrolLocation() is called to set the destination position of the NavMeshAgent component to the first item at locationIndex 0 in the list of locations. The next step is to have the enemy object move from the first patrol location to all the other locations in sequence.

Time for action  patrolling continuously between locations

Our enemy moves to the first patrol point just fine, but then it stops. What we want is for it to continually move between each sequential location, which will require additional logic in Update() and MoveToNextPatrolLocation(). Let's create this behavior.

Add the following code to EnemyBehavior and hit Play:

 public class EnemyBehavior : MonoBehaviour 
{
// ... No changes needed ...

void Start()
{
// ... No changes needed ...
}

void Update()
{
// 1
if(agent.remainingDistance < 0.2f && !agent.pathPending)
{
// 2
MoveToNextPatrolLocation();
}
}

void MoveToNextPatrolLocation()
{
// 3
if (locations.Count == 0)
return;

agent.destination = locations[locationIndex].position;

// 4
locationIndex = (locationIndex + 1) % locations.Count;
}

// ... No other changes needed ...
}

Let's break down the code:

  1. First, it declares the Update() method and adds an if statement to check whether two different conditions are true:
    • remainingDistance returns how far the NavMeshAgent component currently is from its set destination.
    • pathPending returns a true or false Boolean, depending on whether Unity is computing a path for the NavMeshAgent component.
  2. If the agent is very close to its destination, and no other path is being computed, the if statement returns true and calls MoveToNextPatrolLocation().
  3. Here, we added an if statement to make sure that locations isn't empty before the rest of the code in MoveToNextPatrolLocation() is executed:
    • If locations is empty, we use the return keyword to exit the method without continuing.
This is referred to as defensive programming, and, coupled with refactoring, it is an essential skill to have in your arsenal as you move toward more intermediate C# topics.
  1. Then, we set locationIndex to its current value, +1, followed by the modulo (%) of locations.Count:
    • This will increment the index from 0 to 4, then restart it at 0 so that our Enemy prefab moves in a continuous path.
    • The modulo operator returns the remainder of two values being divided  2 divided by 4 has a remainder of 2, so 2 % 4 = 2. Likewise, 4 divided by 4 has no remainder, so 4 % 4 = 0.
Dividing an index by the maximum number of items in a collection is a quick way to always find the next item. If you're rusty on the modulo operator, revisit Chapter 2, The Building Blocks of Programming.

We now need to check that the Enemy is moving toward its set patrol location every frame in Update(); when it gets close, MoveToNextPatrolLocation() is fired, which increments locationIndex and sets the next patrol point as the destination. If you drag the Scene view down next to the Console window, as shown in the following screenshot, and hit Play, you can watch the Enemy prefab walk around the corners of the level in a continuous loop:

The enemy now follows the patrol route around the outside the level, but it doesn't seek out the player and attack when it's within a preset range. You'll use the NavAgent component to do just that in the next section.

Enemy game mechanics

Now that our Enemy is on a continuous patrol circuit, it's time to give it some interaction mechanics of its own; there wouldn't be much risk or reward if we left it walking around with no way to act against us.

Seek and destroy

In this section, we'll be focusing on switching the target of the enemies' NavMeshAgent component when the player gets too close and dealing damage if a collision occurs. When the enemy successfully lowers the player's health, it will return to its patrol route until its next run-in with the player. However, we're not going to leave our player helpless; we'll also add in code to track enemy health, detect when an enemy is successfully hit with one of the player's bullets, and when an enemy needs to be destroyed. 

Time for action  changing the agent's destination

Now that the Enemy prefab is moving around on patrol, we need to get a reference to the player's position and change the destination of NavMeshAgent.

Add the following code to EnemyBehavior and hit Play:

 public class EnemyBehavior : MonoBehaviour 
{
// 1
public Transform player;

public Transform patrolRoute;
public List<Transform> locations;

private int locationIndex = 0;
private NavMeshAgent agent;

void Start()
{
agent = GetComponent<NavMeshAgent>();

// 2
player = GameObject.Find("Player").transform;

// ... No other changes needed ...
}

/* ... No changes to Update,
InitializePatrolRoute, or
MoveToNextPatrolLocation ... */

void OnTriggerEnter(Collider other)
{
if(other.name == "Player")
{
// 3
agent.destination = player.position;

Debug.Log("Enemy detected!");
}
}

void OnTriggerExit(Collider other)
{
// .... No changes needed ...
}
}

Let's break down the code:

  1. First, it declares a public variable to hold the Player capsule's Transform value.
  2. Then, we use GameObject.Find("Player") to return a reference to the player object in the scene:
    • Adding .transform directly references the object's Transform value in the same line.
  3. Finally, we set agent.destination to the player's Vector3 position in OnTriggerEnter() whenever the player enters the enemies' attack zone.

If you play the game now and get too close to the patrolling Enemy, you'll see that it breaks from its path and comes straight for you. Once it reaches the player, the code in the Update() method takes over again and the Enemy prefab resumes its patrol. 

 

We still need the enemy to be able to hurt the player in some way, which we'll learn how to do in the next section.

Time for action  lowering player health

While our enemy mechanic has come a long way, it's still anti-climactic to have nothing happen when the Enemy prefab collides with the Player prefab. To fix this, we'll tie in the new enemy mechanics with the game manager.

Update PlayerBehavior with the following code and hit Play:

 public class PlayerBehavior : MonoBehaviour 
{
// ... No changes to public variables needed ...

private float _vInput;
private float _hInput;
private Rigidbody _rb;
private CapsuleCollider _col;

// 1
private GameBehavior _gameManager;

void Start()
{
_rb = GetComponent<Rigidbody>();
_col = GetComponent<CapsuleCollider>();

// 2
_gameManager = GameObject.Find("Game
Manager").GetComponent<GameBehavior>();

}

/* ... No changes to Update,
FixedUpdate, or
IsGrounded ... */

// 3
void OnCollisionEnter(Collision collision)
{
// 4
if(collision.gameObject.name == "Enemy")
{
// 5
_gameManager.HP -= 1;
}
}
}

Let's break down the code:

  1. First, it declares a private variable to hold the reference to the instance of GameBehavior we have in the scene.
  2. Then, it finds and returns the GameBehavior script that's attached to the Game Manager object in the scene:
    • Using GetComponent() on the same line as GameObject.Find() is a common way to cut down on unnecessary lines of code.
  3. Since our Player is the object being collided with, it makes sense to declare OnCollisionEnter() in PlayerBehavior.
  1. Next, we check for the name of the colliding object; if it's the Enemy prefab, we execute the body of the if statement.
  2. Finally, we subtract 1 from the public HP variable using the _gameManager instance.

Whenever the Enemy tracks and collides with the player now, the game manager will fire the set property on HP. The UI will update with a new value for player health, which means we have an opportunity to put in some additional logic for the loss condition later on.

Time for action  detecting bullet collisions

Now that we have our loss condition, it's time to add in a way for our player to fight back and survive enemy attacks.

Open up EnemyBehavior and modify it with the following code:

 public class EnemyBehavior : MonoBehaviour 
{
public Transform player;
public Transform patrolRoute;
public List<Transform> locations;

private int locationIndex = 0;
private NavMeshAgent agent;

// 1
private int _lives = 3;
public int EnemyLives
{
// 2
get { return _lives; }

// 3
private set
{
_lives = value;

// 4
if (_lives <= 0)
{
Destroy(this.gameObject);
Debug.Log("Enemy down.");
}
}
}

/* ... No changes to Start,
Update,
InitializePatrolRoute,
MoveToNextPatrolLocation,
OnTriggerEnter, or
OnTriggerExit ... */

void OnCollisionEnter(Collision collision)
{
// 5
if(collision.gameObject.name == "Bullet(Clone)")
{
// 6
EnemyLives -= 1;
Debug.Log("Critical hit!");
}
}
}

Let's break down the code:

  1. First, it declares a private int variable called _lives with a public backing variable called EnemyLives. This will let us control how EnemyLives is referenced and set, just like in GameBehavior.
  2. Then, we set the get property to always return _lives.
  3. Next, we use a private set to assign the new value of EnemyLives to _lives to keep them both in sync.
We haven't seen a private get or set before, but they can have their access modifiers, just like any other executable code. Declaring a get or set as private means that only the parent class has access to their functionality.
  1. Then, we add an if statement to check whether _lives is less than or equal to 0, meaning that the Enemy should be dead:
    • When that's the case, we destroy the Enemy GameObject and print out a message to the console.
  2. Because Enemy is the object getting hit with bullets, it's sensible to put a check for those collisions in EnemyBehavior with OnCollisionEnter().
  3. Finally, if the name of the colliding object matches a bullet clone object, we decrement EnemyLives by 1 and print out another message.
Notice that the name we're checking for is "Bullet(Clone)", even though our bullet prefab is named Bullet. This is because Unity adds the (Clone) suffix to any object created with the Instantiate() method, which is how we made them in our shooting logic.

You can also check for the GameObjects' tag, but since that's a Unity-specific feature, we're going to leave the code as-is and do things with pure C#. 

Now, the player can fight back when the enemy tries to take one of its lives by shooting it three times and destroying it. Again, our use of the get and set properties to handle additional logic proves to be a flexible and scalable solution. With that done, your last task is to update the game manager with a loss condition.

Time for action  updating the game manager

To fully implement the loss condition, we need to update the manager class.

Open up GameBehavior and add the following code. Then, get the Enemy prefab to collide with you three times:

 public class GameBehavior : MonoBehaviour 
{
public string labelText = "Collect all 4 items and win your
freedom!"
;
public int maxItems = 4;
public bool showWinScreen = false;

// 1
public bool showLossScreen = false;

private int _itemsCollected = 0;
public int Items
{
// ... No changes needed ...
}

private int _playerHP = 3;
public int HP
{
get { return _playerHP; }
set {
_playerHP = value;

// 2
if(_playerHP <= 0)
{
labelText = "You want another life with that?";
showLossScreen = true;
Time.timeScale = 0;
}
else
{
labelText = "Ouch... that's got hurt.";
}
}
}

void OnGUI()
{
// ... No changes needed ...

// 3
if(showLossScreen)
{
if (GUI.Button(new Rect(Screen.width / 2 - 100,
Screen.height / 2 - 50, 200, 100), "You lose..."))

{
SceneManager.LoadScene(0);
Time.timeScale = 1.0f;
}
}
}
}

Let's break down the code:

  • First, it declares a public bool to keep track of when the GUI needs to display the loss screen button.
  • Then, it adds in an if statement to check when _playerLives drops below 0:
    • If it's truelabelText, showLossScreen, and Time.timeScale are all updated.
    • If the player is still alive after an Enemy collision, labelText shows a different message.
  • Finally, we continually check whether showLossScreen is true, at which point we create and display a button that matches the dimensions of the win condition button but with different text:
    • When the user clicks the loss button, the level is restarted and timeScale is reset to 1 so that input and motion are re-enabled.

That's a wrap! You've successfully added a "smart" enemy that can damage the player and be damaged right back, as well as a loss screen through the game manager. Before we finish this chapter, there's one more important topic that we need to discuss, and that's how to avoid repeating code. Repeated code is the bane of all programmers, so it makes sense that you learn how to keep it out of your projects early on!

Refactoring and keeping it DRY

The Don't Repeat Yourself (DRY) acronym is the software developer's conscience: it tells you when you're in danger of making a bad or questionable decision, and gives you a feeling of satisfaction after a job well done.

In practice, repeated code is part of programming life. Trying to avoid it by constantly thinking ahead will put up so many roadblocks in your project that it won't seem worth it to continue. A more efficient – and sane – approach to dealing with repeating code is to quickly identify it when and where it occurs and then look for the best way to remove it. This task is called refactoring, and our GameBehavior class could use a little of its magic right now.

Time for action  creating a restart method

To refactor the existing level's restart code, you'll need to update GameBehavior, as follows:

  public class GameBehavior : MonoBehaviour 
{
// ... No changes needed ...

// 1
void RestartLevel()
{
SceneManager.LoadScene(0);
Time.timeScale = 1.0f;
}

void OnGUI()
{
GUI.Box(new Rect(20, 20, 150, 25), "Player Health: " +
_playerLives);
GUI.Box(new Rect(20, 50, 150, 25), "Items Collected: " +
_itemsCollected);
GUI.Label(new Rect(Screen.width / 2 - 100, Screen.height -
50, 300, 50), labelText);

if (showWinScreen)
{
if (GUI.Button(new Rect(Screen.width/2 - 100,
Screen.height/2 - 50, 200, 100), "YOU WON!"))
{
// 2
RestartLevel();
}
}

if(showLossScreen)
{
if (GUI.Button(new Rect(Screen.width / 2 - 100,
Screen.height / 2 - 50, 200, 100), "You lose..."))
{
RestartLevel();
}
}
}
}

Let's break down the code:

  1. First, it declares a private method named RestartLevel() that executes the same code as the win/loss buttons in OnGUI().
  2. Then, it replaces both instances of the repeated restart code in OnGUI() with a call to RestartLevel().

There's always more to refactor if you look in the right places. Your final optional task is to refactor the win/lost logic in the game manager, which we'll do in the next section.

Hero's trial  refactoring win/lose logic

While you've got your refactoring brain in gear, you might have noticed that the code that updates labelText, showWinScreen/showLossScreen, and Time.timeScale is repeated in the set block of both Lives and Items. Your mission is to write a private utility function that takes parameters for each of the updated variables mentioned and assign them accordingly. Then, replace the repeated code in Lives and Items with a call to the new method. Happy coding!

Summary

With that, our Enemy and Player interactions are complete. We can deal damage as well as take it, lose lives, and fight back, all while updating the on-screen GUI. Our enemies use Unity's navigation system to walk around the arena and change to attack mode when within a specified range of the player. Each GameObject is responsible for its behavior, internal logic, and object collisions, while the game manager keeps track of the variables that govern the game's state. Lastly, we learned about simple procedural programming and how much cleaner code can be when repeated instructions are abstracted out into their methods.

You should feel a sense of accomplishment at this point, especially if you started this book as a total beginner. Getting up to speed with a new programming language while building a working game is no easy trick. In the next chapter, you'll be introduced to some intermediate topics in C#, including new type modifiers, method overloading, interfaces, and class extensions.

Pop quiz  AI and navigation

  1. How is a NavMesh component created in a Unity scene?
  2. What component identifies a GameObject to a NavMesh?
  3. Executing the same logic on one or more sequential objects is an example of which programming technique?
  4. What does the DRY acronym stand for?
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.147.48.244