Godot Tactics RPG – 16. Status Effects

7thSage again. Sorry about the long delay. This lesson we’ll be working on adding a few status effects to characters, such as Slow, Stop, and Haste. We’ll also create a cursed weapon that will add Poison while equipped.

Turn Controller

Now that I’ve had a chance to go through this lesson, there are a couple small tweaks to the turn controller that we need to add. Open the script “TurnOrderController.gd”

The first change is we need to add the Unit to the turnCheckNotification signal. At the top where it is declared, change it to

signal turnCheckNotification(sender:Unit, exc:BaseException)

And in the function CanTakeTurn(), we need to update the line where we emit the signal.

turnCheckNotification.emit(target, exc)

Next, I want to create a signal for each Unit that we can listen to whenever an individual unit’s turn takes place. We’ll start by creating dictionary to hold the signals. Add it with the other signals and variables at the top of the script.

var _turnBeganNotification = {}

Instead of emitting the signal directly, we’ll create a function to get and create the signal. We use the unit.name as the dictionary key to lookup whether a signal exists.

func TurnBeganNotification(unit:Unit):
	var unitName = unit.name
	
	if(!_turnBeganNotification.has(unitName)):
		self.add_user_signal(unitName+"_turnBegan")
		_turnBeganNotification[unitName] = Signal(self, unitName+"_turnBegan")
		
	return _turnBeganNotification[unitName]

Because the function returns a signal, we can add .emit() to the end of our function call to send it. In the Round() function, in the second for loop, just after “bc.turn.Change(unit)”, add the line.

TurnBeganNotification(unit).emit()

Status Conditions

There are many reasons why an effect would be active on a unit. Whether it be from something equipped, or a spell cast, they are each going to have different requirements for how long it is active. Every Effect that we add, will have at least one Condition attached to it. When the condition is removed, we’ll check if there are any other conditions still keeping the Effect active, and if not, we’ll remove the Effect as well.

While we don’t normally use the base class directly, this time we’ll actually add it to any effect that we just want to remain active until we call on something specifically to delete the condition. This will be useful for things like a status that is active as long as a piece of equipment is equipped.

In the folder “Scripts->View Model Component->” create a folder named “Status”
Inside the “Status” folder, create two more named “Conditions” and “Effects”

In the “Conditions” folder, create a new script named “StatusCondition.gd”

The script itself is pretty simple, just a class_name, and the function Remove() to delete the condition. We’ll create the Status class a little later, but that will be where the actual deletion and adding of status effects will happen.

extends Node
class_name StatusCondition

func Remove():
	var status:Status = self.get_parent().get_parent()
	if status:
		status.Remove(self)

Duration Status Condition

For now, the only variation of StatusCondition that we’ll create is one that will last a certain amount of time. We set the default duration to a value of 10. Each value here is a single tick where every unit checks if they have accumulated enough on the turn counter CTR. So just be aware that this duration is not based on the unit it is on, and each value is smaller than a typical character turn.

To go with the previous comment, the signal roundBeganNotification is emitted at the beginning of the round loop before looping through each character.

I had considered setting “Turn Order Controller” with a unique name to call it, but at least in Godot 4.1, I was having issues with it working in this case. I think because the script didn’t start loaded into the tree. Because of that I just got the Turn Order Controller from root.

When the duration variable reaches zero, we call to delete the condition, which will remove the Status Effect if there isn’t anything else keeping it active.

In the folder “Conditions” again, create a script named “DurationStatusCondition.gd”

extends StatusCondition
class_name DurationStatusCondition

var duration:int = 10
var turnController

func _ready():
	turnController = get_node("/root/Battle/Battle Controller/Turn Order Controller")
	turnController.roundBeganNotification.connect(OnNewTurn)

func _exit_tree():
	turnController.roundBeganNotification.disconnect(OnNewTurn)

func OnNewTurn():
	duration -= 1
	if duration <= 0:
		Remove()

Status Effect

