apex-utility-ai-unity-survival-shooter

时间:2023-03-09 15:22:05
apex-utility-ai-unity-survival-shooter

The AI has the following actions available:

Action Function
Shoot Fires the Kalashnikov
Reload Reload the Kalashnikov with a full magazine
Use bomb Use a bomb
Use health pack Use a health pack to replenish health to full
Move to tactical position Moves to the best (highest scoring) position available
Move to power up Moves to a power up, with the intention to pick it up

Another challenge we wanted to address with the Utility AI was how to create human-like AI behavior. Computers work on a much higher frequency than the human brain, and consequently, they can make simple decisions much faster. This can lead to unrealistic capabilities for multitasking and actions per minute (APM). Thus, to simulate a human player, the AI can only perform one action per second. However, the AI can e.g. move simultaneously while performing other actions, just like a human player would do.

Design

The top-level design of the Utility AI in the Survival Shooter is quite simple, but has some specific features. We will explain these in the following sections.

Note that many new concepts are introduced and used throughout the tutorial. For definitions of these concepts and additional explanations, we suggest using the Utility AI scripting guide as a reference.

The overall design follows a simple model where the AI needs a memory, a sensory system, decision-making processes for each parallel subsystem, and then a link to execution of the actual actions that come out of the decision-making processes.

  • For the memory, we need to to create a Context.
  • For the sensory system, we need to create Scanners that gather critical information about the game level in real-time.
  • We have two parallel subsystems. One system handles actions by the AI, such as shooting and using power ups. The second system handles movement. Both systems implement Qualifiers with a selection of Scorers (specifically ContextualScorers).

Finally, the link to execution is handled through Actions, in many cases ActionWithOptions with a selection of OptionScorers. The actual execution of the action can be delegated to game code specifically designed for this, such as Unity’s NavMesh navigation systems, Weapon Components, etc.

Note

In some AIs there might be dependencies between subsystems. A sniper might not to be able to aim and move at the same time. A wizard might be interrupted in spell casting if hurt. A bee might stop gathering honey if disturbed. Communication between subsystems can happen in many different ways. However, often it is enough to communicate through the Context using e.g. enums or booleans. A more advanced model is using events across AIs.

To make the AI work, we need to implement three (3) Utility AIs:

Utility AI What does the AI do?
Scanner Scans for teddies and power ups and adds them to the Context of the AI
Action Decides what action the AI should execute
Movement Decides where the AI should move to

The Utility AIs work in parallel, and communicate through the Context.The idea behind this design is that the Scanner AI will update the Context with the position of enemies and powerups, as well as walkable positions. The Movement AI will use this information to decide on the best tactical movement options. The Action AI will use this information to decide on the best actions to execute. In addition to the information in the context provided by the scanner, the AIs also have access to the state of the Player, such as health, power-up and ammunition count.

Design of the AI

apex-utility-ai-unity-survival-shooter

apex-utility-ai-unity-survival-shooter

Hellephant – slow but strong

Implementing a Context for the AI

The first step is to implement a Context for the AI to store information to be used by the Utility AI.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public sealed class SurvivalContext : IAIContext
{
    public SurvivalContext(Player entity)
    {
        this.player = entity;
        this.enemies = new List<LivingEntity>();
        this.sampledPositions = new List<Vector3>();
        this.powerups = new List<IEntity>();
    }
    public Player player
    {
        get;
        private set;
    }
    public List<LivingEntity> enemies
    {
        get;
        private set;
    }
    public List<IEntity> powerups
    {
        get;
        private set;
    }
    public List<Vector3> sampledPositions
    {
        get;
        private set;
    }
}

The Context for the Survival Shooter is called SurvivalContext.

The Player is a MonoBehavior component that holds information about the player, such as speed, and holds references to other relevant components such as health, ammunition, and the number of powerups currently held by the AI.

The enemies and powerups are stored by a reference to their Entity component, which is a custom component that is implemented by all GameObjects in the game that are relevant for the AI.

The list of sampled positions used by the AI to store walkable positions in its neighbourhood can be used for decisions about tactical movement.

You can review the full Player class in the Survival Shooter demo project code. Here we will highlight the important parts of the code
The Player component implements the IContextProvider interface. This interface tells the Utility AIs on the Player GameObject to use this class to create a reference to the Context we just implemented.

1
2
3
4
public class Player : LivingEntity, IContextProvider
{
    ...
}

The Context is instantiated in the OnAwake() method

1
2
3
4
5
6
protected override void OnAwake()
{
    _context = new SurvivalContext(this);
    ...
}

The Utility AIs get access to the Context by calling the GetContext() method

1
2
3
4
public IAIContext GetContext(Guid aiId)
{
    return _context;
}

Hence, when the Player component and the UtilityAIComponent

are added to the GameObject, the AIs added to the UtilityAIComponent picks up the context from the Player component using the IContextProvider interface.
Once the Context and the IContextProvider have been implemented, we now need to develop the actual AIs.

Implementing the Scanner

The purpose of the scanner is to identify important information about the environment of the AI. In the survival shooter, we need to identify enemies, powerups and we need to sample potential destinations for tactical movement.

  1. Create a new AI:
    Tools => Apex => AI Editor => New.
    Or
    Right-click in the Project pane: Create => Apex => Utility AI.
  2. Give the AI a name – e.g. “PlayerScanner” and save the AI.
  3. Right Click => Add a Selector => Highest Score Wins
  4. Left click on the “Default Action” Qualifier, and enter “Scan” in the Name property in the Inspector.
  5. Right-Click on the newly renamed “Scan” Qualifier, and choose “Add Action”. Add a Composite Action.
  6. Next, we need to create the scanners as Actions in C# code and add these to the Composite Action.

The image below shows the scanner AI in the Utility AI Editor.

apex-utility-ai-unity-survival-shooter

The scanner consists of one Selector that does nothing but execute the Scan Qualifier once every second. The Scan Qualifier subsequently executes a Composite Action, which is a wrapper for multiple actions.

In the image below the scanning actions can be seen. We can see that we have a scanner for power ups, a scanner for enemies, and a scanner for positions. Only the position scanner has properties. In this case the position scanner will return a grid of points that the AI can use for e.g. evaluation of positions for tactical movement, We can see that it will evaluate positions in a 12 meters range, with a distance between each position of 1.5 meters.

apex-utility-ai-unity-survival-shooter

In the following we show how the scanners are implemented, and added to the AI.

The first scanner is the ScanForEntities scanner.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public sealed class ScanForEntities : ActionBase
{
    public override void Execute(IAIContext context)
    {
        var c = (SurvivalContext)context;
        var player = c.player;
        c.enemies.Clear();
        // Use OverlapSphere for getting all relevant colliders within scan range, filtered by the scanning layer
        var colliders = Physics.OverlapSphere(player.position, player.scanRange, Layers.enemies);
        for (int i = 0; i < colliders.Length; i++)
        {
            var col = colliders[i];
            if (col.isTrigger)
            {
                continue;
            }
            var enemy = EntityManager.instance.GetLivingEntityByGameObject(col.gameObject);
            if (enemy == null)
            {
                continue;
            }
            c.enemies.Add(enemy);
        }
    }
}

