Creating awareness in a stealth game

Now that we know how to implement sensory-level algorithms, it's time to see how they could be taken into account in order to develop higher-level techniques for creating agent awareness.

This recipe is based on the work of Brook Miles and its team at Klei Entertainment for the game, Mark of the Ninja. The mechanism moves around the notion of having interest sources that can be seen or heard by the agents, and a sensory manager handling them.

Getting ready

As a lot of things move around the idea of interests, we'll need two data structures for defining an interest's sense and priority, and a data type for the interest itself.

This is the data structure for sense:

public enum InterestSense
{
    SOUND,
    SIGHT
};

This is the data structure for priority:

public enum InterestPriority
{
    LOWEST = 0,
    BROKEN = 1,
    MISSING = 2,
    SUSPECT = 4,
    SMOKE = 4,
    BOX = 5,
    DISTRACTIONFLARE = 10,
    TERROR = 20
};

The following is the interest data type:

using UnityEngine;
using System.Collections;

public struct Interest
{
    public InterestSense sense;
    public InterestPriority priority;
    public Vector3 position;
}

Before developing the necessary classes for implementing this idea, it's important to note that sensory-level functions are left blank in order to keep the recipe flexible and open to our custom implementations. These implementations could be developed using some of the previously learned recipes.

How to do it…

