Applying status effects

Now we have status effects, we need to put them to use. We have to create two new classes: the builder and the container. Then, we will have to wire the effects with actions and battlers.

Creating StatusEffect objects with the factory pattern.

The builder class implements the factory design pattern. It provides a function that creates concrete status effect objects. It’s a common pattern in object-oriented programming languages that allows you to instantiate a class without having to call new() or referencing the individual classes in your code. In a sense, it encapsulates the creation of these individual instances.

Create a new file named StatusEffectBuilder.gd.

# Creates concrete [StatusEffect] instances from [StatusEffectData] resources.
class_name StatusEffectBuilder
extends Reference

# We register the valid ID strings for `StatusEffectData.id` in this constant.
# This allows us to not have to reference the class names directly and create new effect resources
# in the inspector.
# Note the convention in the GDScript style guide is to use PascalCase for types. I used that
# because our content is a map of types, but you may want to use constant case instead:
# `STATUS_EFFECTS`.
const StatusEffects := {
    haste = StatusEffectHaste,
    slow = StatusEffectSlow,
    bug = StatusEffectBug,
}

# The class only exposes a function that creates and returns a new status effect based on the data
# we provide.
static func create_status_effect(target, data):
    # We can use the function to run through some `assert()` or return early if the input parameters
    # are incorrect.
    if not data:
        return null
    # You can store a reference to a class in a variable!
    var effect_class = StatusEffects[data.effect]
    var effect: StatusEffect = effect_class.new(target, data)
    return effect

Adding a container to manage status effects

Create a new file named StatusEffectContainer.gd.

# Keeps track of status effects active on a [Battler].
# Adds and removes status effects as necessary.
# Intended to be placed as a child of a Battler.
class_name StatusEffectContainer
extends Node

# Maximum number of instances of one type of status effect that can be applied to one battler at a
# time.
const MAX_STACKS := 5
# List of effects that can be stacked.
const STACKING_EFFECTS := ["bug"]
# List of effects that cannot be stacked. When a new effect of this kind is applied, it replaces or
# refreshes the previous one.
const NON_STACKING_EFFECTS := ["haste", "slow"]

# The two properties below work the same way as with the `ActiveTurnQueue`.
# The setters assign the value to child nodes.
var time_scale := 1.0 setget set_time_scale
var is_active := true setget set_is_active


# Adds a new instance of a status effect as a child, ensuring the effects don't stack past
# `MAX_STACKS`.
func add(effect: StatusEffect) -> void:
    # If we already have active effects, we may have to replace one.
    # If it can stack, we replace the one with the smallest time left.
    if effect.can_stack():
        if _has_maximum_stacks_of(effect.id):
            _remove_effect_expiring_the_soonest(effect.id)
    # If it's a unique effect like slow or haste, we replace it.
    elif has_node(effect.name):
        # We call `StatusEffect.expire()` to let the effect properly clean up itself.
        get_node(effect.name).expire()
    # The status effects are node so all we need to do is add it as a child of the container.
    add_child(effect)


# Removes all stacks of an effect of a given type.
func remove_type(id: String) -> void:
    for effect in get_children():
        if effect.id == id:
            effect.expire()


# Removes all status effects.
func remove_all() -> void:
    for effect in get_children():
        effect.expire()


# The two setters below are similar to what we did in `ActiveTurnQueue.gd`
func set_time_scale(value: float) -> void:
    time_scale = value
    for effect in get_children():
        effect.time_scale = time_scale


func set_is_active(value) -> void:
    is_active = value
    for effect in get_children():
        effect.is_active = is_active


# Returns `true` if there are `MAX_STACKS` instances of a given status effect.
func _has_maximum_stacks_of(id: String) -> bool:
    var count := 0
    for effect in get_children():
        if effect.id == id:
            count += 1
    return count == MAX_STACKS


# Finds and expires the effect of a given type expiring the soonest.
func _remove_effect_expiring_the_soonest(id: String) -> void:
    var to_remove: StatusEffect
    var smallest_time: float = INF
    # We have to check all effects to find the ones that match the `id`.
    for effect in get_children():
        if effect.id != id:
            continue
        # We compare the `time_left` for an effect of type `id` to the current `smallest_time` and
        # if it's smaller, we update the variables.
        var time_left: float = effect.get_time_left()
        if time_left < smallest_time:
            to_remove = effect
            smallest_time = time_left
    to_remove.expire()

Let’s add the container to each of our battler scenes.

You’ll want to go through each of your copies of Battler.tscn and add a node as a child named StatusEffectContainer. Thanks to the class_name we set in the script, it should appear in the Add Node dialog.

Synchronizing battlers

Note that in this series, I’ve avoided the use of inherited scenes. Instead, a few lessons ago, we created unique copies of Battler.tscn for individual characters.

However, you won’t want to create dozens of copies of the battler scene to create dozens of characters and monster types.

Inherited scenes are supposed to work like class inheritance and help you reuse and extend existing scenes.

With Godot 3.2, though, using inherited scenes has limitations that can make you lose work unintentionally at the time of writing. There’s a proposal to improve them, but until they get some changes, I don’t recommend using them.

An alternative to adding nodes in the editor is to add them in the Battler.gd script. In our case, it’s about the same.

You can do it like so:

# We create an instance of the StatusEffectContainer class.
var _status_effect_container := StatusEffectContainer.new()


func _ready() -> void:
    # And we add the node as a child when the battler is ready.
    add_child(_status_effect_container)

With that, we need a bit of wiring to put the effects to use.

Using the status effect in code

