Status effects

To wrap up the combat system’s core, let’s add status effects. This is another system that requires quite a bit of code, so we’re going to tackle it over two lessons.

We’re going to code three systems:

  1. The status effects themselves, a node that represents and applies the effect.
  2. The container, a node that adds, removes, and updates status effects on the battler.
  3. The builder, an object that creates status nodes using the factory programming pattern.

In this part, we’re focusing on the effects themselves.

The effect’s data

Like with actions before, we want to use resources to design the game’s available status effects. So we are once again going to split the logic between a concrete object and a data container. Create a new file named StatusEffectData.

# Data container to define new status effects.
class_name StatusEffectData
extends Resource

# Text id for the effect type. See StatusEffectBuilder for more information.
export var effect := ""
# Duration of the effect in seconds.
export var duration_seconds := 20.0
# Modifier amount that the effect applies or removes to a character's stat.
export var effect_power := 20
# Rate of the effect, if, for instance, it is percentage-based.
export var effect_rate := 0.5

# If `true`, the effect applies once every `ticking_interval`
export var is_ticking := false
# Duration between ticks in seconds.
export var ticking_interval := 4.0
# Damage inflicted by the effect every tick.
export var ticking_damage := 3


# Returns the total theoretical damage the effect will inflict over time. For ticking effects.
func calculate_total_damage() -> int:
    var damage := 0
    if is_ticking:
        damage += int(duration_seconds / ticking_interval * ticking_damage)
    return damage

The concrete status effects

Next is the StatusEffect abstract base class. We will extend it to create concrete status effects like slow or poison. Create a new file named StatusEffect.gd.

Let’s start with its properties. Unlike with Action, I gave the class properties that duplicate the ones we defined in the associated resource. Doing that instead of storing a reference to a StatusEffectData instance as a member variable is an implementation detail.

In one case, you can directly access public properties, while in the other, you end up writing getter functions to access the resource’s values.

# Represents and applies the effect of a given status to a battler.
# The status takes effect as soon as the node is added to the scene tree.
class_name StatusEffect
# Our effects extend `Node` so we can use the `_process()` callback to update the effect on every
# frame.
extends Node

# Provided by the ActiveTurnQueue.
var time_scale := 1.0
# Duration of the effect in seconds, if necessary. Changing this variable
# updates the `_time_left` variable below via the `set_duration_seconds()`
# setter function.
var duration_seconds := 0.0 setget set_duration_seconds
# If `true`, the effect applies once every `ticking_interval`
var is_ticking := false
# Duration between ticks in seconds.
var ticking_interval := 1.0
# If `true`, the effect is active and is applying. We toggle processing in the
# `set_is_active()` setter function, as you'll see below.
var is_active := true setget set_is_active
# Text string to reference the effect and instantiate it. See `StatusEffectBuilder`.
var id := "base_effect"

# Time left in seconds until the effect expires.
var _time_left: float = -INF
# Time left in the current tick, if the effect is ticking.
var _ticking_clock := 0.0
# If `true`, this effect can stack.
var _can_stack := false
# Reference to the Battler to whom the effect is applied.
var _target


# We can't write type hints here due to cyclic loading errors in Godot 3.2.
# target: Battler
# data: StatusEffectData
func _init(target, data) -> void:
    _target = target
    set_duration_seconds(data.duration_seconds)

    # If the effect is ticking, we initialize the corresponding variables.
    is_ticking = data.is_ticking
    ticking_interval = data.ticking_interval
    _ticking_clock = ticking_interval


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


func set_duration_seconds(value: float) -> void:
    duration_seconds = value
    _time_left = duration_seconds

Next are the _ready() and _process() callbacks.

# Our status effects are nodes and they get into effect when added inside the battler's
# `StatusEffectContainer` node.
# `_start()` is a virtual method we'll override in derived classes.
func _ready() -> void:
    _start()


