Third-Person Shooter Game With Intelligent Opponents
Gameplay Video
·
Bachelor's Thesis
- About
- Finite State Machine
- Agent Controller
- Health
- Navigation and Movement
- Player Detection
- Sound Detection
For my bachelor's thesis, I developed intelligent opponents within a third-person shooter game. This was achieved by utilizing Finite State Machine (FSM) approach. The FSM approach allowed for the dynamic control of the opponents' behaviors, enabling them to adapt and respond intelligently to different in-game situations. In this document I will explain how to achieve intelligent behaviour with the help of FSM and Unity engine.
The core AI behavior is implemented using a Finite State Machine approach. The approach is to divide the behaviour of an agent into several different states. For example: patrolling state, chasing target state and attacking state. Between these states we define transitions or conditions and actions that the agent will perform in a given state.
The State class is the base class for representing states. Each state inherits from this class and receives a reference to an AgentController script.
public class State
{
public readonly AgentController agent;
public State(AgentController agent) {
this.agent = agent;
}
public virtual void Enter() {} // Method is run only once, when this state begins
public virtual void Tick() {} // Method is being run every frame. It's used for executing logic.
public virtual void Exit() {} // Method is run only once, when this state ends
}
In AgentController script we store currentState. We developed a method ChangeState, which updates current state. First it executes Exit() method from current State, than it changes the old state with new one and finally it executes Enter() method from new state.
public void ChangeState(State nextState)
{
currentState.Exit();
currentState = newState;
currentState.Enter();
}
In the patrol state, the agent walks between the points that we predefine. At each point, the agent jumps for 2 seconds.
public class PatrolState : State
{
private int currentWaypointIndex;
private float timer;
private bool isReversedPatrol;
public PatrolState(AgentController agent) : base(agent) {}
public override void Enter() {
agent.navMeshAgent.enabled = true;
agent.navMeshAgent.speed = 3f;
currentWaypointIndex = 0;
timer = 0f;
}
// Logic
public override Tick() {
if (agent.currentTarget != null)
{
agent.ChangeState(agent.alertState);
return;
}
if (!agent.navMeshAgent.pathPending && agent.navMeshAgent.remainingDistance < agent.navMeshAgent.stoppingDistance && !agent.navMeshAgent.hasPath)
{
timer += Time.deltaTime;
// We want agent to wait, before going to the next waypoint
if (timer >= 2f)
{
GoToNextWaypoint();
timer = 0f;
}
}
}
public override Exit() {} // Not needed in this state
private void GoToNextWaypoint()
{
// Assign Transform[] patrolWaypoints from scene
if (agent.patrolWaypoints.Length == 0) {
return;
}
agent.navMeshAgent.destination = agent.patrolWaypoints[currentWaypointIndex].position;
// We save position, if the agent will need to return back (e.g. he leaves position, to pursue the target)
agent.initialPosition = agent.patrolWaypoints[currentWaypointIndex].position;
if (currentWaypointIndex >= agent.patrolWaypoints.Length - 1) {
isReversedPatrol = true;
} else if (currentWaypointIndex == 0) {
isReversedPatrol = false;
}
if (isReversedPatrol) {
currentWaypointIndex--;
} else {
currentWaypointIndex++;
}
}
}
The alert state is like some kind of intermediate state before the agent goes into the attack state. This is so that the agent doesn't immediately start attacking the player, as this could frustrate him.
public class AlertState : State
{
public float alertTime = 2f;
float timer;
public AlertState(AgentController agent) : base(agent) {}
public override void Enter()
{
agent.aimRig.weight = 1f;
timer = 0f;
}
public override void Tick()
{
agent.RotateTowardsTarget();
if (agent.currentTarget == null)
{
if (agent.lastKnownTargetPosition == null)
{
agent.returnToPostState.position = agent.initialPosition;
agent.ChangeState(agent.returnToPostState);
return;
}
agent.investigationState.investigationPosition = agent.lastKnownTargetPosition;
agent.ChangeState(agent.investigationState);
return;
}
timer += Time.deltaTime;
if (timer >= alertTime && soldier.currentTarget != null)
{
agent.ChangeState(agent.chaseTargetState);
}
}
override public void Exit() {}
}
In a target chase state, the agent follows the player and tries to reach the minimum attacking distance.
public class ChaseTargetState : State
{
public ChaseTargetState(AgentController agent) : base(agent) {}
public override void Enter()
{
agent.navMeshAgent.enabled = true;
agent.navMeshAgent.speed = 5f;
agent.navMeshAgent.destination = agent.currentTarget.transform.position;
}
public override void Tick()
{
if (agent.currentTarget == null)
{
agent.investigationState.investigationPosition = soldier.lastKnownTargetPosition;
agent.ChangeState(soldier.investigationState);
return;
}
if (IsTargetInAttackRange())
{
agent.ChangeState(soldier.attackState);
return;
}
MoveTowardsCurrentTarget();
}
private void MoveTowardsCurrentTarget()
{
agent.navMeshAgent.enabled = true;
agent.navMeshAgent.destination = agent.currentTarget.transform.position;
}
private bool IsTargetInAttackRange()
{
return agent.distanceFromCurrentTarget <= 50f;
}
}
If agent looses the target, he should try to find it. In investigation state the agent first goes to the last know target position. Then it moves between random generated points and looks for target. After certain time passes, agent returns to his previous position.
public class InvestigationState : State
{
public float investigationRadius = 10f;
public float investigationTime = 15f;
public Vector3 investigationPosition;
private float elapsedTime;
private bool firstPointVisited;
public InvestigationState(AgentController agent) : base(agent) {}
public override void Enter()
{
elapsedTime = 0f;
firstPointVisited = false;
agent.aimRig.weight = 1f; // soldier is aiming
agent.navMeshAgent.destination = investigationPosition;
agent.navMeshAgent.speed = soldier.config.speed;
}
public override void Tick()
{
if (agent.currentTarget != null)
{
if (agent.health.currentHealth <= 90)
{
agent.ChangeState(soldier.attackState);
return;
}
agent.ChangeState(soldier.alertState);
return;
}
if (firstPointVisited)
{
elapsedTime += Time.deltaTime;
}
if (elapsedTime >= investigationTime)
{
agent.returnToPostState.position = soldier.initialPosition;
agent.returnToPostState.rotation = soldier.initialRotation;
agent.ChangeState(soldier.returnToPostState);
return;
}
// Check if the agent has reached the destination point
if (agent.navMeshAgent.remainingDistance <= agent.navMeshAgent.stoppingDistance)
{
firstPointVisited = true;
agent.navMeshAgent.destination = GetRandomDestinationPoint();
}
}
public override void Exit() {}
private Vector3 GetRandomDestinationPoint()
{
// Calculate a new destination point around the center point
Vector2 randomCircle = Random.insideUnitCircle * investigationRadius;
Vector3 randomDirection = new Vector3(randomCircle.x, 0f, randomCircle.y);
Vector3 destinationPoint = investigationPosition + randomDirection;
// Project the destination point onto the NavMesh
NavMeshHit navMeshHit;
NavMesh.SamplePosition(destinationPoint, out navMeshHit, investigationRadius, NavMesh.AllAreas);
return navMeshHit.position;
}
}
In a attack state agents shoots at target.
public class AttackState : State
{
public AttackState(AgentController agent) : base(agent) {}
override public void Enter()
{
soldier.aimRig.weight = 1f;
soldier.weaponIK.isAiming = true;
soldier.navMeshAgent.enabled = false;
soldier.weaponIK.SetFiring(true);
// TODO - Implement shooting logic
}
override public void Tick()
{
agent.RotateTowardsTarget();
if (agent.currentTarget == null)
{
soldier.investigationState.investigationPosition = soldier.lastKnownTargetPosition;
soldier.ChangeState(soldier.investigationState);
return;
}
if (agent.distanceFromCurrentTarget > 50f)
{
agent.ChangeState(agent.chaseTargetState);
return;
}
}
override public void Exit()
{
soldier.weaponIK.SetFiring(false);
soldier.aimRig.weight = 0f;
soldier.weaponIK.isAiming = false;
soldier.navMeshAgent.enabled = true;
}
}
To control agent's health we implemented a Health script which is attached to agent's game object. In inspector just connect OnDeath event to a method in AgentController script.
using UnityEngine;
using UnityEngine.Events;
public class Health : MonoBehaviour
{
[SerializeField] private int startingHealth = 100;
public int currentHealth;
public UnityEvent OnDeath;
public UnityEvent<int> OnDamage;
private void Awake()
{
currentHealth = startingHealth;
}
public void TakeDamage(int amount)
{
currentHealth -= amount;
OnDamage?.Invoke(currentHealth);
if (currentHealth <= 0)
{
currentHealth = 0;
OnDeath?.Invoke();
}
}
}
AgentController is a script on agent's GameObject, which is used for handling agent's states and animations.
public class AgentController : MonoBehaviour
{
private Animator animator;
private NavMeshAgent navMeshAgent;
private State currentState;
[Header("Agent")]
public Transform[] patrolWaypoints;
public Vector3 initialPosition;
public Quaternion initialRotation;
[Header("Agent States")]
public PatrolState patrolState;
public AlertState alertState;
public ChaseTargetState chaseTargetState;
public InvestigationState investigationState;
public AttackState attackState;
[Header("Target")]
public Transform currentTarget;
public float distanceFromCurrentTarget; // distance from current target to agent
public Vector3 lastKnownTargetPosition;
public void Awake() {
animator = GetComponent<Animator>();
navMeshAgent = GetComponent<NavMeshAgent>();
initialPosition = transform.position;
initialRotation = transform.rotation;
}
// Run before first frame
public void Start()
{
InitializeStates();
currentState = idleState;
}
// Run every frame
public void Update()
{
currentState.Tick();
if (currentTarget != null) {
distanceFromCurrentTarget = Vector3.Distance(currentTarget.transform.position, transform.position);
}
}
private void InitializeStates()
{
patrolState = new PatrolState(this);
chaseTargetState = new ChaseTargetState(this);
attackState = new AttackState(this);
}
public void ChangeState(State nextState)
{
currentState.Exit();
currentState = newState;
currentState.Enter();
}
public void RotateTowardsTarget()
{
if (currentTarget == null) return;
Vector3 direction = currentTarget.position - transform.position; // Calculate the direction from the agent's position to the target's position
direction.y = 0; // Ignore the vertical component of the direction
transform.LookAt(transform.position + direction); // Rotate the agent towards the target
}
}
The Navigation Mesh, or NavMesh, is a data structure that represents walkable surfaces within the game world. It acts as a blueprint for the AI characters to navigate the environment seamlessly. With NavMesh, the opponents can find optimal paths to reach their destinations, whether it's chasing the player, taking cover, or maneuvering around obstacles.
- Mark all static objects in scene as Static.
- Select all objects that should affect the navigation - walkable surfaces and obstacles.
- Generate a NavMesh clicking Bake button (open Window > AI > Navigation)
Generated NavMesh should look something like this. Blue color represents walkable areas for agents.
NavMeshAgent is used for moving your object and navigating it on the NavMesh.
Here's how you can add and configure a NavMeshAgent for it:
- Select the GameObject: Click on the AI opponent GameObject in the Unity Scene Hierarchy that you want to enable navigation for.
- Add NavMeshAgent Component: In the Inspector window, click on the "Add Component" button. Search for "NavMeshAgent" and select it from the list to add the NavMeshAgent component to the GameObject.
- Configuring NavMeshAgent:
- Speed: Adjust the "Speed" parameter to set the movement speed of the AI opponent. This determines how fast the agent moves along the NavMesh.
- Stopping Distance: Set the "Stopping Distance" parameter to determine how close the AI opponent gets to its destination before stopping.
- Acceleration: You can adjust the "Acceleration" parameter to control how quickly the AI opponent accelerates and decelerates while moving.
NavMeshAgent is responsible for moving the GameObject. You just have to set the destintation.
navMeshAgent.destination = currentTarget.transform.position; // Agent will start moving towards the destination
In order for the agent to detect the player, it must initially verify whether the player is within its field of vision (FOV) and ensure that no obstacles obstruct the line of sight between them.
We have implemented a function called SearchForTarget, tasked with determining whether a target is present and setting the currentTarget variable accordingly. This function is executed at intervals of 1 second using Coroutines.
We start this Coroutine inside the Start method of AgentController.
public void Start()
{
StartCoroutine(SearchForTarget());
}
private IEnumerator SearchForTarget()
{
WaitForSeconds waitTime = new WaitForSeconds(1f);
while (!isDead)
{
yield return waitTime;
// Find all objects around agent's position
// Detection layer is a Layer Mash set to "Player", because we only want to detect the player (ignore other layers)
Collider[] colliders = Physics.OverlapSphere(transform.position, 40f, config.detectionLayer);
if (colliders.Length <= 0)
{
currentTarget = null;
yield return waitTime;
}
foreach (Collider collider in colliders)
{
// If object has component PlayerController (script on player's game object), we detected the player
if (collider.TryGetComponent(out PlayerController player))
{
Vector3 directionToTarget = (player.transform.position - transform.position).normalized;
// Check FOV
if (Vector3.Angle(transform.forward, directionToTarget) < 100)
{
float distanceToTarget = Vector3.Distance(transform.position, player.transform.position);
Vector3 startPoint = new Vector3(transform.position.x, config.characterEyeLevel, transform.position.z);
// Check if there is a obstacle between agent and player
// Obstacle layer is layer with everything instead of Player
if (Physics.Raycast(startPoint, directionToTarget, distanceToTarget, config.obstacleLayer))
{
currentTarget = null;
break;
}
currentTarget = player.transform;
lastKnownTargetPosition = currentTarget.position;
break;
} else {
currentTarget = null;
break;
}
} else {
currentTarget = null;
}
}
}
}
Sound detection works similar as player detection. When we play a certain sound effect on a game object, for example explosion, we must notify all agents in certain radius around that object. Each agent than responds to that sound.
// Class for representing information about sound
public class MySound
{
public readonly Vector3 position;
public readonly float range;
public MySound(Vector3 position, float range)
{
this.position = position;
this.range = range;
}
}
// Every object, that can response to sound, must implement this interface
public interface IHear
{
void RespondToSound(MySound sound);
}
// Function for notifying agents nearby
// We call this function, when we play some sound effect in scene
public static class MySounds
{
public static void MakeSound(MySound sound)
{
int layerMask = 1 << 15; // 15 = Agent's Layer Mask (filter, for faster and more optimal search)
Collider[] colliders = Physics.OverlapSphere(sound.position, sound.range, layerMask);
foreach (Collider collider in colliders)
{
if (collider.TryGetComponent(out IHear hearer))
{
hearer.RespondToSound(sound);
}
}
}
}