This is a long recipe where we'll implement two extensive classes. It is advised to carefully read the following steps:

  1. Let's start by creating the class that defines our agents, and its member variables:
    using UnityEngine;
    using System.Collections;
    using System.Collections.Generic;
    
    public class AgentAwared : MonoBehaviour
    {
        protected Interest interest;
        protected bool isUpdated = false;
    }
  2. Define the function for checking whether a given interest is relevant or not:
    public bool IsRelevant(Interest i)
    {
        int oldValue = (int)interest.priority;
        int newValue = (int)i.priority;
        if (newValue <= oldValue)
            return false;
        return true;
    }
  3. Implement the function for setting a new interest in the agent:
    public void Notice(Interest i)
    {
        StopCoroutine(Investigate());
        interest = i;
        StartCoroutine(Investigate());
    }
  4. Define the custom function for investigating. This will have our own implementation, and it will take into account the agent's interest:
    public virtual IEnumerator Investigate()
    {
        // TODO
        // develop your implementation
        yield break;
    }
  5. Define the custom function for leading. This will define what an agent does when it's in charge of giving orders, and will depend on our own implementation:
    public virtual IEnumerator Lead()
    {
        // TODO
        // develop your implementation
        yield break;
    }
  6. Create the class for defining interest sources:
    using UnityEngine;
    using System.Collections;
    using System.Collections.Generic;
    
    public class InterestSource : MonoBehaviour
    {
        public InterestSense sense;
        public float radius;
        public InterestPriority priority;
        public bool isActive;
    }
  7. Implement a property for retrieving its interest:
    public Interest interest
    {
        get
        {
            Interest i;
            i.position = transform.position;
            i.priority = priority;
            i.sense = sense;
            return i;
        }
    }
  8. Define the function for checking whether or not the agent is affected by the interest source. This could be defined in the agent's class, but it requires some changes in some of the next steps. This is one of the sensory-level functions:
    protected bool IsAffectedSight(AgentAwared agent)
    {
        // TODO
        // your sight check implementation
        return false;
    }
  9. Implement the next sensory-level function for checking if an agent is affected by sound. It has the same architectural considerations as the previous step:
    protected bool IsAffectedSound(AgentAwared agent)
    {
        // TODO
        // your sound check implementation
        return false;
    }
  10. Define the function for getting the list of agents affected by the interest source. It is declared virtual, in case we need to specify further, or simply change the way it works:
    public virtual List<AgentAwared> GetAffected(AgentAwared[] agentList)
    {
        List<AgentAwared> affected;
        affected = new List<AgentAwared>();
        Vector3 interPos = transform.position;
        Vector3 agentPos;
        float distance;
        // next steps
    }
  11. Start creating the main loop for traversing the list of agents and return the list of affected ones:
    foreach (AgentAwared agent in agentList)
    {
        // next steps
    }
    return affected;
  12. Discriminate an agent if it is out of the source's action radius:
    agentPos = agent.transform.position;
    distance = Vector3.Distance(interPos, agentPos);
    if (distance > radius)
        continue;
  13. Check whether the agent is affected, given the source's type of sense:
    bool isAffected = false;
    switch (sense)
    {
        case InterestSense.SIGHT:
            isAffected = IsAffectedSight(agent);
            break;
        case InterestSense.SOUND:
            isAffected = IsAffectedSound(agent);
            break;
    }
  14. If the agent is affected, add it to the list:
    if (!isAffected)
        continue;
    affected.Add(agent);
  15. Next, create the class for the sensory manager:
    using UnityEngine;
    using System.Collections;
    using System.Collections.Generic;
    
    public class SensoryManager : MonoBehaviour
    {
        public List<AgentAwared> agents;
        public List<InterestSource> sources;   
    }
  16. Implement its Awake function:
    public void Awake()
    {
        agents = new List<AgentAwared>();
        sources = new List<InterestSource>();
    }
  17. Declare the function for getting a set of scouts, given a group of agents:
    public List<AgentAwared> GetScouts(AgentAwared[] agents, int leader = -1)
    {
        // next steps
    }
  18. Validate according to the number of agents:
    if (agents.Length == 0)
        return new List<AgentAwared>(0);
    if (agents.Length == 1)
        return new List<AgentAwared>(agents);
  19. Remove the leader, if given its index:
    List<AgentAwared> agentList;
    agentList = new List<AgentAwared>(agents);
    if (leader > -1)
        agentList.RemoveAt(leader);
  20. Calculate the number of scouts to retrieve:
    List<AgentAwared> scouts;
    scouts = new List<AgentAwared>();
    float numAgents = (float)agents.Length;
    int numScouts = (int)Mathf.Log(numAgents, 2f);
    
  21. Get random scouts from the list of agents:
    while (numScouts != 0)
    {
        int numA = agentList.Count;
        int r = Random.Range(0, numA);
        AgentAwared a = agentList[r];
        scouts.Add(a);
        agentList.RemoveAt(r);
        numScouts--;
    }
  22. Retrieve the scouts:
    return scouts;
  23. Define the function for checking the list of interest sources:
    public void UpdateLoop()
    {
        List<AgentAwared> affected;
        AgentAwared leader;
        List<AgentAwared> scouts;
        foreach (InterestSource source in sources)
        {
            // next steps
        }
    }
  24. Avoid inactive sources:
    if (!source.isActive)
        continue;
    source.isActive = false;
  25. Avoid sources that don't affect any agent:
    affected = source.GetAffected(agents.ToArray());
    if (affected.Count == 0)
        continue;
  26. Get a random leader and the set of scouts:
    int l = Random.Range(0, affected.Count);
    leader = affected[l];
    scouts = GetScouts(affected.ToArray(), l);
  27. Call the leader to its role if necessary:
    if (leader.Equals(scouts[0]))
        StartCoroutine(leader.Lead());
  28. Finally, inform the scouts about noticing the interest, in case it's relevant to them:
    foreach (AgentAwared a in scouts)
    {
        Interest i = source.interest;
        if (a.IsRelevant(i))
            a.Notice(i);
    }

How it works…

There is a list of interest sources that could get the attention of a number of agents in the world. Those lists are kept in a manager that handles the global update for every source, taking into account only the active ones.

An interest source receives the list of agents in the world and retrieves only the affected agents after a two-step process. First, it sets aside all the agents that are outside its action radius and then only takes into account those agents that can be reached with a finer (and more expensive) sensory-level mechanism.

The manager handles the affected agents, sets up a leader and scouts, and finally

There is more…

It is worth mentioning that the SensoryManager class works as a hub to store and organize the list of agents and the list of interest sources, so it ought to be a singleton. Its duplication could bring undesired complexity or behavior.

An agent's interest is automatically changed by the sensory manager using the priority values. Still, it can be reset when needed, using the public function Notice.

There is room for improvement still, depending on our game. The scout lists can overlap with each other, and it's up to us and our game to handle this scenario the best way we can. However, the system we built takes advantage of the priority values to make decisions.

See also

For further information on the train of thought behind this recipe, please refer to Steve Rabin's book, Game AI Pro.

..................Content has been hidden....................

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