func _process(delta: float) -> void:
    _time_left -= delta * time_scale

    # If the effect is ticking, we want to know when we have to apply it. For example, for poison,
    # you want to inflict damage every `ticking_interval` seconds.
    if is_ticking:
        var old_clock = _ticking_clock
        # The `wrapf()` function cycles values between the second and the third argument. If
        # `ticking_interval` is `3.0` seconds and the expression evaluate to `-0.2`, the resulting
        # value will be `3.0 - 0.2 == 2.8`.
        _ticking_clock = wrapf(_ticking_clock - delta * time_scale, 0.0, ticking_interval)
        # When the value wraps, the ticking clock is greater than the `old_clock` and we apply the
        # effect.
        if _ticking_clock > old_clock:
            _apply()

    # The effect expires when there's no time left.
    if _time_left < 0.0:
        set_process(false)
        # This is another virtual method defined below.
        _expire()

The logic for ticking effects above does not work if you want a status effect to apply at extremely short time intervals, like ten times per second or more. But this is not our case here.

We have two getter functions to get the value of some private member variables from the outside. These methods allow you to probe the object from the outside. For example, to display the time left for a given status in the interface.

func can_stack() -> bool:
    return _can_stack


func get_time_left() -> float:
    return _time_left

We wrap up with three virtual methods that respectively start, apply, and clean up the status effect. We create new effects by overriding these methods.

# By convention in this project, all methods starting with a leading underscore,
# like `_expire()` below, are considered pseudo-private. So we add an extra
# "public" method for other objects to cause the status effect to expire. You
# might want that, for example, if you have poison status and the player uses an
# antidote.
func expire() -> void:
    _expire()


# Initializes the status effect on the battler.
func _start() -> void:
    pass


# Applies the status effect to the battler. Used with ticking effects,
# for example, a poison status dealing damage every two seconds.
func _apply() -> void:
    pass


# Cleans up and removes the status effect from the battler.
# The default behavior is to free the node.
func _expire() -> void:
    queue_free()

Creating haste and slow effects

Let’s see how we can implement specific effects, starting with haste and slow: effects that modify a battler’s speed stat momentarily.

Create a new file, StatusEffectHaste.gd.

class_name StatusEffectHaste
extends StatusEffect

# We give an explicit name to the value `StatusEffectData.effect_power` for this effect.
var speed_bonus := 0

# We're going to use our stats' class to add and remove a modifier.
var _stat_modifier_id := -1


# Below, target is of type `Battler`.
func _init(target, data: StatusEffectData).(target, data) -> void:
    # All haste effects should have the same `id` so we can later find and remove them.
    id = "haste"
    speed_bonus = data.effect_power


func _start() -> void:
    # We initialize the effect by adding a stat modifier to the target battler.
    _stat_modifier_id = _target.stats.add_modifier("speed", speed_bonus)


func _expire() -> void:
    # And we remove the stat modifier when the effect expires.
    _target.stats.remove_modifier("speed", _stat_modifier_id)
    queue_free()

Here’s the StatusEffectSlow, using a percentage-based stat reduction:

class_name StatusEffectSlow
extends StatusEffect

var speed_reduction := 0.0 setget set_speed_rate

var _stat_modifier_id := -1


# Similar to `StatusEffectHaste` above.
func _init(target, data: StatusEffectData).(target, data) -> void:
    id = "slow"
    speed_reduction = data.effect_rate


func _start() -> void:
    _stat_modifier_id = _target.stats.add_modifier(
        # We can calculate the modifier's value from the `speed_reduction` ratio.
        "speed", -1.0 * speed_reduction * _target.stats.speed
    )


# Same as `StatusEffectHaste`.
func _expire() -> void:
    _target.stats.remove_modifier("speed", _stat_modifier_id)
    queue_free()


# We ensure the speed reduction is neither 0% nor greater than 99%.
func set_speed_rate(value: float) -> void:
    speed_reduction = clamp(value, 0.01, 0.99)

Damaging and ticking effects

