Letting the AI choose an action and targets

Let’s implement the missing features in our BattlerAI class.

For the agent to choose text action and target, we need a public function that the ActiveTurnQueue can call. Let’s call it choose().

We’re going to split the selection process into two steps: the agent first chooses an action, then a target. As we have the battlefield information pre-calculated for us, we can easily split the two operations and think of them almost separately.

Also, we’re going to have a similar pattern to the Action class with a public method and a pseudo-private virtual one.

# Returns a dictionary representing an action and its targets.
# The dictionary has the form { action: Action, targets: Array[Battler] }
# Arguments:
# - `battlers: Array[Battler]`, all battlers on the field, including the actor
func choose() -> Dictionary:
    # A defensive assert to ensure we don't forget to call `setup()` on the AI during development.
    assert(
        not _opponents.empty(),
        "You must call setup() on the AI and give it opponents for it to work."
    )
    return _choose()


func _choose() -> Dictionary:
    # We start the turn by gathering information about the battlefield.
    var battle_info := _gather_information()

    # The values we need to return in our dictionary.
    var action: ActionData
    var targets := []

    # We're going to add two methods, `_choose_action()` and `_choose_targets()`,
    # so we can override only one or the other in our subclasses.
    action = _choose_action(battle_info)

    # There's a special case with actions, if it's targeting its user, we don't need
    # to choose a target.
    if action.is_targeting_self:
        targets = [_actor]
    else:
        targets = _choose_targets(action, battle_info)
    return {action = action, targets = targets}


# Virtual method. Returns the action the agent is choosing to use this turn.
func _choose_action(_info: Dictionary) -> ActionData:
    return _actor.actions[0]


# Virtual method. Returns the agent's targets this turn.
func _choose_targets(_action: ActionData, _info: Dictionary) -> Array:
    return [_info.weakest_target]

Say that you want a boss to have a really powerful attack that requires charging energy for three turns before unleashing it. We can have an array of planned actions that the agent pulls from each turn.

# Queue of [ActionData]. If not empty, the action is popped from this array on the next turn.
# Use this property to plan actions over multiple turns.
var _next_actions := []


# We need to update the code that chooses the action in the `_choose()` method.
func _choose() -> Dictionary:
    #...

    # We pop one action from `_next_actions` if the array isn't empty.
    if not _next_actions.empty():
        action = _next_actions.pop_front()
    # Otherwise, we call `_choose_action()`
    else:
        action = _choose_action(battle_info)
    #...

Finally, let’s add a random number generator so that each agent can generate random values as needed.

var _rng := RandomNumberGenerator.new()

You can use it to shuffle actions or to choose between targets randomly.

Coding a concrete aggressive AI

With our sanbox implemented, we can now create concrete agents with limited amounts of code.

Here is a minimal example with an aggressive AI: it always attacks the weakest target with its strongest available ability.

class_name AggressiveBattlerAI
extends BattlerAI


func _choose_action(info: Dictionary) -> ActionData:
    # We always pick the strongest action.
    return info.strongest_action


func _choose_targets(_action: ActionData, info: Dictionary) -> Array:
    # We always work with arrays of targets to support targeting multiple targets.
    # If you want to target only one opponent, don't forget to wrap it in an array.
    return [info.weakest_target]

You can see we pull the values from our pre-computed info dictionary. That’s one of the advantages of the sandbox pattern: we can keep subclasses lean and produce many variations quickly once the parent class provides enough functionality.

Setting up AI agents

To wrap up this lesson, we’re going to put our AI system to use and test it.

We have to wire the AI with the Battler and use its choose() method in the ActiveTurnQueue.

Open Battler.gd and add a setup() function to it. We use it to give the AI brain a reference to all battlers on the battlefield. We can also add a method to allow players to access the BattlerAI instance.

# This is the concrete instance of `ai_scene`, an exported variable we defined in lesson 06.
var _ai_instance = null

