Taking and inflicting damage

In this lesson, we will add the ability to take and inflict damage on the battlers. We will add resources so that each battler has stats and a basic attack action available. Then, we’ll write the code to apply those actions.

Creating stats and action resources

I prepared a scene with the following node structure, with our ActiveTurnQueue and two battlers: one for the player and one for the enemy. For each of them, we want to create at least one stat resource. We also need one action data resource to represent a basic attack.

To create a new resource, right-click anywhere in the FileSystem dock and click on New Resource.

As we used the class_name keyword for our resource classes, Godot adds them to the menu that pops up. You can search for AttackActionData and BattlerStats to create resources of the corresponding type.

Create two files that extend BattlerStats and one that extends AttackActionData.

To edit a resource, double-click on the .tres file in the FileSystem dock, and it will open in the Inspector. You can also input the numbers you want for Health, Attack, Defense, and other values. Here are the ones I used.

The PlayerBattler stats.

EnemyBattler stats.

Attack action properties.

At this point, the values are arbitrary. Design-wise, I don’t bother calculating the interactions between the stats and the formulas outside the game at first. You want to make the values feel good to the player and not just try to make them work on paper, which requires gameplay testing.

We then want to assign those resources to the battlers in the scene. To do so, select one of the battler nodes and drag and drop resources on the related properties.

When it comes to the Actions array, you want to expand the field by clicking on it, increase the size to 1, and click on the pencil icon to create a new value of type Object.

The PlayerBattler node.

The EnemyBattler node.

Taking hits and acting

We’re going to add several methods to our battler and call them from the ActiveTurnQueue. First, at the start of the encounter, we should make sure that we duplicate the stats resource and call its reinitialize() function.

Open Battler.gd and add the _ready() function:

func _ready() -> void:
    assert(stats is BattlerStats)
    stats = stats.duplicate()
    stats.reinitialize()

Resources are shared in memory so if you have two monsters of the same type on the battlefield, they’re going to reference the same BattlerStats instance and share health. If we don’t duplicate the stats resources, when either takes damage, it’s the same value that goes down.

Let’s add the ability to take a hit next. To do so, we also need a function to take damage. We also add two signals that we’ll get to use in the next chapter, when adding the user interface.

# Emitted when taking damage.
# We'll use this signal in the next chapter to display the number of damage
# taken.
signal damage_taken(amount)
# Emitted when a received hit missed.
# We'll use this signal in the next chapter to display a "miss" label on the
# screen.
signal hit_missed


# Applies a hit object to the battler, dealing damage or status effects.
func take_hit(hit: Hit) -> void:
    # We encapsulated the hit chance in the hit. The hit object tells us if 
    # we should take damage.
    if hit.does_hit():
        _take_damage(hit.damage)
        emit_signal("damage_taken", hit.damage)
    else:
        emit_signal("hit_missed")


# Applies damage to the battler's stats.
# Later, it should also trigger a damage animation.
func _take_damage(amount: int) -> void:
    stats.health -= amount

The next piece of the puzzle is adding a method that allows the battler to act and attack a target.

# Emitted when the battler finished their action and arrived back at their rest
# position.
signal action_finished


# We can't specify the `action`'s type hint here due to cyclic dependency errors
# in Godot 3.2.
# But it should be of type `Action` or derived, like `AttackAction`.
func act(action) -> void:
    # If the action costs energy, we subtract it.
    stats.energy -= action.get_energy_cost()
    # We wait for the action to apply. It's a coroutine, that is to say, an
    # asynchronous function, so we need to use yield.
    # The "completed" function signal is built-in, more on that below.
    yield(action.apply_async(), "completed")
    # We reset the `_readiness`. The value can be greater than zero, depending
    # on the action.
    _set_readiness(action.get_readiness_saved())
    # We shouldn't set process back to `true` if the battler isn't active, so its readiness doesn't update.
    # That can be the case if we code a "stop" or "petrify" status effect, or
    # during an animation that interrupts the normal flow of battle.
    if is_active:
        set_process(true)
    # We emit our new signal, indicating the end of a turn for a
    # player-controlled character.
    emit_signal("action_finished")