Here’s how to implement a poison-like effect. I called it “bug” in the final demo, but it’s a stacking effect that deals damage to its target at regular time intervals.

Create a new file, StatusEffectBug.gd.

class_name StatusEffectBug
extends StatusEffect

var damage := 3


# The parent class's constructor takes care of initializing ticking-related properties.
func _init(target, data).(target, data) -> void:
    id = "bug"
    damage = data.ticking_damage
    # We set `_can_stack` to `true` so this effect can stack up to five times.
    # We'll see how in the next lesson.
    _can_stack = true


# On every tick, we deal damage to the target battler.
func _apply() -> void:
    _target.take_hit(Hit.new(damage))

The code so far

Here are the five files we coded in this lesson.

StatusEffectData.gd

class_name StatusEffectData
extends Resource

export var effect := ""
export var duration_seconds := 20.0
export var effect_power := 20
export var effect_rate := 0.5

export var is_ticking := false
export var ticking_interval := 4.0
export var ticking_damage := 3


func calculate_total_damage() -> int:
    var damage := 0
    if is_ticking:
        damage += int(duration_seconds / ticking_interval * ticking_damage)
    return damage

StatusEffect.gd

class_name StatusEffect
extends Node

var time_scale := 1.0
var duration_seconds := 0.0 setget set_duration_seconds
var is_ticking := false
var ticking_interval := 1.0
var is_active := true setget set_is_active
var id := "base_effect"

var _time_left: float = -INF
var _ticking_clock := 0.0
var _can_stack := false
var _target


func _init(target, data) -> void:
    _target = target
    set_duration_seconds(data.duration_seconds)

    is_ticking = data.is_ticking
    ticking_interval = data.ticking_interval
    _ticking_clock = ticking_interval


func _ready() -> void:
    _start()


func _process(delta: float) -> void:
    _time_left -= delta * time_scale

    if is_ticking:
        var old_clock = _ticking_clock
        _ticking_clock = wrapf(_ticking_clock - delta * time_scale, 0.0, ticking_interval)
        if _ticking_clock > old_clock:
            _apply()

    if _time_left < 0.0:
        set_process(false)
        _expire()


func can_stack() -> bool:
    return _can_stack


func get_time_left() -> float:
    return _time_left


func expire() -> void:
    _expire()


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


func set_duration_seconds(value: float) -> void:
    duration_seconds = value
    _time_left = duration_seconds


func _start() -> void:
    pass


func _apply() -> void:
    pass


func _expire() -> void:
    queue_free()

StatusEffectHaste.gd

class_name StatusEffectHaste
extends StatusEffect

var speed_bonus := 0

var _stat_modifier_id := -1


func _init(target, data: StatusEffectData).(target, data) -> void:
    id = "haste"
    speed_bonus = data.effect_power


func _start() -> void:
    _stat_modifier_id = _target.stats.add_modifier("speed", speed_bonus)


func _expire() -> void:
    _target.stats.remove_modifier("speed", _stat_modifier_id)
    queue_free()

StatusEffectSlow.gd

class_name StatusEffectSlow
extends StatusEffect

var speed_reduction := 0.0 setget set_speed_rate

var _stat_modifier_id := -1


func _init(target, data: StatusEffectData).(target, data) -> void:
    id = "slow"
    speed_reduction = data.effect_rate


func _start() -> void:
    _stat_modifier_id = _target.stats.add_modifier(
        "speed", -1.0 * speed_reduction * _target.stats.speed
    )


func _expire() -> void:
    _target.stats.remove_modifier("speed", _stat_modifier_id)
    queue_free()


func set_speed_rate(value: float) -> void:
    speed_reduction = clamp(value, 0.01, 0.99)

StatusEffectBug.gd

class_name StatusEffectBug
extends StatusEffect

var damage := 3


func _init(target, data).(target, data) -> void:
    id = "bug"
    damage = data.ticking_damage
    _can_stack = true


func _apply() -> void:
    _target.take_hit(Hit.new(damage))