Giter Club home page Giter Club logo

intelligent-opponent-shooter-game-unity's Introduction

Intelligent Opponents in Unity using Finite State Machine


Third-Person Shooter Game With Intelligent Opponents

Gameplay Video ¡ Bachelor's Thesis


FSM Example

Table of Contents

About

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.


Finite State Machine

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.


FSM Example

Base 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
}

Changing Current State

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();
}

Patrol State:

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++;
        }
    }
}

Alert State:

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() {}
}

Chase Target State:

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;
    }

}

Investigation State:

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;
    }
}

Attack State:

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;
    }
}

Health and Death

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();
        }
    }
}

Agent Controller

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
    }
}

Navigation and Movement (NavMesh & NavMeshAgent)

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.

Generating a NavMesh

  1. Mark all static objects in scene as Static.
  2. Select all objects that should affect the navigation - walkable surfaces and obstacles.
  3. Generate a NavMesh clicking Bake button (open Window > AI > Navigation)

Generated NavMesh should look something like this. Blue color represents walkable areas for agents.

Generated NavMesh

Adding a NavMeshAgent

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:

  1. Select the GameObject: Click on the AI opponent GameObject in the Unity Scene Hierarchy that you want to enable navigation for.
  2. 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.
  3. 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 Component

Movement

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

Player Detection

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.


NavMeshAgent Component

Implementation

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

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.

Implementation

// 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);
            }
        }
    }
}

intelligent-opponent-shooter-game-unity's People

Contributors

blaz-cerpnjak avatar

Stargazers

 avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

Forkers

ikili

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤ī¸ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.