With both Status Effects and Status Conditions, its important to make sure all of them have a class_name, as we’ll need it to create our effects. While that is all the Status Effect base class has, we’ll also use that to ensure the scripts we are passing are all Status Effects.

In the folder “Scripts->View Model Component->Status->Effects” create a script named “StatusEffect.gd”

extends Node
class_name StatusEffect

Haste

In the same folder create a script named “HasteStatusEffect.gd”

extends StatusEffect
class_name HasteStatusEffect

var myStats:Stats
const speedModifier = 2

func _enter_tree():
	myStats = self.get_parent().get_parent().get_node("Stats")
	if myStats:
		myStats.WillChangeNotification(StatTypes.Stat.CTR).connect(OnCounterWillChange)
		
func _exit_tree():
	myStats.WillChangeNotification(StatTypes.Stat.CTR).disconnect(OnCounterWillChange)

func OnCounterWillChange(sender:Stats, exc:ValueChangeException):
	exc.AddModifier(MultDeltaModifier.new(0,speedModifier))

Here, we listen for any WillChangeNotification on the CTR stat, and to any value added, we multiply what is added by 2 with MultDeltaModifier.

Slow

To the same folder again, add a script named “SlowStatusEffect”

extends StatusEffect
class_name SlowStatusEffect

var myStats:Stats
const speedModifier = 0.5

func _enter_tree():
	myStats = self.get_parent().get_parent().get_node("Stats")
	if myStats:
		myStats.WillChangeNotification(StatTypes.Stat.CTR).connect(OnCounterWillChange)
		
func _exit_tree():
	myStats.WillChangeNotification(StatTypes.Stat.CTR).disconnect(OnCounterWillChange)

func OnCounterWillChange(sender:Stats, exc:ValueChangeException):
	exc.AddModifier(MultDeltaModifier.new(0,speedModifier))

This one is almost the exact same as Haste, the only difference is we multiply by 0.5 instead of 2. There are several ways we could have accomplished this. It could be a variable and a single class SpeedStatusEffect or something. Or we could inherit from a common class and just set the variable in the individual classes. I think at least having them as their own classes make sense. It will let us check in the future if those Effects are applied without having to checking the exact numbers.

If you do go the route of having Haste and Slow inheriting from a Speed class, just be aware that you can’t override variables in GDScript, so you’ll have to set them in _ready() or another function.

Stop

To the same folder add a script named “StopStatusEffect.gd”

extends StatusEffect
class_name StopStatusEffect

var myStats:Stats

func _enter_tree():
	myStats = self.get_parent().get_parent().get_node("Stats")
	if myStats:
		myStats.WillChangeNotification(StatTypes.Stat.CTR).connect(OnCounterWillChange)
		
func _exit_tree():
	myStats.WillChangeNotification(StatTypes.Stat.CTR).disconnect(OnCounterWillChange)

func OnCounterWillChange(sender:Stats, exc:ValueChangeException):
	exc.FlipToggle()

This is another one that works very similar to the previous two, but instead of multipyling the stat we flip the toggle to disable it. We could also multiply any of the changes by 0, but with programming, that may have some odd edge cases where the value isn’t exactly zero because of some float math issue.

Poison

One last Status Effect, to the same folder, create a script named “PoisonStatusEffect.gd”

extends StatusEffect
class_name PoisonStatusEffect

var unit:Unit
var turnController

func _enter_tree():
	turnController = get_node("/root/Battle/Battle Controller/Turn Order Controller")
	unit = self.get_parent().get_parent()
	if unit:
		turnController.TurnBeganNotification(unit).connect(OnNewTurn)
		
func _exit_tree():
	turnController.TurnBeganNotification(unit).disconnect(OnNewTurn)

func OnNewTurn():
	var s:Stats = unit.get_node("Stats")
	var currentHP:int = s.GetStat(StatTypes.Stat.HP)
	var maxHP:int = s.GetStat(StatTypes.Stat.MHP)
	var reduce:int = min(currentHP, floori(maxHP * 0.1))
	s.SetStat(StatTypes.Stat.HP, (currentHP - reduce), false)