Above, we use the yield keyword to pause execution until the apply_async() method completes.

When you use yield in Godot, the engine waits for a signal to be emitted to resume the function’s execution. It can be either a built-in or a custom signal.

In this case, "completed" is a special signal emitted by functions when they finish executing.

It works like so: when you call a method through yield, Godot creates a GDScriptFunctionState object for that function, which can pause and resume a function call.

The "completed" signal comes from this class.

Making the battlers act on their turn

Now we have a public Battler.act() method, we need to call it. Let’s open ActiveTurnQueue.gd and, at the bottom of _play_turn(), add the following code.

func _play_turn(battler: Battler) -> void:
    #...

    # Create a new attack action based on the chosen `action_data` and
    # `targets`.
    var action = AttackAction.new(action_data, battler, targets)
    # And let the battler consume it.
    battler.act(action)
    # We wait for the battler's action to finish to complete the function.
    yield(battler, "action_finished")

We will later add some more code after the yield line.

Testing attacks

We now we have everything we need to test our attack and see our battlers take damage and suffer (!).

Let’s test like the pros we are using the almighty print() function. In the Battler.gd script, add the following temporary print statement:

func _take_damage(amount: int) -> void:
    #...
    print("%s took %s damage. Health is now %s." % [name, amount, stats.health])

You can then run your game by pressing F5, provided you already set your main scene to be the combat arena, and watch the Output console at the bottom of the editor.

The %s notation is a string placeholder. You can use it to programmatically insert values in text strings using the % operator. The notation is "%s %s" % [value_1, value_2]. The compiler converts the values in square brackets to text strings for you.

For more information, read GDScript format strings in the official documentation.

The code so far

Here are the complete Battler.gd and ActiveTurnQueue.gd files so far.

Battler.gd

extends Node2D
class_name Battler

signal ready_to_act
signal readiness_changed(new_value)
signal selection_toggled(value)
signal damage_taken(amount)
signal hit_missed
signal action_finished

export var stats: Resource
export var ai_scene: PackedScene
export var actions: Array
export var is_party_member := false

var time_scale := 1.0 setget set_time_scale
var is_active: bool = true setget set_is_active
var is_selected: bool = false setget set_is_selected
var is_selectable: bool = true setget set_is_selectable

var _readiness := 0.0 setget _set_readiness

func _ready() -> void:
    assert(stats is BattlerStats)
    stats = stats.duplicate()
    stats.reinitialize()
    stats.connect("health_depleted", self, "_on_BattlerStats_health_depleted")


func _process(delta: float) -> void:
    _set_readiness(_readiness + stats.speed * delta * time_scale)


func is_player_controlled() -> bool:
    return ai_scene == null


func set_time_scale(value) -> void:
    time_scale = value


func set_is_active(value) -> void:
    is_active = value
    set_process(is_active)


func set_is_selected(value) -> void:
    if value:
        assert(is_selectable)

    is_selected = value
    emit_signal("selection_toggled", is_selected)


func set_is_selectable(value) -> void:
    is_selectable = value
    if not is_selectable:
        set_is_selected(false)


func act(action) -> void:
    stats.energy -= action.get_energy_cost()
    yield(action.apply_async(), "completed")
    _set_readiness(action.get_readiness_saved())
    if is_active:
        set_process(true)
    emit_signal("action_finished")


func take_hit(hit: Hit) -> void:
    if hit.does_hit():
        _take_damage(hit.damage)
        emit_signal("damage_taken", hit.damage)
    else:
        emit_signal("hit_missed")


func _set_readiness(value: float) -> void:
    _readiness = value
    emit_signal("readiness_changed", _readiness)
    if _readiness >= 100.0:
        emit_signal("ready_to_act")
        set_process(false)


func _take_damage(amount: int) -> void:
    stats.health -= amount


func _on_BattlerStats_health_depleted() -> void:
    set_is_active(false)
    if not is_party_member:
        set_is_selectable(false)

ActiveTurnQueue.gd

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

    var action = AttackAction.new(action_data, battler, targets)
    battler.act(action)
    yield(battler, "action_finished")


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)