The active turn queue

Our battlers can already get ready to act conceptually, but we lack a way for them to take turns. We want the turn logic to be in a separate object. If one battler takes a turn, another battler shouldn’t access this information directly. Queueing and giving turns should be another object’s responsibility, and we’re going to start coding it in this lesson.

By the end of this part, you will have the turn system’s foundation. As we haven’t coded actions yet, the battlers won’t perform attacks yet.

We need a new scene to represent our combat arena. Create a new 2D scene with the following node structure and save it as CombatDemo.tscn.

In the image above, Bear and BugCat are two instances of Battler.tscn that I renamed. They’re temporary. In a later lesson, we should replace them with separate scenes.

In our demo, the ActiveTurnQueue node has the encounter’s battlers as its children, so we need the battler instances for testing.

Add a new script to ActiveTurnQueue named ActiveTurnQueue.gd. Let’s start by storing the player’s party members and opponents to help us select targets later. Also, we connect to the battler’s ready_to_act signal.

# Queues and delegates turns for all battlers.
class_name ActiveTurnQueue
extends Node

var _party_members := []
var _opponents := []

# All battlers in the encounter are children of this node. We can get a list of all of them with
# get_children()
onready var battlers := get_children()


func _ready() -> void:
    for battler in battlers:
        # Listen to each battler's ready_to_act signal, binding a reference to the battler to the callback.
        battler.connect("ready_to_act", self, "_on_Battler_ready_to_act", [battler])
        if battler.is_party_member:
            _party_members.append(battler)
        else:
            _opponents.append(battler)

Time scale and is_active

In the previous lesson, we defined two public properties of the Battler class, is_active and time_scale. Let’s do the same on ActiveTurnQueue to pause or slow down all battlers.

# Allows pausing the Active Time Battle during combat intro, a cutscene, or combat end.
var is_active := true setget set_is_active
# Multiplier for the global pace of battle, to slow down time while the player is making decisions.
# This is meant for accessibility and to control difficulty.
var time_scale := 1.0 setget set_time_scale


# Updates `is_active` on each battler.
func set_is_active(value: bool) -> void:
    is_active = value
    for battler in battlers:
        battler.is_active = is_active


# Updates `time_scale` on each battler.
func set_time_scale(value: float) -> void:
    time_scale = value
    for battler in battlers:
        battler.time_scale = time_scale

To slow down time, now, we can call set_time_scale() with a value in the [0.0, 1.0] range.

Taking turns

When a battler is ready to take a turn, we let it play its turn. While the Battler.ready_to_act signal does not tell us which battler is ready, we use the binding feature of the connect() method to know which battler is ready.

func _on_Battler_ready_to_act(battler: Battler) -> void:
    _play_turn(battler)

Later, we will add more code to this callback function. This is why we don’t directly connect the ready_to_act signal to _play_turn().

We need to add the _play_turn() method, one of the most complex in the project. This large function asks the battler to pick an action and target(s). It has two branches:

  1. If the battler is player-controlled, we let them select an action and target through menus. We will add the menus in a later lesson.
  2. If the battler is AI-controlled, we let the AI brain choose the action and target.

We start with two variables to store the action and target(s).

func _play_turn(battler: Battler) -> void:
    var action_data: ActionData
    var targets := []

ActionData isn’t defined yet, and this type hint will cause an error. When starting a new project, I often write the types I need before defining them. If you have experience working with an IDE in other languages, the program can define those for you. In GDScript’s case, we lack such a tool.

The issue here is that an error will prevent you from using auto-completion, among other features. To address it, you have two options:

  1. Temporarily remove the type hint.
  2. Create a file ActionData.gd with the line class_name ActionData at the top.

Continuing with the function, we increase the battler’s energy by one. Keep in mind that we have yet to code the stats system. Calling code we have yet to define happens a lot when writing complex systems.

Then, we list potential targets: battlers that are opponents and that we can select.

    battler.stats.energy += 1

    # The code below makes a list of selectable targets using `Battler.is_selectable`
    var potential_targets := []
    var opponents := _opponents if battler.is_party_member else _party_members
    for opponent in opponents:
        if opponent.is_selectable:
            potential_targets.append(opponent)