In the previous effects, the signal we listened for was a change in a Stat, this time though we’ll be listening for the start of the unit’s turn, using the TurnBeganNotification signal that we created earlier. Whenever the start of their turn rolls around, we subtract one-tenth of their max HP. If they have less than that left, their current HP is subtracted instead, preventing the HP from dropping below 0.

Status

This is the script that was the most trouble converting into GDScript. I’m not sure I really liked any of the solutions, but I felt like this at least close to what I wanted. The Status class is what is in charge of actually managing adding and removing Status Effects.

In the folder “Scripts->View Model Component->Status” create a script named “Status.gd”

extends Node
class_name Status

signal AddedNotification(StatusEffect)
signal RemovedNotificication(StatusEffect)

func Add(status_effect:GDScript,status_condition:GDScript, effect_name:String = "Status Effect", condition_name:String = "Status Condition"):
	var effect = status_effect.new()
	if not effect is StatusEffect:
		print("Not Status Effect")
		return null
	
	var condition = status_condition.new()
	if not condition is StatusCondition:
		print("Not Status Condition")
		return null
	
	var children:Array[Node] = self.get_children()
	var filtered:Array[StatusEffect]
	filtered.assign(children.filter(func(node): return is_instance_of(node, status_effect)))
	
	if not filtered:
		self.add_child(effect)
		effect.name = effect_name
		AddedNotification.emit(effect)
	else:
		effect = filtered[0]
	
	effect.add_child(condition)
	condition.name = condition_name
	return condition

func Remove(target:StatusCondition):
	var effect:StatusEffect = target.get_parent()
	target.queue_free()
	
	var children:Array[Node] = effect.get_children()
	var condition:Array[StatusCondition]
	condition.assign(children.filter(func(node): return node is StatusCondition && !node.is_queued_for_deletion()))
	if condition.is_empty():
		RemovedNotificication.emit(effect)
		effect.queue_free()

In the Add() function, I wanted to keep things as readable as possible, so I wanted to avoid having to pass in a full script path to each resource. Storing the class_name in a string might work, but is tricky trying to check for class names, and we’d have to recreate the path to the script to load it. Passing an empty variable doesn’t work, because GDScript will just detect it’s type as null until after something is added to it. So I settled on this. I can pass the script in using the class_name directly. I can call .new() on it to get an object, and I can use it to check for type. It’s still not a perfect solution, but I think it is as good as at least I’m going to get. The main thing it lacked, which I think is fixed in newer versions of Godot, is that I can’t access the class_name, so I can’t grab it via code to use as the object names. To get around this I just passed an additional string value to use as the name of the nodes created.

Aside from that, the script itself is fairly straight forward. I create an instance of both the Effect and Condition. Once we have an instance of the class, we can check if the types are valid.

Next we check if the effect is already added, and if not, we add the effect we created and set its name to the string we passed into the function, and we emit a notification to say that we added it.

Lastly in Add(), we add the Condition as a child of the Effect.

Remove() is called when a Condition calls to remove itself. We check if there is any other Conditions attached aside from the ones being removed, and if not, we delete the StatusEffect as well.

Add Status Feature

As mentioned earlier, one of the ways we’ll be able to apply a Status Effect to a character, will be a part of weapons that we equip. To that end we’ll create a new Feature that can be added as part of our weapons.

When this feature is applied, we get the Status node and call Add() to add the status effect. Because we’ll need to know what effect we added so we can remove later, we keep track of the status condition in the variable “condition”.

To the folder “Scripts->View Model Component->Features” add a new script named “AddStatusFeature.gd”

extends Feature
class_name AddStatusFeature

var statusEffect:GDScript
var statusString:String
var conditionString:String
var condition:StatusCondition #So we know what to delete later

func OnApply():
	var status:Status = self.get_parent().get_parent().get_parent().get_node("Status")	
	condition = status.Add(statusEffect, StatusCondition, statusString, conditionString)

func OnRemove():
	if condition:
		condition.Remove()

Poison Status Feature