As we want to run the scanner as an action in the AI, to be executed at certain intervals, the Scanner inherits from the ActionBase class. The scanner starts by caching the reference to the Context and subsequently clearing it of previous observations. Subsequently, the scanner scans the context using an OverlapSphere in the enemies layer. The list of returned colliders are iterated and their entity is retrieved from the EntityManager. Provided an entity exists, the enemies are added to the list of enemies in the context.

The second scanner we implement is the scanner for power ups.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public override void Execute(IAIContext context)
{
    var c = (SurvivalContext)context;
    var player = c.player;
    var powerups = c.powerups;
    powerups.Clear();
    // Use OverlapSphere for getting all relevant colliders within scan range, filtered by the scanning layer
    var colliders = Physics.OverlapSphere(player.position, player.scanRange, Layers.powerUps);
    for (int i = 0; i < colliders.Length; i++)
    {
        var col = colliders[i];
        var powerup = EntityManager.instance.GetEntityByGameObject(col.gameObject);
        if (powerup == null)
        {
            continue;
        }
        powerups.Add(powerup);
    }
}

The scanner for power-ups works in the same way as the scanner for enemies, except that it scans in the power up layer.

The final scanner we implement is the scanner for positions. The purpose of this scanner is to sample a grid of positions around the AI, which can be prioritized for tactical movement.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public sealed class ScanForPositions : ActionBase
{
    [ApexSerialization]
    public float samplingRange = 12f;
    [ApexSerialization]
    public float samplingDensity = 1.5f;
    public override void Execute(IAIContext context)
    {
        var c = (SurvivalContext)context;
        var player = c.player;
        c.sampledPositions.Clear();
        var halfSamplingRange = this.samplingRange * 0.5f;
        var pos = player.position;
        for (var x = -halfSamplingRange; x < halfSamplingRange; x += this.samplingDensity)
        {
            for (var z = -halfSamplingRange; z < halfSamplingRange; z += this.samplingDensity)
            {
                var p = new Vector3(pos.x + x, 0f, pos.z + z);
                NavMeshHit hit;
                if (NavMesh.SamplePosition(p, out hit, this.samplingDensity * 0.5f, NavMesh.AllAreas))
                {
                    c.sampledPositions.Add(hit.position);
                }
            }
        }
    }
}

The scanner for positions samples a grid of positions around the AI and adds these positions to the samplePositions list in the context. For each position, the scanner gets the closes position on the NavMesh, provided a positions exists within a range of half the sampling density. The samples positions are to be used by the AI for tactical movement.

When the three (3) scanning Actions have been created, go back to the Utility AI Editor and add them to the Composite Action we created before.

  1. Left-click on the Composite Action, and go to the Inspector.
  2. Click the “+” sign, and add the newly created three (3) scanner Actions; “ScanForPowerUps”, “ScanForEnemies” and “ScanForPositions”
  3. Click “Save” in the AI Editor

The scanner Utility AI is now complete, and can be added to the Player prefab.

  1. Locate the Player prefab in the Project pane => SurvivalShooter => Prefabs => Players => PlayerEmpty
  2. Add a Utility AI Component, click the “+” sign, and Select the “PlayerScanner” AI. The interval is set to one (1) second as default.

The AI is now scanning its environment for powerups, enemies and positions every second.

Implementing Tactical Movement

The positions sampled by the ScanForPositions scanner are used for implementing tactical movement. The idea is that the AI can score each position based on different criteria that makes up a good position for the AI to be at. These criteria are for example such as whether the position is not too close to any zombie teddy, has line of fire to a zombie teddy and is near a power-up. By adding up the scores for each criterion, we select the highest scoring position and use this as our preferred position.

Implementing this type of tactical reasoning is not trivial, as some options can be mutually exclusive. A position that is close to a powerup should not be chosen if it is at the same time close to a zombie teddy, as this might put the AI in peril. However, in some cases we might prioritize moving to the powerup despite the peril, if the alternative is worse. Consequently, the criteria for good tactical positions are not binary. The second problem with designing good tactical reasoning is that the number of parameters to take into consideration can quickly explode in numbers. We need to take in several parameters such as the proximity of multiple zombie teddies, the proximity of powerups, lines of fire, safe ranges, etc. These parameters will constantly change and we cannot foresee the number of different scenarios that can occur. We need a decision-making process that is rather robust and can also handle situations we have not foreseen reasonably well. Utility-based decision-making provides a good and intuitive solution for designing tactical decision-making.

Start by creating a new AI.

  1. Create a new AI: Tools => Apex => AI Editor => New – or
    Right-click in the Project pane: Create => Apex => Utility AI.
  2. Give the AI a name – e.g. “PlayerMove” and save the AI.
  3. Right Click => Add a Selector => Highest Score Wins. Rename this to “Move Or Idle” in the Inspector.
  4. Right-click in the Selector, and choose “Add Qualifier (Sibling)”, choose “Sum of Children”, rename this to “Has Enemies”
  5. Left-click on the “Default Action” Qualifier, and choose “Add Action”, select the “Empty Action” action, and rename this to “Idle” in the Inspector.
  6. Right-click on an empty part of the Utility AI Editor, choose Add Selector, choose Highest Score wins. Rename this to “Tactical Move”.
  7. Holding down the left mouse button, drag a line from the “Has Enemies” Qualifier on the “Move Or Idle” Selector to the  new selector.
  8. Rename the “Default Action” on the new Selector to “Tactical Move”.
  9. Right-click on the “Tactical Move” qualifier and choose “Add Qualifier (sibling)”, choose “All or Nothing”. Rename this new Qualifier to “Move To Power Up”.
  10. Save the AI.

The framework for the AI is now complete. We now need to design and add the Scorers and Actions to the AI.

apex-utility-ai-unity-survival-shooter

Note

Adding an Idle action is not really necessary as a Qualifier with no action is idle by default.
We do it here for demonstration purposes to accentuate the fact that it does nothing.

Overview of the Movement AI.

The idea is that when the AI has no enemies it will be idle. When there are enemies in sight, the AI will move to the best tactical destination or pick up a power-up.
The first decision happens in the Move Or Idle Selector, which is the Root Selector. The decision is to either move or idle based on the HasEnemies Qualifier. This is a simple Sum of Children qualifier with a HasEnemies ContextualScorer.

apex-utility-ai-unity-survival-shooter

Note

