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:
In this part, we’re focusing on the effects themselves.
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
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()
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)
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))
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))