To create variations for different status effects, we just need to set our class_name and the variables we need to pass along.

extends AddStatusFeature
class_name AddPoisonStatusFeature

func _ready():
	statusEffect = PoisonStatusEffect
	statusString = "Poison"
	conditionString = "Status Condition"

Hero Prefab

Before we get onto the demo, we need to add a little to the Hero Prefab.

Open up the “Hero.tscn” prefab scene.

Under the main “Hero” node, create a child of type Node, named “Status”. To that attach the script “Status.gd” that we created in this lesson.

Again under the main “Hero” node, create another child of type Node. Name this one “Equipment” and add the script “Equipment.gd” that we created in a previous lesson. You can find it in the folder “Scripts->View Model Component->Actor”

If you are testing the “Monster.tscn” prefab, you can add the nodes and scripts to that file as well. Otherwise we’ll figure that out when we get both teams on the board in a later lesson.

Demo

And that’s all the classes we need for this lesson, so lets go ahead and create a quick demo to test out some of these new features. Create a new test script named something like “StatusEffectDemo.gd” and back in our “Battle.tscn” scene, attach it to a test node. If you have one from a previous lesson, remove the test script and replace it with this one.

extends Node

var step:int = 0
var cursedUnit:Unit
var cursedItem:Equippable

func _ready():
	var turnController = get_node("/root/Battle/Battle Controller/Turn Order Controller")
	turnController.turnCheckNotification.connect(OnTurnCheck)

func OnTurnCheck(target:Unit, exc:BaseException):
	if not exc.toggle:
		return
	
	match step:
		0:
			EquipCursedItem(target)
		1:
			Add(target, "Slow", SlowStatusEffect, 15 )
		2:
			Add(target, "Stop", StopStatusEffect, 15 )
		3:
			Add(target, "Haste", HasteStatusEffect, 15 )			
		_:
			UnEquipCursedItem(target)
		
	step += 1

func Add(target:Unit, effect_name:String, effect:GDScript, duration:int):
	var status:Status = target.get_node("Status")
	var condition:DurationStatusCondition
	condition = status.Add(effect , DurationStatusCondition, effect_name, "Duration Condition")
	condition.duration = 15
	
func EquipCursedItem(target:Unit):
	cursedUnit = target
	
	var obj = Node.new()
	obj.name = "Cursed Sword"
	
	var poisonFeature = AddPoisonStatusFeature.new()
	poisonFeature.name = "Poison Feature"
	obj.add_child(poisonFeature)
	
	cursedItem = Equippable.new()
	cursedItem.name = "Equippable"
	obj.add_child(cursedItem)
	
	var equipment:Equipment = target.get_node("Equipment")
	equipment.Equip(cursedItem, EquipSlots.Slot.PRIMARY )
	
	
func UnEquipCursedItem(target:Unit):
	if target != cursedUnit || step < 10:
		return
	
	var equipment:Equipment = target.get_node("Equipment")
	equipment.UnEquipItem(cursedItem)
	cursedItem.queue_free()
	
	#Once cursed item is removed, we don't need this script anymore
	self.queue_free()

If we look at the Scene view and choose the “Remote” tab, we can see the status effects and equipment created in the hierarchy. You’ll also be able to see the counter values in the inspector if you select one of the durations.

You’ll see that as the units take their turns, each of them will have a Status Effect applied, and if you continue taking turns, you’ll see the test node delete itself along with the Status Effects a few turns later. You should also notice that one of the units won’t take any further turns until the effect is removed, and the other two have their rate of turns impacted.

The picture shows a sample of the remote scene view. It also gives a good glimpse of what the hierarchy looks like.

Summary

That’s it for this lesson. This time around we created a Status Effects and a way to add and remove them. We added a Haste effect that speeds up how fast turns come around, and a Stop effect that prevented turns altogether. We also created a Cursed Sword that adds a poison effect. There is still more to add, but the project is starting to shape up.

As always, you can find the code in the repository, and if you have questions feel free to ask in the comments below.

Leave a Reply

Your email address will not be published. Required fields are marked *