Instead of using Scorers, the Qualifier could also itself implement the functionality of the HasEnemies ContextualScorer. This can be is useful for simple qualifiers, if you want to limit the number of classes in your project and if the scoring functionality does not need to be reused by other Qualifier.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public sealed class HasEnemies : ContextualScorerBase
{
    [ApexSerialization]
    public bool not = false;
    public override float Score(IAIContext context)
    {
        var c = (SurvivalContext)context;
        if (c.enemies.Count == 0)
        {
            return this.not ? this.score : 0f;
        }
        return this.not ? 0f : this.score;
    }
}

The HasEnemies scores gets the context argument and casts this to the SurvivalContext. Subsequently, it gets the number of entries in the enemies list in the context via the Count property, and returns the score if the count is not zero.

We then add the new Scorer to the “HasEnemies” Qualifier:

  1. Go to or open the “PlayerMove” AI.
  2. Left-click on the “HasEnemies” Qualifier. In the Inspector click the “+” sign, and find and add the HasEnemies Scorer. Set the score to ten (10).

The first Selector in the PlayerMove AI is now complete. If there are enemies in the Context, the HasEnemies ContextualScorer returns the score of 10 and the AI will execute the “TacticalMove” Selector.

TacticalMove

The TacticalMove Selector consists of the MoveToPowerUp and TacticalMove Qualifiers. The MoveToPowerUp controls the AI’s behavior for picking up power ups. The TacticalMove Qualifier shows how tactical movement can be implemented using utility-based scoring of options.

The TacticalMove Qualifier is the DefaultAction, and is executed if the MoveToPowerUp is not selected. The TacticalMove Qualifier executes the MoveToBestPosition, deriving from the ActionWithOptions base class. The ActionWithOptions class allows us to pass a list of objects to be evaluatued. In this case, we want to pass a list positions that the AI can evaluate, and pick the top scoring position as the destination for the AI.

First we need to create the MoveToBestPositionAction. This action selects the best destination to move to, and subsequently asks the Player GameObject to move.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public sealed class MoveToBestPosition : ActionWithOptions
{
    public override void Execute(IAIContext context)
    {
        var c = (SurvivalContext)context;
        var best = this.GetBest(context, c.sampledPositions);
        if (best.sqrMagnitude == 0f)
        {
            // no valid position found
            return;
        }
        c.player.MoveTo(best);
    }
}

The MoveToBestPosition retrieves the positions sampled by the ScanForPositions scanner, and gets the best scoring position via the GetBest() method. Notice that the type of the list that the GetBest() method takes is set in the <T> type argument in the ActionWithOptions<T>. In this case, the list must be a list of Vector3s. To score each position, the ActionWithOptions<T> has a list of OptionScorers attached in the editor. The AI then iterations through the list of Vector3s and adds up the score for each Optionscorer to each entry. In this case, the highest scoring entry is returned as the best position.

Now we need to add the class we just created to the AI.

Open the “PlayerMove” AI.

  1. Right-click on the TacticalMove Qualifier.
  2. Choose Add Action, choose “MoveToBestPosition”.

Next we need to create the OptionScorers that allow the AI to select the best destination to move to. These Scorers are then added to the “MoveToBestPosition” action in the AI Editor.
We create the following OptionScorers:

OptionScorer What does the AI do? Why is it useful?
PositionProximityToSelf Scores positions higher that are closer to the position of the AI. Ensures that positions that are close to the AI are prioritized over positions that are further away. To prioritize positions, minimize movement and avoid jitter.
ProximityToNearestEnemy Scores positions higher that are close to the desired range to the enemy closest to the AI. Ensures that the AI prioritizes moving within preferred attack range to the nearest enemy and dealing with this threat before dealing with threats further away.
OverRangeToClosestEnemy Scores positions beyond a certain range to the closest enemy with a fixed score. Ensures that they AI stays outside the danger zone of the closest enemy. Works in conjunction with OverRangeToAnyEnemy, but gives priority to avoiding the closest threat first. This is critical so that two (2) distant threats do not cancel out a closer - and thus bigger - threat.
ProximityToClosestPowerUp Scores positions higher that are closer to a power up. Only scores relative to the closest powerup. Ensures that the AI prioritizes positions that bring it closer to powerups.
LineOfSightToAnyEnemy Scores positions that have line of sight to enemies. Ensures that the AI prioritizes positions where it can actually shoot at enemies. This is to avoid the AI engaging enemies on long ranges, instead of hiding while the enemies approach.
LineOfSightToClosestEnemy Scores positions based on whether they have line of sight to the enemy closest to the AI. Gives priority to the closest threat, and ensures that this is handled first and as early as possible.
OverRangeToAnyEnemy Scores positions beyond a certain range to any enemy with a fixed score. Ensures that they AI stays outside the danger zone of all enemies. The fixed score ensures that staying outside of this zone can be prioritized over all other decisions, as keeping safe is the key priority.
OverRangeToAnyEnemySpawner Scores positions beyond a certain range to any enemy spawner. This is to ensure that the AI does not linger around enemy spawners, where it can be surprised by the sudden emergence of an enemy.
ProximityToPlayerSpawner Scores positions higher based on their proximity to the original spawning position of the AI. This is to ensure that the AI prioritizes the middle of the room where it was spawned. The AI will still wander in e.g. the search for power-ups or fleeing enemies, but it will prioritize returning to the middle of the room where it has maximum freedom of navigation.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
public sealed class PositionProximityToSelf : CustomScorer<Vector3>
{
    public override float Score(IAIContext context, Vector3 position)
    {
        var c = (SurvivalContext)context;
        var range = (position - c.player.position).magnitude;
        return Mathf.Max(0f, (this.score - range));
    }
}

The PositionProximityToSelf class calculates the range between the player and the position. It then deducts the range from the score, and returns the difference, or zero (0) if the difference is negative.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public sealed class ProximityToNearestEnemy : CustomScorer<Vector3>
{
    [ApexSerialization]
    public float desiredRange = 8f;
    public override float Score(IAIContext context, Vector3 position)
    {
        var c = (SurvivalContext)context;
        var enemies = c.enemies;
        var count = enemies.Count;
        if (count == 0)
        {
            return 0f;
        }
        var nearest = Vector3.zero;
        var shortest = float.MaxValue;
        for (int i = 0; i < count; i++)
        {
            var enemy = enemies[i];
            var distance = (position - enemy.position).sqrMagnitude;
            if (distance < shortest)
            {
                shortest = distance;
                nearest = enemy.position;
            }
        }
        if (nearest.sqrMagnitude == 0f)
        {
            return 0f;
        }
        var range = (position - nearest).magnitude;
        return Mathf.Max(0f, (this.score - Mathf.Abs(this.desiredRange - range)));
    }
}