We need to update four classes to pass our status effect around: The Hit, the AttackAction, its AttackActionData, and the Battler.

It can seem complicated, but this often happens when we try to decouple and encapsulate code with object-oriented programming. You end up having to pass a reference multiple times to deliver it to the final Target.

Let’s start with the Hit class, where we add a new effect property. Open Hit.gd.

var effect: StatusEffect

# We add the ability to pass an effect through the constructor.
func _init(_damage: int, _hit_chance := 100.0, _effect: StatusEffect = null) -> void:
    # ...
    # hit_chance = _hit_chance
    effect = _effect

We want the action to feed the effect to the hit when it generates it. To do that, we need to update the AttackActionData and then the AttackAction class. Open AttackActionData.gd.

# Status effect applied by the attack, of type `StatusEffectData`.
export var status_effect: Resource

# Returns the total damage for the action, factoring in damage dealt by a status effect.
func calculate_potential_damage_for(battler) -> int:
    # ...
    # Adding the effect's damage if applicable.
    if status_effect:
        total_damage += status_effect.calculate_total_damage()
    return total_damage

Next, add this code to AttackAction.gd.

func _apply_async() -> bool:
    for target in _targets:
        # We use the `StatusEffectBuilder` to instantiate the right effect.
        var status: StatusEffect = StatusEffectBuilder.create_status_effect(
            target, _data.status_effect
        )
        # ...
        # We add the status effect to the hit.
        var hit := Hit.new(damage, hit_chance, status)
        # ...

Finally, when the battler takes a hit, we need to apply the status effect if the hit object contains one. Add the following code to Battler.gd.

# Adding a reference to the container.
onready var _status_effect_container: StatusEffectContainer = $StatusEffectContainer


func take_hit(hit: Hit) -> void:
    if hit.does_hit():
        #...
        # If the hit comes with an effect, we apply it to the battler.
        if hit.effect:
            _apply_status_effect(hit.effect)


# We forward the `time_scale` and `is_active` to the status effects through the container.
func set_time_scale(value) -> void:
    # ...
    _status_effect_container.time_scale = time_scale


func set_is_active(value) -> void:
    # ...
    _status_effect_container.is_active = value


# This function applies the status effect to the battler.
# Effect is of type `StatusEffect`.
func _apply_status_effect(effect) -> void:
    _status_effect_container.add(effect)

Testing the code

We are getting to the end of it. To wrap things up, we want to test our new code and the connections using print statements once again.

We need to create a new StatusEffectData resource and assign it to our AttackActionData.

In the FileSystem dock, create a new resource of type StatusEffectData.

Assign the effect “bug” to it, if you named it the same as I did. This should be an id you wrote in the StatusEffectBuilder class. Here are the settings I used for my effect. It inflicts three damage every four seconds.

Double click on the basic attack action resource we created some lessons ago and drag and drop your new status effect resource onto its effect slot.

Now, every attack should cause this status effect. Let’s verify that with some print() statements or breakpoints. You can place them wherever you want; I’m going to put one in the Battler._apply_status_effect() function and one in one in Battler.take_damage(), because they should both get called through the status effect. I expect to see the battler take three damage at regular time intervals.

# In Battler.gd
func _apply_status_effect(effect) -> void:
    # _status_effect_container.add(effect)
    print("Applying effect %s to %s" % [effect.id, name])


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

Don’t forget that time slowing down on the player’s turn in ActiveTurnQueue can delay the output a lot.

You should see text like the following in the Output dock:

Bear took 7 damage
Applying effect bug to Bear
BugCat took 13 damage
Applying effect bug to BugCat
Bear took 3 damage
BugCat took 3 damage

The code so far

Here are the complete StatusEffectBuilder and StatusEffectContainer classes.

StatusEffectBuilder.gd

class_name StatusEffectBuilder
extends Reference

const StatusEffects := {
    haste = StatusEffectHaste,
    slow = StatusEffectSlow,
    bug = StatusEffectBug,
}

static func create_status_effect(target, data):
    if not data:
        return null
    var effect_class = StatusEffects[data.effect]
    var effect: StatusEffect = effect_class.new(target, data)
    return effect

StatusEffectContainer.gd

class_name StatusEffectContainer
extends Node

const MAX_STACKS := 5
const STACKING_EFFECTS := ["bug"]
const NON_STACKING_EFFECTS := ["haste", "slow"]

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


func add(effect: StatusEffect) -> void:
    if effect.can_stack():
        if _has_maximum_stacks_of(effect.id):
            _remove_effect_expiring_the_soonest(effect.id)
    elif has_node(effect.name):
        get_node(effect.name).expire()
    add_child(effect)


func remove_type(id: String) -> void:
    for effect in get_children():
        if effect.id == id:
            effect.expire()


func remove_all() -> void:
    for effect in get_children():
        effect.expire()


func set_time_scale(value: float) -> void:
    time_scale = value
    for effect in get_children():
        effect.time_scale = time_scale


func set_is_active(value) -> void:
    is_active = value
    for effect in get_children():
        effect.is_active = is_active


func _has_maximum_stacks_of(id: String) -> bool:
    var count := 0
    for effect in get_children():
        if effect.id == id:
            count += 1
    return count == MAX_STACKS


func _remove_effect_expiring_the_soonest(id: String) -> void:
    var to_remove: StatusEffect
    var smallest_time: float = INF
    for effect in get_children():
        if effect.id != id:
            continue
        var time_left: float = effect.get_time_left()
        if time_left < smallest_time:
            to_remove = effect
            smallest_time = time_left
    to_remove.expire()