How to make AI sentient in Unity, Part I
In the IsInVisibleArea method, we have the same test as in the Ears component to find out, whether a detectable is in the visible distance and we also test if the detectable is inside the field of view. For now, just note that on Ears it's set to true and on Eyes, it's set to false. Equals(detectable.Today, I'd like to show you one way to grant sentience to your AI agent in Unity so it can T-800 the world...
Ok, not really. However, I'm going to guide you through an implementation of eyes and ears you can use for your NPCs, so they will be able to react when they see or hear a specific object in a scene, which is also pretty cool, right? :-)
In this Part I, we're going to see the base class Sense, and Eyes and Ears that inherit from it. We're also going to see how to take advantage of UnityAction to keep the implementations of senses and reactions nicely decoupled.
In the subsequent and final Part II, we're going to dive into the implementation of some actual reactive AI behavior and find out how to harness the power of a simple State pattern, which is enough if we don't plan any complex AI.
If you do plan to have a game with complex AI behavior, look for behavior trees, Goal Oriented Action Planning (GOAP), or Hierarchical Task Network (HTN).
I assume you have at least a basic understanding of Unity engine and C# programming language, though I tried to make this tutorial reasonably beginner-friendly. Feedback is always welcomed.
Before we'll continue, I encourage you to get the final example from GitHub and open it in Unity 2020.3.17f1, so you can see the parts of code I'll be describing in the context.
So if you're ready, without further ado, let's get started 🚀.
Detectable and PlayerController components
First, we don't want our AI to uncontrollably react on every GameObject in the scene, so we need to mark only the Player as detectable.
It can be achieved with Tags, for instance, but marking objects by adding a custom component gives us a possibility to store and pass some useful data.
In this example, it's the flag CanBeHear that is set by PlayerController only when a player is moving and unset when the player stays still. You can see in the hierarchy that both Detectable and PlayerController are attached to the Player object.
As for the PlayerController, I'm not going to describe its implementation in much detail, it's very basic and not what we're focusing on in this post.
But notice how we set detectable.CanBeHear to true only if the verticalAxis is bigger than zero, in other words, only when the player is moving.
This player controller depends on the Detectable component, so it's a good practice to decorate the class with the RequireComponent attribute, just as you can see on the first line below.
The base class Senses
Before we'll be talking about Eyes and Ears, let's point out that although they need to have a slightly different implementation, they both can share a fair bit of common logic.
That's a good place for using inheritance. You might have heard that you should always favor composition over inheritance and that some modern languages like Go don't even have the inheritance.
But the short-sighted conclusion "inheritance is bad" is just wrong. Inheritance represents is a relationship like a cat is an animal, or in our case, eyes and ears are senses, while composition represents has a relationship, like a car has an engine, or in Unity, a GameObject can have MeshRenderer, Rigidbody, Collider, and many other components.
In fact, in relationship with GameObject, our Eyes and Ears are also components and Sense is a MonoBehavior, which makes Eyes and Ears MonoBehaviors as well.
Both inheritance and composition are useful concepts of OOP and both are often used together. When used right they are very powerful when misused they both can lead to an unmanageable mess. But let's get back to the original topic.
In the Sense class code below, notice how we have no logic in the protected virtual HasDetected method. Because we won't be attaching to any GameObject the base class itself, but its child classes Eyes and Ears which overwrites this method with their own logic as we'll soon see.
The usefulness of the IsDetectionContinuous flag we'll see in Part II in context with behavior. For now, just note that on Ears it's set to true and on Eyes, it's set to false. Ears and Eyes with respective components are in the scene hierarchy child objects of the Enemy.
using UnityEngine;
using UnityEngine.Events;
public class Sense : MonoBehaviour
{
public Detectable Detectable;
public float Distance;
protected bool IsSensing;
public bool IsDetectionContinuous = true;
public UnityAction<Detectable> OnDetect;
public UnityAction<Detectable> OnLost;
private void Detect(Detectable detectable)
{
IsSensing = true;
OnDetect?.Invoke(detectable);
}
private void Lost(Detectable detectable)
{
IsSensing = false;
OnLost?.Invoke(detectable);
}
void Update()
{
if (IsSensing)
{
if (!HasDetected(Detectable))
{
Lost(Detectable);
return;
}
if(IsDetectionContinuous)
{
Detect(Detectable);
}
}
else
{
if (!HasDetected(Detectable))
return;
Detect(Detectable);
}
}
protected virtual bool HasDetected(Detectable detectable) => false;
}
The Ears component
With Sense as a base class, the Ears class derived from it is actually pretty simple, all it needs is to provide overwrite for HasDetected method where true is returned when detectable is within the Distance and its flag CanBeHear is set.
The Eyes component
As you probably already guess, the implementation of the Eyes class is not a one-liner. However, it's still quite simple. The detectable zone just needs to be determined not only by distance but also by field of view.
When a detectable object is inside the zone, the Eyes component needs to perform just one extra test to find out whether the object is occluded or not.
As you can see, HasDetected method is still a one-liner, but both IsInVisibleArea and IsNotOcclued methods need to be implemented by us.
In the IsInVisibleArea method, we have the same test as in the Ears component to find out, whether a detectable is in the visible distance and we also test if the detectable is inside the field of view.
To figure it out, we need direction between detectable and eyes, which we get simply by vector subtraction. Then we calculate the dot product between normalized direction and forward vector of Eyes (which is also a unit vector).
Finally, we compare the dot product with FieldOfViewDot, which we set in the Start method as one minus half of the FieldOfView remapped from the original range to a range from 0 to 1. If it's bigger or equal, detectable is inside the field of view.
The dot product is particularly useful in this case. Since we don't need to care whether the detectable is on the left or on the right side, there's no need to calculate the actual angle between two vectors.
Of course, we can set FieldOfView directly in the range between 0 to 1, get rid of FieldOfViewDot and cut out the value remapping, but it's much more intuitive to think about FOV in terms of angles.
Exposing values in human-friendly units while internally using different ones for better performance is a common practice often see also with angles and radians.
Unity have us covered here, once again with Vector3.Distance, and also with Vector3.Dot methods. There's also Vector3.Angle method for cases when dot product is not enough. That's nice, even though the math behind it is relatively basic. I'm including it here, as a little bonus:
As mentioned before, in the last test Eyes component check if detectable is occluded. This is implemented in the IsNotOccluded method with Unity's Physics.Raycast.
X-Ray vision, invisibility, glass, more detectable objects, etc.
Before we wrap up Part I, let's briefly talk about some edge-cases, limitations, and extra features.
To tackle invisibility, you can add another member next to CanBeHear in the Detectable component and in the the Eyes component perform a similar check as you've seen in Ears component.
For X-Ray vision, the simplest approach would be to add a Boolean variable to the Eyes component, something like HasXRay, and skip IsNotOccluded method call when the flag is set.
If you have transparent objects in your scene, like glass doors or windows, you can simply add them to Ignore Raycast layer.
Having more than one detectable object would be a bit tricky, but one reasonable approach would be, instead of adding complexity to senses, creating another layer that would provide detectable from a pool of detectables on runtime. This way, there will be still only one detectable associated with a set of senses at a time and implementation of senses would stay intact.
That's it for today. I hope you've enjoyed it. If so, stay tuned for Part II, where we're going to dissect the implementation of an AI controller and behaviors like patrol between points, chase player, and investigate location.