The ProximityToNearestEnemy class finds the nearest enemy to the position, and scores the position according to its distance from the enemy relative to the desired range.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public sealed class OverRangeToClosestEnemy : CustomScorer<Vector3>
{
    [ApexSerialization]
    public float desiredRange = 14f;
    public override float Score(IAIContext context, Vector3 position)
    {
        var c = (SurvivalContext)context;
        var player = c.player;
        var enemies = c.enemies;
        var count = enemies.Count;
        if (count == 0)
        {
            return 0f;
        }
        var nearest = Vector3.zero;
        var shortest = float.MaxValue;
        for (int i = 0; i < count; i++)
        {
            var enemy = enemies[i];
            var distance = (player.position - enemy.position).sqrMagnitude;
            if (distance < shortest)
            {
                shortest = distance;
                nearest = enemy.position;
            }
        }
        var range = (position - nearest).magnitude;
        if (range > desiredRange)
        {
            return this.score;
        }
        else
        {
            return 0;
        }
    }
}

The OverRangeToClosestEnemy class scores the position if it is above a certain range to the nearest enemy.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public sealed class ProximityToClosestPowerUp : CustomScorer<Vector3>
{
    [ApexSerialization]
    public float multiplier = 1f;
    public override float Score(IAIContext context, Vector3 position)
    {
        var c = (SurvivalContext)context;
        var powerups = c.powerups;
        var count = powerups.Count;
        if (count == 0)
        {
            return 0f;
        }
        var closest = Vector3.zero;
        var shortest = float.MaxValue;
        for (int i = 0; i < count; i++)
        {
            var powerup = powerups[i];
            var distance = (position - powerup.position).sqrMagnitude;
            if (distance < shortest)
            {
                shortest = distance;
                closest = powerup.position;
            }
        }
        var range = (position - closest).magnitude;
        return Mathf.Max(0f, (this.score - range) * multiplier);
    }
}

The ProximityToClosestPowerUp class calculates the range to the nearest power up and deducts this from the score. The difference is then multiplied with the multiplier.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public sealed class LineOfSightToAnyEnemy : CustomScorer<Vector3>
{
    public override float Score(IAIContext context, Vector3 position)
    {
        var c = (SurvivalContext)context;
        var enemies = c.enemies;
        var count = enemies.Count;
        if (count == 0)
        {
            return 0f;
        }
        for (int i = 0; i < count; i++)
        {
            var enemy = enemies[i];
            var dir = enemy.position - position;
            var range = dir.magnitude;
            var ray = new Ray(position + Vector3.up, dir);
            if (!Physics.Raycast(ray, range, Layers.cover))
            {
                return this.score;
            }
        }
        return 0;
    }
}

The LineOfSightToAnyEnemy class scores the position if there is line of sight to any enemy – i.e. any of the enemies in the enemies list in the context are not covered by an object in the “cover” layer – from the position.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public sealed class LineOfSightToClosestEnemy : CustomScorer<Vector3>
{
    public override float Score(IAIContext context, Vector3 position)
    {
        var c = (SurvivalContext)context;
        var player = c.player;
        var enemies = c.enemies;
        var count = enemies.Count;
        if (count == 0)
        {
            return 0f;
        }
        var nearest = Vector3.zero;
        var shortest = float.MaxValue;
        for (int i = 0; i < count; i++)
        {
            var enemy = enemies[i];
            var distance = (player.position - enemy.position).sqrMagnitude;
            if (distance < shortest)
            {
                shortest = distance;
                nearest = enemy.position;
            }
        }
        var dir = (nearest - position);
        var range = dir.magnitude;
        var ray = new Ray(position + Vector3.up, dir);
        if (!Physics.Raycast(ray, range, Layers.cover))
        {
            return this.score;
        }
        return 0f;
    }
}

The LineOfSightToClosestEnemy class finds the nearest enemy and scores the position if there is line of sight to this enemy – i.e. if the enemy is not covered by an object in the “cover” layer.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public sealed class OverRangeToAnyEnemy : CustomScorer<Vector3>
{
    [ApexSerialization]
    public float desiredRange = 14f;
    public override float Score(IAIContext context, Vector3 position)
    {
        var c = (SurvivalContext)context;
        var player = c.player;
        var enemies = c.enemies;
        var count = enemies.Count;
        if (count == 0)
        {
            return 0f;
        }
        var sqrDesiredRange = desiredRange * desiredRange;
        for (int i = 0; i < count; i++)
        {
            var enemy = enemies[i];
            var dirPlayerToEnemy = (enemy.position - player.position).OnlyXZ();
            var dirPositionToEnemy = (enemy.position - position).OnlyXZ();
            //all positions behind the enemy or closer than the desired range are not of interest
            if (Vector3.Dot(dirPlayerToEnemy, dirPositionToEnemy) < 0f || dirPositionToEnemy.sqrMagnitude < sqrDesiredRange)
            {
                return 0f;
            }
        }
        return this.score;
    }
}

The OverRangeToAnyEnemy class scores all positions that are not covered by the half plane from the line orthogonal to the direction from the enemy to the player with a length equal to the range property and all the way from to the enemy and behind the enemy.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public sealed class OverRangeToAnyEnemySpawner : CustomScorer<Vector3>
{
    [ApexSerialization]
    public float desiredRange = 14f;
    public override float Score(IAIContext context, Vector3 position)
    {
        var enemySpawPoints = Blackboard.enemySpawnPoints;
        if (enemySpawPoints == null || enemySpawPoints.Length == 0)
        {
            return 0;
        }
        for (int i = 0; i < enemySpawPoints.Length; i++)
        {
            var sqrRange = (position - enemySpawPoints[i]).sqrMagnitude;
            if (sqrRange < desiredRange * desiredRange)
            {
                return 0;
            }
        }
        return this.score;
    }
}

The OverRangeToAnyEnemySpawner class scores the position if the range to the nearest spawner is over the desiredRange. Notice that this class uses the Blackboard to get information about spawnpoints, as these spawnpoints are available to all AIs.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
public sealed class ProximityToPlayerSpawner : CustomScorer<Vector3>
{
    [ApexSerialization]
    public float multiplier = 1f;
    public override float Score(IAIContext context, Vector3 position)
    {
        var c = (SurvivalContext)context;
        var range = (position - c.player.spawnPoint).magnitude;
        return Mathf.Max(0f, (this.score - range) * multiplier);
    }
}

The ProximityToPlayerSpawner calculates the range to the player’s original spawn point and deducts this from the score. The difference is then multiplied with the multiplier.

Once the Scorers for the TactivalMove Qualifier are complete, we need to add them to the AI.

Open the “PlayerMove” AI:

  1. Left-click on the “MoveToBestPosition” Action under the TacticalMove Qualifier in the TacticalMove Selector.
  2. In the Inspector, click on the “+” sign and add the Scorers you just created.
    1. PositionProximityToSelf
    2. ProximityToNearestEnemy
    3. OverRangeToClosestEnemy
    4. ProximityToClosestPowerUp
    5. LineOfSightToAnyEnemy
    6. LineOfSightToClosestEnemy
    7. OverRangeToAnyEnemy
    8. OverRangeToAnyEnemySpawner
    9. ProximityToPlayerSpawner