# Allows the AI brain to get a reference to all battlers on the field.
func setup(battlers: Array) -> void:
    if ai_scene:
        # We instance the `ai_scene` and store a reference to it.
        _ai_instance = ai_scene.instance()
        # `BattlerAI.setup()` takes the actor and all battlers in the encounter.
        _ai_instance.setup(self, battlers)
        # Adding the instance as a child to trigger its `_ready()` callback.
        add_child(_ai_instance)


# Returns the `BattlerAI` instance attached to this battler.
# We wrap it in a method because our property is pseudo-private: we want it to be read-only.
func get_ai() -> Node:
    return _ai_instance

Next, open ActiveTurnQueue.gd. There, we want to call Battler.setup() at the start of the encounter to initialize AI agents.

We then update the _play_turn() function to let the AI choose an action and targets.

func _ready() -> void:
    #...
    for battler in battlers:
        # Setting up all AI-powered agents.
        battler.setup(battlers)
        #...


func _play_turn(battler: Battler) -> void:
    #...
    if battler.is_player_controlled():
        #...
    else:
        # Letting the AI choose by calling its `choose()` method.
        var result: Dictionary = battler.get_ai().choose()
        action_data = result.action
        targets = result.targets

Testing the agent

It would be nice to test our artificial intelligence, ensure that it works.

We don’t have a user interface yet so let’s use the print function to output its actions to the console. We first have to add an AI brain to one or more battlers.

Open the CombatDemo scene you created in the turn queue lesson. Each battler has an Ai Scene property where we can drop a scene representing the artificial intelligence we want to use.

Create a new scene with a single node called AggressiveBattlerAI. Attach the AggressiveBattlerAI.gd script to it.

Then, either in the combat demo or in a given battler’s source scene, drag and drop AggressiveBattlerAI.tscn onto the Ai Scene property in the inspector.

This is all you need to make an AI-controlled agent. We don’t have many actions or strategies that the agent can use at this point, as we’re just coding our system’s foundations. But at least the core features are in place, and we can test that the code works as expected.

Let’s add some print statements in the in one of the AI’s _choose_*() virtual methods. For example, we can print the battlefield information like so, in AggressiveBattlerAI.gd:

func _choose_action(info: Dictionary) -> ActionData:
    print(info)
    return info.strongest_action

We can also add code in ActiveTurnQueue.gd, in the branch of _play_turn() that calls battler.get_ai().choose()

    else:
        var result: Dictionary = battler.get_ai().choose()
        action_data = result.action
        targets = result.targets
        print("%s attacks %s with action %s" % [battler.name, targets[0].name, action_data.label])

When running the game, you should see output like this, showing that our new code gets called:

{attack_actions:[[Resource:1245]], available_actions:[[Resource:1245]], defensive_actions:[], fallen_opponents_count:0, fallen_party_count:0, health_status:4, strongest_action:[Resource:1245], weakest_ally:[Node2D:1410], weakest_target:[Node2D:1400]}

BugCat attacks Bear with action Claw

Note that if your player-controlled battler is faster than the enemies, you may not see this message until long seconds as we slow down time on the player’s turn. If that is the case, you can comment out the line set_time_scale(0.05) in ActiveTurnQueue.gd

The code

Here is the complete code for BattlerAI.gd

class_name BattlerAI
extends Node

enum HealthStatus { CRITICAL, LOW, MEDIUM, HIGH, FULL }

var _next_actions := []
var _rng := RandomNumberGenerator.new()

var _actor: Battler
var _party := []
var _opponents := []
var _weaknesses_dict := {}


func setup(actor: Battler, battlers: Array) -> void:
    _actor = actor
    for battler in battlers:
        var is_opponent: bool = battler.is_party_member != actor.is_party_member
        if is_opponent:
            _opponents.append(battler)
        else:
            _party.append(battler)
    _calculate_weaknesses()


func choose() -> Dictionary:
    assert(
        not _opponents.empty(),
        "You must call setup() on the AI and give it opponents for it to work."
    )
    return _choose()