Here starts the first branch. If the player controls the battler, we need to let them use menus. For now, we write boilerplate code.

    if battler.is_player_controlled():
        # We'll use the selection in the next lesson to move playable battlers
        # forward. This value will also make the Heads-Up Display (HUD) for this
        # battler move forward.
        battler.is_selected = true
        # This line slows down time while the player selects an action and
        # target. The function `set_time_scale()` recursively assigns that value
        # to all characters on the battlefield.
        set_time_scale(0.05)

        # Here is the meat of the player's turn. We use a while loop to wait for
        # the player to select a valid action and target(s).
        #
        # For now, we have two boilerplate asynchronous functions,
        # `_player_select_action_async()` and `_player_select_targets_async()`,
        # that respectively return an action to perform and an array of targets.
        # This seemingly complex setup will allow the player to cancel
        # operations in menus.
        var is_selection_complete := false
        # The loop keeps running until the player selected an action and target.
        while not is_selection_complete:
            # The player has to first select an action, then a target.
            # We store the selected action in the `action_data` variable defined
            # at the start of the function.
            action_data = yield(_player_select_action_async(battler), "completed")
            # If an action applies an effect to the battler only, we
            # automatically set it as the target.
            if action_data.is_targeting_self:
                targets = [battler]
            else:
                targets = yield(
                    _player_select_targets_async(action_data, potential_targets), "completed"
                )
            # If the player selected a correct action and target, we can break
            # out of the loop. I'm using a variable here to make the code
            # readable and clear. You could write a `while true` loop and use
            # the break keyword instead, but doing so makes the code less
            # explicit.
            is_selection_complete = action_data != null && targets != []
        # The player-controlled battler is ready to act. We reset the time scale
        # and deselect the battler.
        set_time_scale(1.0)
        battler.is_selected = false

The second branch runs if the battler has an artificial intelligence. Until we code it, let’s hard-code the action and target.

    else:
        action_data = battler.actions[0]
        targets = [potential_targets[0]]

We need to define two more functions so the code compiles without errors, _player_select_targets_async() and _player_select_action_async(). They’re also boilerplate for the time being. We need to design and implement menus before the player can select actions.

# We must use a placeholder `yield()` call to turn the methods into coroutines.
# Otherwise, we can't use `yield()` in the `_play_turn()` method.
func _player_select_action_async(battler: Battler) -> ActionData:
    yield(get_tree(), "idle_frame")
    return battler.actions[0]


func _player_select_targets_async(_action: ActionData, opponents: Array) -> Array:
    yield(get_tree(), "idle_frame")
    return [opponents[0]]

Note that both functions have the word async in their name. We use that suffix to indicate a function is a coroutine and will pause its execution. That’s why, in _play_turn(), we have to use the yield keyword.

The code so far

Here is the code we wrote for ActiveTurnQueue.gd.

# Queues and delegates turns for all battlers.
class_name ActiveTurnQueue
extends Node

var is_active := true setget set_is_active
var time_scale := 1.0 setget set_time_scale

var _party_members := []
var _opponents := []

onready var battlers := get_children()


func _ready() -> void:
    for battler in battlers:
        battler.connect("ready_to_act", self, "_on_Battler_ready_to_act", [battler])
        if battler.is_player_controlled():
            _party_members.append(battler)
        else:
            _opponents.append(battler)


func set_is_active(value: bool) -> void:
    is_active = value
    for battler in battlers:
        battler.is_active = is_active


func set_time_scale(value: float) -> void:
    time_scale = value
    for battler in battlers:
        battler.time_scale = time_scale


func _play_turn(battler: Battler) -> void:
    var action_data: ActionData
    var targets := []

    battler.stats.energy += 1

    var potential_targets := []
    var opponents := _opponents if battler.is_party_member else _party_members
    for opponent in opponents:
        if opponent.is_selectable:
            potential_targets.append(opponent)

    if battler.is_player_controlled():
        battler.is_selected = true
        set_time_scale(0.05)

        var is_selection_complete := false
        while not is_selection_complete:
            action_data = yield(_player_select_action_async(battler), "completed")
            if action_data.is_targeting_self:
                targets = [battler]
            else:
                targets = yield(
                    _player_select_targets_async(action_data, potential_targets), "completed"
                )
            is_selection_complete = action_data != null && targets != []
        set_time_scale(1.0)
        battler.is_selected = false
    else:
        action_data = battler.actions[0]
        targets = [potential_targets[0]]


func _player_select_action_async(battler: Battler) -> ActionData:
    yield(get_tree(), "idle_frame")
    return battler.actions[0]


func _player_select_targets_async(_action: ActionData, opponents: Array) -> Array:
    yield(get_tree(), "idle_frame")
    return [opponents[0]]


func _on_Battler_ready_to_act(battler: Battler) -> void:
    _play_turn(battler)