Next, we need to set the scores for each of the OptionScorers. In the image below you can see the scores for the PlayerMove AI.

apex-utility-ai-unity-survival-shooter

As we will further elaborate on in the following, the relationship between the scores for each of the OptionScorers affect the how the AI will choose between different requirements for its tactical movement, and it will prioritize different and sometimes conflicting requirements.

Tip

The values assigned to the OptionScorers can be tweaked to simulate different tactical behaviors. Scoring positions in safe range lower and positions with line of fire higher gives a more aggressive behavior. Scoring positions near powerups higher, emphasises gathering of power-ups rather than staying safe or tactical maneuvering. Different playing styles can thus be simulated.

The following table explains each score:

OptionScorer What is the reason for the scores?
PositionProximityToSelf The score is set to ten (10) meaning that positions within 10 meters are scored. Returns the score deducted by the range from the AI to the position.
ProximityToNearestEnemy The desired range is set to eight (8) meaning that this range around the nearest enemy is prioritized. Being on this circle scores fifty (50) points.
OverRangeToClosestEnemy The range is set to five (5) and the score to a hundred (100). The AI will thus prioritize positions beyond range 5 from the closest enemy.
ProximityToClosestPowerUp The score is set to twenty (20) and the multiplier is set to two (2) this means that cells that have a power up in them will receive a score of forty (40) and this score will decrease with two (2) for every unit of distance from the power-up. The higher score is designed to override either the PositionProximityToSelf and ProximityToPlayerSpawner scorers, to prioritize picking up a power up if they are near the AI.
LineOfSightToAnyEnemy The score is set to fifty (50) meaning that any position with line of sight to an enemy is scored with 50 points to ensure that positions which allow the AI to fire are prioritized.
LineOfSightToClosestEnemy The score is set to to fifty (50) meaning that line of sight to main threat is scored with 50 points. As this is cumulative with the score of LineOfSightToAnyEnemy, the position that enables the AI to fire at the closest threat is always prioritized.
OverRangeToAnyEnemy The score is set to fifty (50) and the range to five (5). It ensures that the AI moves away from any enemies, and does not suddenly want to move through enemy lines to reach a position on the other side. The AI will thus not allow itself to be encircled.
OverRangeToAnyEnemySpawner The score is set to five hundred (500). This is to ensure that no combination of other scorers will allow the AI to move into a spawner, where it with great certainty will be in great danger from spawning enemies.
ProximityToPlayerSpawner The score is set to a hundred (100) meaning that disregarding where the AI is on the map, is will always prioritize moving towards its spawning position if it has no other priorities.

Each of the OptionScorers influence which positions are attractive to move to for the AI, and which are not. In the following section, we will go through each OptionScorer in the Survival Shooter and show how it affects the priority between different positions around the AI.

PositionProximityToSelf

The image below shows how the PositionProximityToSelf scorer scores positions closer to the AI with the highest values. The screenshot below shows how the positions are scored. The green spheres are the highest scoring and the red spheres the lowest scoring.
The highest scoring position is marked by the cyan sphere in the middle right where the AI is located. PositionProximityToSelf will thus make the AI choose positions that are close to its current position.

apex-utility-ai-unity-survival-shooter

ProximityToNearestEnemy

The image below shows how ProximityToNearestEnemy scores a circle around the enemy in the range set in the scorer. The scores decrease on each side of the ring. Notice how several positions on the ring score fifty (50). The highest scoring position is whatever position is first in the list of positions with equal scores. This can cause oscillation between highest scoring positions.

apex-utility-ai-unity-survival-shooter

Combining PositionProximityToSelf and ProximityToNearestEnemy

The image below shows how combining the PositionProximityToSelf and ProximityToNearestEnemy scorers ensures that the AI selects the position that is closest to the AI and on the preferred range around the enemy. Combining the scores provides the optimal position for the AI to be in, to attack the enemy from a safe distance, while minimizing the AIs movement from its current position.

apex-utility-ai-unity-survival-shooter

OverRangeToClosestEnemy

The image below shows how the OverRangeToClosestEnemy ensures that the AI selects any position not in the danger zone around the enemy. This is very useful to keep the AI out of harm’s way, and if the score is set high enough it can override other scorers to ensure that the AI always prioritizes keeping a safe distance to enemies, before it starts considering the best position to attack from.

apex-utility-ai-unity-survival-shooter

Combining OverRangeToClosestEnemy, PositionProximityToSelf and ProximityToNearestEnemy

The image below shows that combining OverRangeToClosestEnemy with PositionProximityToSelf and ProximityToNearestEnemy identifies a best position that takes the AI away from the enemy to a position out of the danger zone, at the preferred range to the enemy, and yet closest to the current position to minimize travel.

apex-utility-ai-unity-survival-shooter

We can see how the zone around the zombie teddy is red, to prioritize positions outside of the danger zone. The light blue just below to the right our little hero is the highest scoring position, which he is moving towards.

OverRangeToAnyEnemy

The image below shows how the positions are scored when there are multiple enemies, using the OverRangeToAnyEnemy scorer. The OverRangeToAnyEnemy shows how the AI will select positions that lead it away from the enemies, and thus avoid being pinched between the two enemies.

apex-utility-ai-unity-survival-shooter

Current OptionScorers Combined

The image below shows how the current scorers combine. As can be seen, the pink zombie teddy is closest enemy and thus considered the greatest threat. However, the AI also wisely keeps out of the danger zone of the cyan zombie bunny, and moves to positions that are closest to its current position, but still outside of the danger zone of both enemies.

apex-utility-ai-unity-survival-shooter

LineOfSightToClosestEnemy

The image below shows the scores for line of sight to the closest enemy, which is the cyan zombie bunny. Only the positions covered by the spinning top are scored with zero (0).

apex-utility-ai-unity-survival-shooter

LineOfSightToAnyEnemy

The image below shows the scores for line of sight to any enemy. As there are no positions that do not have line of sight to at least one enemy they all have a score above zero. However, this OptionScorer is relevant for ensuring that AI doesn’t hide behind obstacles where it cannot see any enemies.

apex-utility-ai-unity-survival-shooter

ProximityToClosestPowerUp

The image below shows how the ProximityToClosestPowerUp scorer scores positions closer to the health power-up with the highest values. The highest scoring position is marked by the cyan sphere close to the power-up. This scorer thus entices the AI to choose a position close to the power-up, if possible and if it’s not overridden by higher priority scores.

apex-utility-ai-unity-survival-shooter

Combining all scores

The image below shows how the AI evaluates the positions based on all scores combined.
The AI prioritizes positions away from the approaching enemy and outside of the danger zone. In addition, the AI prioritizes positions that are as close as possible to its current position to minimize movement. If there are other priorities, such as health power up, the AI will be biased towards moving towards these.

apex-utility-ai-unity-survival-shooter

Combining the scorers thus ensures that the AI can reasonably prioritize where to move under many different scenarios. Furthermore, the AI can reasonably prioritize between self-preservation, finding a good attack position, and choose positions that keeps it close to power-ups.