# @tags: virtual
func _choose() -> Dictionary:
    var battle_info := _gather_information()
    var action: ActionData
    var targets := []

    if not _next_actions.empty():
        action = _next_actions.pop_front()
    else:
        action = _choose_action(battle_info)

    if action.is_targeting_self:
        targets = [_actor]
    else:
        targets = _choose_targets(action, battle_info)
    return {action = action, targets = targets}


# @tags: virtual
func _choose_action(_info: Dictionary) -> ActionData:
    return _actor.actions[0]


# @tags: virtual
func _choose_targets(_action: ActionData, _info: Dictionary) -> Array:
    return [_info.weakest_target]


func _gather_information() -> Dictionary:
    var actions := _get_available_actions()
    var attack_actions := _get_attack_actions_from(actions)
    var defensive_actions := _get_defensive_actions_from(actions)
    var info := {
        weakest_target = _get_battler_with_lowest_health(_opponents),
        weakest_ally = _get_battler_with_lowest_health(_party),
        health_status = _get_health_status(_actor),
        fallen_party_count = _count_fallen_party(),
        fallen_opponents_count = _count_fallen_opponents(),
        available_actions = actions,
        attack_actions = attack_actions,
        defensive_actions = defensive_actions,
        strongest_action = _find_most_damaging_action_from(attack_actions),
    }
    return info


func _get_battler_with_lowest_health(battlers: Array) -> Battler:
    var weakest: Battler = battlers[0]
    for battler in battlers:
        if battler.stats.health < weakest.stats.health:
            weakest = battler
    return weakest


func _is_weak_to(battler: Battler, action: ActionData) -> bool:
    return action.element in battler.stats.weaknesses


func _count_fallen_party() -> int:
    var count := 0
    for ally in _party:
        if ally.is_fallen():
            count += 1
    return count


func _count_fallen_opponents() -> int:
    var count := 0
    for opponent in _opponents:
        if opponent.is_fallen():
            count += 1
    return count


func _get_health_status(battler: Battler) -> int:
    if _is_health_below(battler, 0.1):
        return HealthStatus.CRITICAL
    elif _is_health_below(battler, 0.3):
        return HealthStatus.LOW
    elif _is_health_below(battler, 0.6):
        return HealthStatus.MEDIUM
    elif _is_health_below(battler, 1.0):
        return HealthStatus.HIGH
    else:
        return HealthStatus.FULL


func _is_health_below(battler: Battler, ratio: float) -> bool:
    ratio = clamp(ratio, 0.0, 1.0)
    return battler.stats.health < battler.stats.health * ratio


func _is_health_above(battler: Battler, ratio: float) -> bool:
    ratio = clamp(ratio, 0.0, 1.0)
    return battler.stats.health > battler.stats.health * ratio


func _calculate_weaknesses() -> void:
    for action in _actor.actions:
        _weaknesses_dict[action] = []
        for opponent in _opponents:
            if _is_weak_to(opponent, action):
                _weaknesses_dict[action].append(opponent)


func _get_available_actions() -> Array:
    var actions := []
    for action in _actor.actions:
        if action.can_be_used_by(_actor):
            actions.append(action)
    return actions


func _get_attack_actions_from(available_actions: Array) -> Array:
    var attack_actions := []
    for action in available_actions:
        if action is AttackActionData:
            attack_actions.append(action)
    return attack_actions


func _get_defensive_actions_from(available_actions: Array) -> Array:
    var defensive_actions := []
    for action in available_actions:
        if not action is AttackActionData:
            defensive_actions.append(action)
    return defensive_actions


func _find_most_damaging_action_from(attack_actions: Array):
    var strongest_action
    var highest_damage := 0
    for action in attack_actions:
        var total_damage = action.calculate_potential_damage_for(_actor)
        if total_damage > highest_damage:
            strongest_action = action
            highest_damage = total_damage
    return strongest_action