MoveToPowerUp Qualifier

Sometimes it is not enough to score positions to get the desired behavior. Certain decisions can get crowded out by other scorers, or overriding certain scorers can create unwanted behavior. We see this scenario in the case of power-ups. Even though we score proximity to power-ups as part of the tactical positioning, other scorers are influencing the ability of the AI to move to the exact position of the power-ups. Solving this by scoring power-ups higher influences the other priorities for our little hero, the AI, such as staying outside of the danger zone of the zombie teddies. We can thus get into a situation, where solving the problem of moving to powerups creates a problem for ensuring self-preservation.

One way of solving this problem, is to implement rules that override the tactical move behavior under certain circumstances. In this case, we decide that if there are no zombie teddies in a certain range of the AI, and a power-up is nearby, we can override the tactical position to pick up the power-up instead. It is a calculated risk, but one that players – for example – might be very willing to take.

For this purpose we have added a MoveToPowerUp Qualifier that is selected when certain conditions are met. These conditions are implemented as two ContextualScorers called HasEnemiesInRange and HasPowerUpsInRange. The Qualifier is an ‘All or Nothing’ Qualifier, meaning that all OptionScorers need to score above the threshold, before the Qualifier returns its score. Otherwise it returns zero (0).

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public sealed class HasEnemiesInRange : ContextualScorerBase
{
   [ApexSerialization]
   public float range = 3f;
   [ApexSerialization]
   public bool not;
   public override float Score(IAIContext context)
   {
       var c = (SurvivalContext)context;
       var enemies = c.enemies;
       var count = enemies.Count;
       for (int i = 0; i < count; i++)
       {
           var enemy = enemies[i];
           var sqrDist = (enemy.position - c.player.position).sqrMagnitude;
           if (sqrDist <= range * range)
           {
               if (not)
               {
                   return 0f;
               }
               return this.score;
           }
       }
       if (not)
       {
           return this.score;
       }
       return 0f;
   }
}

The HasEnemiesInRange class returns the score to the Qualifier if there are any enemies in the desiredRange.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public sealed class HasPowerUpsInRange : ContextualScorerBase
{
    [ApexSerialization]
    public float range = 3f;
    [ApexSerialization]
    public bool not;
    public override float Score(IAIContext context)
    {
        var c = (SurvivalContext)context;
        var powerups = c.powerups;
        var count = powerups.Count;
        for (int i = 0; i < count; i++)
        {
            var powerup = powerups[i];
            var sqrDist = (powerup.position - c.player.position).sqrMagnitude;
            if (sqrDist <= range * range)
            {
                if (not)
                {
                    return 0f;
                }
                return this.score;
            }
        }
        if (not)
        {
            return this.score;
        }
        return 0f;
    }
}

The HasPowerUpsInRange class return the score if there are power ups in the desired range.

We now need to add the two ContextualScorers to the MoveToPowerUp Qualifer.

Open the PlayerMove AI:

  1. Left-click on the MoveToPowerUp Qualifier.
  2. Set the Threshold to 100.
  3. In the Inspector click on the “+” sign and add the HasEnemiesInRange Selector. Set the Not boolean to TRUE. Set the Range to 15. Set the Score to 200.
  4. Click the “+” sign and add the HasPowerUpsInRange Selector. Set the range to 8 and the Score to 200.

apex-utility-ai-unity-survival-shooter

The ‘Not’ boolean for the HasEnemiesInRange OptionsScorer is true. This means that HasEnemiesInRange will return its score if its criterion is not met. I.e. if there are no zombie teddies within range 15. For HasPowerUpsInRange it will return its score if there is a power up within range 8. Under these conditions the AI can take the chance and pick up the power-up. By only requiring zombie teddies to be out of range, instead of not visible at all, this allows the AI to pick power-ups while still fighting the zombie teddies. Setting the range to zombie teddies to double the range of the power-ups allows the AI to pick up power-ups and get safely out of range of the zombie teddies, even in a worst-case scenario where the power-up is exactly between the AI and the zombie teddy, given that the AI moves faster than the zombie teddies. The behavior should thus be safe, but still give rise to some daring situations where the AI snatches the power-ups right from under the nose of the zombie teddies.

Now we need to add the Action that tells the AI to move.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public sealed class MoveToClosestPowerUp : ActionWithOptions<IEntity>
{
    public override void Execute(IAIContext context)
    {
        var c = (SurvivalContext)context;
        var best = this.GetBest(context, c.powerups);
        if (best == null)
        {
            // no valid position found
            return;
        }
        c.player.MoveTo(best.position);
    }
}

The MoveToClosestPowerUp class works very similarly to the MoveToBestPosition. But the type is IEntity instead of Vector3. The GetBest() method thus takes a List as argument, which in this case if the list of powerups from the SurvivalContext. It then returns the highest scoring powerup.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
public sealed class PowerupProximityToSelf: CustomScorer<IEntity>
{
    [ApexSerialization]
    public float multiplier = 1f;
    public override float Score(IAIContext context, LivingEntity entity)
    {
        var c = (SurvivalContext)context;
        var distance = (entity.position - c.player.position).magnitude;
        return Mathf.Max(0f, (this.score - distance) * this.multiplier);
    }
}

Open the PlayerMove AI:

  1. Right-click on the “MoveToPowerUp” Qualifier. Choose Add Action.
  2. Add the “MoveToClosestPowerUp” Action.
  3. Left-click on the “MoveToClosestPowerUp”. In the Inspector, click the “+” sign, and add the ”PowerupProximityToSelf“ OptionScorers.

When playing the demo, you will see how the AI retreats away from zombie teddies while firing at them. If two zombie teddies are approaching, the lAI will prioritize positions that are outside of their danger zones, thus retreating away from the areas crowded by zombie teddies. If possible, the AI will prioritize moving towards power-ups, so that when the zombie teddies are eliminated, the AI can safely pick them up. These behaviors allow the AI to wander around the room, picking up power-ups while eliminating zombie teddies without getting too close to them. This behavior is not intended, but is emergent. It gives a natural feel to his behavior.

Finally, you need to add the PlayerMove AI to the UtilityAIComponent on the AI Gameobject, just like the PlayerScanner AI was added.

Tip

The idea of calculating scores to positions can be used for many other decision-making processes involving a high number of potential choices. It can for example be used to select positions for placing a building in RTS games, it can be used to help the AI make decisions about which spells to throw, which strategies to implement, or how to respond to player propositions.

Implementing Actions

The AIs actions are executed in parallel with movement. The Act Or Idle Root Selector is similar to the Root Selector in the Movement AI. If the AI has no enemies, it will be idle.

However, if there are enemies, the AI will decide what action to execute. The AI can be seen in the image below.

The SelectAction Selector is a First Score Wins Selector. This means that the first Qualifier that scores more than zero (0) executes its action.

apex-utility-ai-unity-survival-shooter

The following qualifiers and actions need to be implemented for the AI:

Qualifier Actions What does it do?
Use Health Use Band Aid Fully heals the AI by consuming one Band Aid.
Throw Bomb Throw Bomb Throws a bomb that deals area damage to all enemies around the AI, while consuming one bomb.
Reload Gun Reload Gun Fully replenishes the magazine of the gun.
Fire Gun Set Target and Fire Gun Sets one of the visible enemies as target and fires the gun at this enemy.
Default Action Idle No action is executed.

The Action AI is executed every second. This means that the AI can execute a maximum of one action per second. There will thus be situations where the AI has to prioritize actions, such as prioritising between healing, firing, throwing a bomb or reloading.

To evaluate each action, each Qualifier has a list of ContextualScorers.

Use Health

The UseHealth Qualifier is an All or Nothing Qualifier with a threshold of two hundred (200). This means that all ContextualScorers must result in a score over the threshold for it to score. In this case there are two ContextualScorers.

ContextualScorer What does it do?
Health Below Threshold If the health of the AI is below thirty five (35), a score of two hundred (200) is returned.
Has Band Aids If the AI has at least one (1) Band Aid a score of two hundred (200) is returned.

Hence, if the AI has Band Aids and its health is below 35 it will use a Band Aid to restore itself to full health. The reason for putting the UseHealth at the top of the hierarchy is that if the AI gets down to 35 health, it is very close to losing. It must then prioritize health above anything, as one unfortunate move from the enemies could otherwise finish it off. Setting the threshold to 35 also indicates that Band Aids are sparse, and should be used sparingly. The AI should thus wait until each Band Aid can be used to great effect, but not wait until it is too late. A threshold of 35 and top priority of this Qualifier in the Selector is a good compromise.

apex-utility-ai-unity-survival-shooter

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public sealed class HealthBelowThreshold : ContextualScorerBase
{
  [ApexSerialization]
  public bool not = false;
  [ApexSerialization]
  public float threshold = 30;
  public override float Score(IAIContext context)
  {
      var c = (SurvivalContext)context;
      if (c.player.currentHealth < threshold)
      {
          if (not)
          {
              return 0f;
          }
          return this.score;
      }
      if (not)
      {
          return this.score;
      }
      return 0f;
  }
}

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public sealed class HasBandAids : ContextualScorerBase
{
    [ApexSerialization]
    public bool not = false;
    public override float Score(IAIContext context)
    {
        var c = (SurvivalContext)context;
        if (c.player.currentBandAids <= 0)
        {
            return this.not ? this.score : 0f;
        }
        return this.not ? 0f : this.score;
    }
}

Throw Bomb

The Throw Bomb Qualifier is an All or Nothing Qualifier with a threshold of one hundred (100). This means that all ContextualScorers must result in a score over the threshold for it to score. In this case there are two ContextualScorers.

ContextualScorer What does it do?
Has Enemies in Range If there are enemies within a range of three (3) meters, a score of one hundred and ten (110) is returned.
Can Throw Bomb If the AI has at least one (1) Bomb and has not used a bomb the last second (default), a score of one hundred and ten (110) is returned.

Hence, if the AI has bombs and any enemy is within three (3) meters of the AI, the AI will throw a bomb. Remembering that the range for avoiding enemies in the movement AI was five (5) meters, the idea is that the AI should rarely get into a situation where it has to use bombs. However, situations where enemies do get within three (3) meters must be situations where the AI has not managed to move away or cannot move away from the enemies – i.e. emergency situations, where the AI could be cornered, overwhelmed by the number of enemies, or could be surprised by an enemy suddenly emerging halfway inside the danger zone.

apex-utility-ai-unity-survival-shooter

The HasEnemiesInRange class used in the MoveToPowerUp Qualifier can be reused here.

In addition, create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public sealed class CanThrowBomb : ContextualScorerBase
{
    public override float Score(IAIContext context)
    {
        var c = (SurvivalContext)context;
        if (c.player.canThrowBomb)
        {
            return this.score;
        }
        return 0f;
    }
}

The CanThrowBomb class checks the canThrowBomb property and returns the score if this is true.

We now need to implement the ThrowBomb Action, to ensure that the AI can actually throw a bomb.

Create the following class in C# code:

1
2
3
4
5
6
7
8
public sealed class ThrowBomb : ActionBase
{
    public override void Execute(IAIContext context)
    {
        var c = (SurvivalContext)context;
        c.player.ThrowBomb();
    }
}

The ThrowBomb class calls the ThrowBomb() method on the player component that executes the actual action.

Reload Gun

The Reload Gun Qualifier is a Sum of Children Qualifier. It has only one ContexualScorer.

ContextualScorer What does it do?
IsGunLoaded If there is not more than zero (0) bullets in the magazine of the gun, it returns a score of a hundred (100)

The IsGunLoaded has the ‘Not’ boolean set to true. This means that if the gun is not loaded, it will return its score to the qualifier. The idea is that the Reload Gun Qualifier comes before the Fire Gun Qualifier in the First Score Wins Selector. The reason is that it does not make much sense to try to fire the gun if it is not loaded.

apex-utility-ai-unity-survival-shooter

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public sealed class IsGunLoaded : ContextualScorerBase
{
    [ApexSerialization]
    public bool not = false;
    public override float Score(IAIContext context)
    {
        var c = (SurvivalContext)context;
        if (c.player.currentAmmo <= 0)
        {
            return this.not ? this.score : 0f;
        }
        return this.not ? 0f : this.score;
    }
}

The IsGunLoaded class checks the currentAmmo property on the player component, and returns score if the count is higher than zero (0).

We now need to implement the Reload Action.

Create the following class in C# code:

1
2
3
4
5
6
7
8
public sealed class ReloadGun : ActionBase
{
    public override void Execute(IAIContext context)
    {
        var c = (SurvivalContext)context;            
        c.player.Reload();
    }
}

The ReloadGun class calls the Reload() action on the player component that executes the actual action.

Fire Gun

The FireGun Qualifier is a Sum of Children Qualifier. It has only one ContexualScorer.

ContextualScorer What does it do?
IsGunLoaded If there is more than zero (0) bullets in the magazine of the gun, it returns a score of ten (10)

The FireGun Qualifier executes a Composite Action that contains two actions. The first action sets the target, and the second action fires the gun at this target. The pre-condition for both actions is that there is at least one potential target. However, remember that the AI checks for this in the HasEnemies Qualifier in the ActOrIdle Selector.

apex-utility-ai-unity-survival-shooter

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
public sealed class SetBestAttackTarget : ActionWithOptions<LivingEntity>
{
    public override void Execute(IAIContext context)
    {
        var c = (SurvivalContext)context;
        var best = this.GetBest(context, c.enemies);
        if (best != null)
        {
            c.player.attackTarget = best;
        }
    }
}

The SetBestAttackTarget class takes LivingEntity as type. The GetBest() methods thus takes a List<LivingEntity> as argument, in this case the list of enemies from the SurvivalContext. It returns the highest scoring enemy, which is then set as the attack target in the player component.

We now need to implement the actual attack Action that fires the AIs weapon.

Create the following class in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public sealed class FireAtAttackTarget : ActionBase
{
  public override void Execute(IAIContext context)
  {
      var c = (SurvivalContext)context;
      var player = c.player;
      var attackTarget = player.attackTarget;
      if (attackTarget == null)
      {
          player.StopFiring();
          // no valid attack target to attack -> so return
          return;
      }
      // Issue a 'fire at' command against the entity's current attack target
      player.StartFiring();
  }
}

The FireAtAttackTarget class calls the StartFiring() method on the player component if the player has an attackTarget. If the attackTarget is null, the StopFiring() action is called on the player component.

apex-utility-ai-unity-survival-shooter

SetBestAttackTarget

The SetBestAttackTarget Action is an ActionWithOptions<LivingEntity> that scores targets – in the form of a list of LivingEntity objects – using OptionScorers. In this case, the only target selection OptionScorer is a EnemyProximityToSelf.

OptionScorer What does it do?
EnemyProximityToSelf The score is set to fifty (50) meaning that positions within 50 meters are scored. Returns the score deducted by the range from the AI to the position.
IsAliveScorer The score is set to one hundred (100) to ensure that the AI only considers targets that are alive.
IsCurrentTargetScorer The score is set to 1.5 to ensure that the existing target is always preferred even if another target gets an equal score.

Create the following classes in C# code:

1
2
3
4
5
6
7
8
9
10
11
12
13
public sealed class EnemyProximityToSelf : CustomScorer<LivingEntity>
{
    [ApexSerialization]
    public float multiplier = 1f;
    public override float Score(IAIContext context, LivingEntity entity)
    {
        var c = (SurvivalContext)context;
        var distance = (entity.position - c.player.position).magnitude;
        return Mathf.Max(0f, (this.score - distance) * this.multiplier);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
public sealed class IsAliveScorer : CustomScorer<LivingEntity>
{
    public override float Score(IAIContext context, LivingEntity entity)
    {
        if(entity.currentHealth > 0)
        {
            return this.score;
        }
        return 0f;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public sealed class IsCurrentTargetScorer : CustomScorer<LivingEntity>
{
    public override float Score(IAIContext context, LivingEntity entity)
    {
        var c = (SurvivalContext)context;
        if (c.player.attackTarget == entity)
        {
            return this.score;
        }
        return 0f;
    }
}

In the AI Editor do the following:

  1. Left-click on the “SetBestAttackTarget” Action, and go to the Inspector.
  2. Click the “+” sign, and add the “EnemyProximityToSelf“, “IsAliveScorer”, and the “IsCurrentTargetScorer” OptionScorers.
  3. Set the scores and other properties as indicated in the screenshot above.

Finally, remember to add the PlayerAction AI to the UtilityAIComponent on the AI GameObject, just like PlayerScanner and PlayerMove.

The AI is now ready. Make a last check that all three AIs are added to the UtilityAIComponent on the player GameObject.

apex-utility-ai-unity-survival-shooter

And press play to see the AI wreak havoc among the zombie teddies!

Testing the AI

The Apex Utility AI provides a number of tools for testing the AI. One of the key tools when designing complex AIs is, of course, visual runtime debugging.

If the Utility AIs are implemented using the Utility AI Component, AIs can be opened and debugged in runtime from the inspector by navigating to the Utility AI Component on the game object in question, and clicking the “Open Editor” button.

This will open the Utility AI Editor window for the AI. Below is the Utility AI Editor window for the Scanner AI:

apex-utility-ai-unity-survival-shooter

Below is the Utility AI Editor for the Action AI:

apex-utility-ai-unity-survival-shooter

Below is the Utility AI Editor for the Movement AI:

apex-utility-ai-unity-survival-shooter

The Qualifiers that have been selected the last time the AI ran are marked with a green line to the left of the Qualifier. Qualifiers that have not been selected are marked with an orange line. Links between Selectors that have been executed are likewise green. Furthermore, the scores returned by each Qualifier can be viewed, and the score returned by each ContexualScorer can be viewed runtime by pressing the grey arrow on the right side of the qualifier.

For OptionScorers used to score e.g. the positions in the Move Actions, visual debugging using e.g. gizmos and GUI debugging is needed. This can be implemented using visualizers. For additional information on this please see the tutorial “Visualizers in Apex Utility AI”.
There are visualizers added to the Survival Shooter project for reference. To view the visualizations do the following:

  1. Make sure the AI is open.
  2. Make sure Gizmos are enabled in the scene.
  3. Enable one or more visualizers in the scene, by checking one or more components on the “Visualizers” GameObject.
  4. Press play, and select the player GameObject under Managers => EntityManager GameObject.

Below screenshot shows such a visualization:

apex-utility-ai-unity-survival-shooter

Extensions

The Survival Shooter has only shown a small part of what the Apex Utility AI can do. There are many ways the AI can be extended to add additional dimensions to it, such as more elaborate tactics, AI to the zombie teddies, more human feel to the AI, dialogues, taunts etc. In the tutorial it was elaborated how changing scores in the Utility AI can change behavior, and give rise to different personalities and variations in e.g. tactics and gameplay using the same base AI.

Another example could be to address the AI’s extremely good accuracy, as it has access to the exact coordinates of the zombie teddies. If more human-like accuracy is desired, a certain degree of randomness can be added to his ray cast against the teddies. Adjusting this randomness can be used to mimic varying skill-levels. Alternatively, a prediction algorithm could be used for where to fire the gun at, which might be more similar to human behaviour, or Scorers could be used to add inaccuracy depending on e.g. whether the AI is moving, whether the target is in shadow, whether there is recoil etc. Different priorities can also be added to the targeting, such as prioritizing Hellophants over Zombie Bunnies.

Additional OptionScorers can also be added for tactical movement. For some games it might be relevant to take into consideration cover and wall hugging, keeping a certain separation to allies, avoiding line of sight from secondary targets, prioritising positions from where targets can be attacked in the side or back, and so on.

The Survival Shooter game in itself can also be extended, with additional power-ups, progression in the numbers, strength and tactics of the zombie teddies, introduction of particularly smart bosses, etc.

For additional ideas and functionality of the Apex Utility AI we advise that you consult our other tutorials, videos, forums and game demos.

Conclusion

This tutorial showed in detail how the Apex Utility AI has been implemented in the Unity Survival Shooter to animate the little hero. The tutorial went through the idea behind the AI, the details, as well as the configuration in the Utility AI Editor and the C# code for the relevant classes. Furthermore, it detailed runtime debugging and presented a number of possibilities for extending the Survival Shooter using the Apex Utility AI.