Godot Tactics RPG – 18. Ability Effects

7thSage again. This lesson we’ll be fleshing out our ability system a bit more. Remove a few of those temporary functions and get to doing some damage. There will still be a bit more to implement in the next section along with the magic as well, but this will be a solid step along the way. I’ve taken the liberty of refactoring some of the code from the next lesson into this one to consolidate some of the changes. So there should be a little less refactoring in the next lesson, though there will still have to be some.

Refactoring

First up is some code from the previous lesson. Had I been more attentive I would have brought these changes to that lesson, but they are relatively minor anyway. In the “HitRate.gd” script we don’t really need to pass the attacker variable, as the script will be attached to that unit. We’ll also need to adjust the scripts that inherit or call from it.

Hit Rate

We added one more variable to hold the attacker at the top of the script. Instead of passing the attacker in the functions, we set it in the _ready() function. To get the unit, we’ll be adding a function to the battle controller we can use to find the parent Unit node in the tree. The next 4 functions, Calculate(), AutomaticHit(), AutomaticMiss() and AdjustForStatusEffects() we need to remove the variable “attacker:Unit” from all the function parameters, and in the Calculate() function we’ll also be changing the type from “Unit” to “Tile” to later give us the option to target the tile if we want to do things like lay traps on the ground or interact with objects that are not of the type “Unit”.

Also, while we are here, let’s add one quick function that we’ll be using later in the lesson. RollForHit(), which will generate a random number, call Calculate, and return true or false depending on whether it is higher than the value we returned with Calculate()

Open the script “HitRate.gd”

extends Node
class_name HitRate

var hitIndicator:HitSuccessIndicator
var attacker:Unit

func _ready():
	var battle:BattleController = get_node("/root/Battle/Battle Controller")
	hitIndicator = battle.get_node("Hit Success Indicator")
	attacker = battle.GetParentUnit(self)

func Calculate(target:Tile):
	pass

func AutomaticHit(target:Unit)->bool:
	var exc:MatchException = MatchException.new(attacker, target)
	hitIndicator.AutomaticHitCheckNotification.emit(exc)
	return exc.toggle

func AutomaticMiss(target:Unit)->bool:
	var exc:MatchException = MatchException.new(attacker, target)
	hitIndicator.AutomaticMissCheckNotification.emit(exc)
	return exc.toggle

func AdjustForStatusEffects(target:Unit, rate:int)->int:
	var args:Info = Info.new(attacker, target, rate)
	hitIndicator.StatusCheckNotification.emit(args)
	return args.data

func Final(evade:int):
	return 100 - evade

func RollForHit(target:Tile):
	var roll:int = randi_range(0, 100)
	var chance:int = Calculate(target)
	return roll <= chance

A-Type Hit Rate

Here the changes are just removing “attacker” from the functions, however we do also have to add quick line to get the defender from the tile object in Calculate(). Be careful that you don’t remove “attacker” from the call to “GetFacing()”. That’s not in the HitRate scripts, so it still needs “attacker” passed to it.

Open the script “ATypeHitRate.gd”

extends HitRate
class_name ATypeHitRate

func Calculate(target:Tile):
	var defender = target.content
	if(AutomaticHit(defender)):
		return Final(0)
	
	if(AutomaticMiss(defender)):
		return Final(100)
		
	var evade:int = GetEvade(defender)
	evade = AdjustForRelativeFacing(defender, evade)
	evade = AdjustForStatusEffects(defender, evade)
	evade = clamp(evade, 5, 95)
	return Final(evade)

func GetEvade(target:Unit):
	var s:Stats = target.get_node("Stats")
	return clamp(s.GetStat(StatTypes.Stat.EVD), 0, 100)

func AdjustForRelativeFacing(target:Unit, rate:int):
	match Directions.GetFacing(attacker, target):
		Directions.Facings.FRONT:
			return rate
		Directions.Facings.SIDE:
			return rate/2
		_:
			return rate/4

S-Type Hit Rate

This one is very similar to the previous script.

Open the script “STypeHitRate.gd”

extends HitRate
class_name STypeHitRate

func Calculate(target:Tile):
	var defender = target.content
	if(AutomaticMiss(defender)):
		return Final(100)	
	
	if(AutomaticHit(defender)):
		return Final(0)
		
	var res:int = GetResistance(defender)
	res = AdjustForStatusEffects(defender, res)
	res = AdjustForRelativeFacing(defender, res)
	
	res = clamp(res, 0, 100)
	return Final(res)

func GetResistance(target:Unit):
	var s:Stats = target.get_node("Stats")
	return clamp(s.GetStat(StatTypes.Stat.RES), 0, 100)

func AdjustForRelativeFacing(target:Unit, rate:int):
	match Directions.GetFacing(attacker, target):
		Directions.Facings.FRONT:
			return rate
		Directions.Facings.SIDE:
			return rate - 10
		_:
			return rate - 20

Full Type Hit Rate

Open the script “FullTypeHitRate.gd”

extends HitRate
class_name FullTypeHitRate

func Calculate(target:Tile):	
	var defender = target.content
	if(AutomaticMiss(defender)):
		return Final(100)
		
	return Final(0)

Battle Controller

As getting the Unit a script is parented to is a common operation, I thought it was time that we created a small helper function that will let us find the node of type Unit, that is in the tree somewhere above a node. The function will search recursively until either there is no parent, or it finds one of type “Unit”.

Open up the script “BattleController.gd” and add the following function.

func GetParentUnit(node:Node):
	var parent = node.get_parent()
	if parent == null:
		return null
	if parent is Unit:
		return parent
	return GetParentUnit(parent)

Confirm Ability Target State

Open the script “ConfirmAbilityTargetState.gd”

We’ll come back to this script soon, but for now we only need two small changes, both in the function CalculateHitRate(),

First, we need to change the type of the variable “target”, and then set the tile itself to that variable, instead of the .content(aka unit) that is attached to it.

var target:Tile = turn.targets[index]

The second change we need to remove “turn.actor” in the line “return hr.Calculate(turn.actor, target)”

return hr.Calculate(target)

Once that is done we can test the code to make sure it still functions as it had previously.

Hierarchy

Before getting into the next bit of code, there are some changes to the Node hierarchy for attacks and how it is set up on the character. The first change is that each attack or spell will have a script named “Ability” attached to it. Originally this was part of the next lesson, but I moved it to this one. This will hold some functionality we can use, and also identify which nodes are skills. We’ll also be separating each damage or status effect in the attack, so Hit Rate and Ability Effect Target will have a slightly different path to access them.

Ability

Create a script named “Ability.gd” in the folder “Scripts->View Model Component->Ability”

This will be attached to each ability, which will make them easier to identify as such in the tree, and will be in charge of determining whether an ability can be performed, performing the ability and will send out signals depending on whether the ability is performed.

extends Node
class_name Ability

var battle:BattleController
var abilityController:AbilityMenuPanelController

func _ready():
	battle = get_tree().root.get_node("Battle/Battle Controller")
	abilityController = battle.abilityMenuPanelController

func CanPerform():
	var exc:BaseException = BaseException.new(true)
	abilityController.CanPerformCheck.emit(exc)
	return exc.toggle

func Perform(targets:Array[Tile]):
	if not CanPerform():
		abilityController.FailedNotification.emit()
		return
	
	for target in targets:
		_Perform(target)
	abilityController.DidPerformNotification.emit()

func _Perform(target:Tile):
	var children:Array[Node] = self.find_children("*", "BaseAbilityEffect", false)
	for child in children:
		var effect:BaseAbilityEffect = child as BaseAbilityEffect
		effect.Apply(target)

Turn

Now that we have an actual Ability type, in the script “Turn.gd” we can set that as the type instead of the standard Node.

var ability: Ability

Ability Menu Panel Controller

We need a few more signals for our abilities. Since they are Ability related, I decided to place them in “AbilityMenuPanelController.gd” for ease of finding.

signal GetAttackNotification(info:Info)
signal GetDefenseNotification(info:Info)
signal GetPowerNotification(info:Info)
signal TweakDamageNotification(info:Info)

signal CanPerformCheck(exc:BaseException)
signal FailedNotification()
signal DidPerformNotification()

Hit Success Indicator

A couple more signals. This time related to attacks hitting or missing that we’ll use for our Hit Success Panel, so I placed them in “HitSuccessIndicator.gd”

signal MissedNotification
signal HitNotification

Base Ability Effect

Each ability can have one or more effects attached to it, such as inflicting damage, adding a status effect and so on. Each effect will have a script inheriting from Base Ability Effect attached to it. The main two functions in this script will be to provide a function for Predict() to fill out our HitSuccessIndicator, and Apply() that will apply the effect. Apply will send a notification for hit or miss depending on whether the RollForHit() succeeds. I’ve also added some print statements so we can see whether the attack hit or missed more easily. There is also a function that will be used in several of the subclasses GetStat() that makes sense to have here.

Create a new folder named “Effects” in the folder “Scripts->View Model Component->Ability” and inside the folder we just made, create a script named “BaseAbilityEffect.gd”

extends Node
class_name BaseAbilityEffect

const minDamage:int = -999
const maxDamage:int = 999
var battle:BattleController
var abilityCont:AbilityMenuPanelController
var hitController:HitSuccessIndicator

func _ready():
	battle = get_tree().root.get_node("Battle/Battle Controller")
	abilityCont = battle.abilityMenuPanelController
	hitController = battle.hitSuccessIndicator
	

func Predict(target:Tile):
	pass

func Apply(target:Tile):
	if self.get_node("Ability Effect Target").IsTarget(target) == false:
		return
	if self.get_node("Hit Rate").RollForHit(target):
		print("Hit")
		hitController.HitNotification.emit(OnApply(target))
	else:
		print("Missed")
		hitController.MissedNotification.emit()

func OnApply(target:Tile):
	pass

func GetStat(attacker:Unit, target:Unit, notifier:Signal, startValue:int):
	var mods:Array[ValueModifier]
	var info = Info.new(attacker, target, mods)
	notifier.emit(info)
	
	mods.sort_custom(func(a, b): return a.sortOrder < b.sortOrder)
	
	var value:float = startValue
	for mod in mods:
		value = mod.Modify(startValue, value)
	
	var retValue:int = floori(value)
	retValue = clamp(retValue, minDamage, maxDamage)
	return retValue

Damage Ability Effect

The first subclass of Base Ability Effect, and the one we will use most often. In predict we get the stats of the attacker and defender, and set a formula to calculate the damage, while taking into account the various modifiers we have available. With the modifiers and playing with the damage formula, you should be able to create a wide array of RPG damage formulas.

In OnApply() we take the calculations from Predict() and apply some random variance to them. Once that is complete, we finish by finalizing the stat changes.

Create a new file named “DamageAbilityEffect.gd” in the same folder

extends BaseAbilityEffect
class_name DamageAbilityEffect

func Predict(target:Tile):
	var attacker:Unit = battle.GetParentUnit(self)
	var defender:Unit = target.content
	
	# Get the attackers base attack stat considering
	# mission items, support check, status check, and equipment, etc
	var attack:int = GetStat(attacker, defender, abilityCont.GetAttackNotification,0)
	
	# Get the targets base defense stat considering
	# mission items, support check, status check, and equipment, etc
	var defense:int = GetStat(attacker, defender, abilityCont.GetDefenseNotification, 0)
	
	# Calculate base damage
	var damage:int = attack - (defense / 2)
	damage = max(damage, 1)
	
	# Get the abilities power stat considering possible variations
	var power:int = GetStat(attacker, defender, abilityCont.GetPowerNotification, 0)
	
	# Apply power bonus
	damage = damage * power / 100
	damage = max(damage,1)
	
	# Tweak the damage based on a variety of other checks like
	# Elemental damage, Critical Hits, Damage multipliers, etc.
	damage = GetStat(attacker, defender, abilityCont.TweakDamageNotification, damage)
	
	# Clamp the damage to a range
	damage = clamp(damage, minDamage, maxDamage)
	return -damage

func OnApply(target:Tile):
	var defender:Unit = target.content
	
	# Start with the predicted damage value
	var value:int = Predict(target)
	
	# Add some random variance
	value = floori(value * randf_range(.9,1.1))
	
	# Apply the damage to the target
	var s:Stats = defender.get_node("Stats")
	var currentHP = s.GetStat(StatTypes.Stat.HP)
	s.SetStat(StatTypes.Stat.HP, currentHP + value)
	return value

Inflict Ability Effect

Because there is no damage dealt directly by this script, Predict() only needs to return 0. Once we attach the script to a scene, we’ll need to set up the three @export variables. “status_effect” we’ll drag the script we want to add to a character. This should be of type StatusEffect. “effect_name” will be used to name the node, so it should match the effect, and “duration” is the number of ticks the effect will stay in effect.

OnApply() we grab the “Status” node from our target and call .add() to create the effect.

Create a new script named “InflictAbilityEffect.gd” in the same folder as before.

extends BaseAbilityEffect
class_name InflictAbilityEffect

@export var status_effect:GDScript
@export var effect_name:String
@export var duration:int

func Predict(target:Tile):
	return 0

func OnApply(target:Tile):
	var unit:Unit = target.content
	if unit == null:
		return 0
	
	var status:Status = unit.get_node("Status")
	
	var condition:DurationStatusCondition
	condition = status.Add(status_effect , DurationStatusCondition, effect_name, "Duration Condition")
	condition.duration = duration
	
	return 0

Base Ability Power

In the previous script for damage, we added power to the equation like this “damage = damage * power / 100”, so we can think of power like a percentage multiplier. We won’t attach this base class directly to our attacks, but it will provide several things for the classes that inherit from it. First it connects all the signals that we’ll need to watch from. Along with the signals we have the functions that are called when the signals emit that will be in charge of the basic tasks of getting those stats. The pieces of those functions that vary depending on the power type, we split into their own functions that we will override in the next classes.

In the folder “Scripts->View Model Component->Ability”, create a new folder named “Power”.
Create a new script named “BaseAbilityPower.gd” in the folder we just created.

extends Node
class_name BaseAbilityPower

var battle:BattleController
var abilityController:AbilityMenuPanelController

func _ready():
	battle = get_tree().root.get_node("Battle/Battle Controller")
	abilityController = battle.abilityMenuPanelController
	abilityController.GetAttackNotification.connect(OnGetBaseAttack)
	abilityController.GetDefenseNotification.connect(OnGetBaseDefense)
	abilityController.GetPowerNotification.connect(OnGetPower)
	
func _exit_tree():
	abilityController.GetAttackNotification.disconnect(OnGetBaseAttack)
	abilityController.GetDefenseNotification.disconnect(OnGetBaseDefense)
	abilityController.GetPowerNotification.disconnect(OnGetPower)

func OnGetBaseAttack(info:Info):
	if info.attacker != battle.GetParentUnit(self):
		return
		
	var mod:AddValueModifier = AddValueModifier.new(0, GetBaseAttack())
	info.data.append(mod)

func OnGetBaseDefense(info:Info):
	if info.attacker != battle.GetParentUnit(self):
		return
		
	var mod:AddValueModifier = AddValueModifier.new(0, GetBaseDefense(info.target))
	info.data.append(mod)

func OnGetPower(info:Info):
	if info.attacker != battle.GetParentUnit(self):
		return
		
	var mod:AddValueModifier = AddValueModifier.new(0, GetPower())
	info.data.append(mod)

func GetBaseAttack():
	pass
func GetBaseDefense(target:Unit):
	pass
func GetPower():
	pass

Physical Ability Power

This type of power will be used for attacks that are physical based. Because of this we’ll be grabbing the standard ATK and DEF stats for GetBaseAttack() and GetBaseDeffense(). As mentioned previously we should think of power as a percentage modifier for our attacks, which we’ll hold in the variable “level”, not to be confused with the characters actual level. We have that as an @export variable so we can access it while creating our skills.

Create a new script named “PhysicalAbilityPower.gd” in the same folder.

extends BaseAbilityPower
class_name PhysicalAbilityPower

@export var level:int

func GetBaseAttack():
	var stats:Stats = battle.GetParentUnit(self).get_node("Stats")
	return stats.GetStat(StatTypes.Stat.ATK)
	
func GetBaseDefense(target:Unit):
	var stats:Stats = target.get_node("Stats")
	return stats.GetStat(StatTypes.Stat.DEF)
	
func GetPower():
	return level

Magical Ability Power

This one is very similar to the last one, but this time we’ll be grabbing the stats MAT(Magic Attack) and MDF(Magic Defense).

extends BaseAbilityPower
class_name MagicalAbilityPower

@export var level:int

func GetBaseAttack():
	var stats:Stats = battle.GetParentUnit(self).get_node("Stats")
	return stats.GetStat(StatTypes.Stat.MAT)
	
func GetBaseDefense(target:Unit):
	var stats:Stats = target.get_node("Stats")
	return stats.GetStat(StatTypes.Stat.MDF)
	
func GetPower():
	return level

Weapon Ability Power

This one is fairly similar to Physical, but instead of setting the power directly, we grab it from the weapon, or if no weapon is equipped, we use the base stat from the job.

extends BaseAbilityPower
class_name WeaponAbilityPower

func GetBaseAttack():
	var stats:Stats = battle.GetParentUnit(self).get_node("Stats")
	return stats.GetStat(StatTypes.Stat.ATK)
	
func GetBaseDefense(target:Unit):
	var stats:Stats = target.get_node("Stats")
	return stats.GetStat(StatTypes.Stat.DEF)
	
func GetPower():
	var power:int = PowerFromEquippedWeapon()
	return power if power > 0 else UnarmedPower()
	
func PowerFromEquippedWeapon():
	var power = 0
	var eq:Equipment = battle.GetParentUnit(self).get_node("Equipment")
	var item:Equippable = eq.GetItem(EquipSlots.Slot.PRIMARY)
	var features:Array[StatModifierFeature] 
	var children = item.get_children()
	features.assign(children.filter(func(node): return is_instance_of(node, StatModifierFeature)))
	
	for feature in features:
		if feature.type == StatTypes.Stat.ATK:
			power = power + feature.amount
	return power
	
func UnarmedPower():
	var job:Job = battle.GetParentUnit(self).get_node("Job")
	for i in job.statOrder.size():
		if job.statOrder[i] == StatTypes.Stat.ATK:
			return job.baseStats[i]
	return 0

Equipment

In the previous script we called GetItem(), so we need to add that function to “Equipment.gd” real quick. This function loops through the items in _items, and returns an Equippable if there is one equipped.

func GetItem(slots:EquipSlots.Slot):
	for i in range(_items.size()-1,-1,-1):
		var item = _items[i]
		if (item.slots & slots) != EquipSlots.Slot.NONE:
			return item
	return null

Confirm Ability Target State

As I mentioned earlier, we’re back to this script, “ConfirmAbilityTargetState.gd”. We’ll be making changes to two sections of this script. The first changes are to the functions FindTargets() and IsTarget(). Because the hierarchy has changed a bit we’ll be looking over a list of BaseAbilityEffect instead of AbilityEffectTarget. Some of the logic also moved from FindTargets() to IsTarget()

func FindTargets():
	turn.targets = []
	
	for tile in tiles:
		if(IsTarget(tile)):
			turn.targets.append(tile)

func IsTarget(tile:Tile):
	var children:Array[Node] = turn.ability.find_children("*", "BaseAbilityEffect", false)
	for child in children:
		var targeter:AbilityEffectTarget = child.get_node("Ability Effect Target")
		if targeter.IsTarget(tile):
			return true
	return false

The next section is with the functions UpdateHitSuccessIndicator(), CalculateHitRate() and EstimateDamage(). We’ll be eliminating the latter two functions and roll the changes into UpdateHitSuccessIndicator(). This will reduce the amount of times we need to iterate, and we’ll add the call to get an actual value for the estimated damage.

func UpdateHitSuccessIndicator():
	var chance:int = 0
	var amount:int = 0
	var target:Tile = turn.targets[index]
	
	var children:Array[Node] = turn.ability.find_children("*", "BaseAbilityEffect", false)
	for child in children:
		var targeter:AbilityEffectTarget = child.get_node("Ability Effect Target")
		if targeter.IsTarget(target):
			var hitRate:HitRate = child.get_node("Hit Rate")
			chance = hitRate.Calculate(target)
			amount = child.Predict(target)
			break
	
	hitSuccessIndicator.SetStats(chance, amount)

Perform Ability State

In the script “PerformAbilityState.gd” we need to make a couple tweaks. We’ll start in the Animate() function, rename “TemporaryAttackExample()” to “ApplyAbility()”. We’ll also be sure to rename the function it is pointing to the same. We’ve moved the logic of that function to the Ability class, so we can delete all of its contents, and we’ll just make a call to the function in ability where the logic now resides.

func Animate():
	#TODO play animations, etc
	
	#TODO apply ability effect, etc
	
	ApplyAbility()
	if(turn.hasUnitMoved):
		_owner.stateMachine.ChangeState(endFacingState)
	else:
		_owner.stateMachine.ChangeState(commandSelectionState)

func ApplyAbility():
	turn.ability.Perform(turn.targets)

Demo

We have enough now that we can build up quite a few different abilities, though we still need a little bit more for full magic. For now, lets modify our “Attack” skill to test out some of our abilities.

For each Ability, we can have multiple effects. For attack, one effect could be Damage, while another may be something like inflicting Sleep or Blind. Each effect will have a script inheriting from “BaseAbilityEffect”, and we’ll need two nodes as children, a “HitRate”, and an “AbilityEffectTarget”

So lets start by opening the “Hero.tscn” Prefab.

In the scene view, attach the script “Ability.gd” to the node “Attack”. Next, under the Attack node, create three child Nodes, the first named, “Physical Ability Power” which we’ll attach the script “PhysicalAbilityPower.gd” to, and two more for the ability effects named “Damage” and “Inflict Blind”. Move the nodes “Ability Effect Target” and “Hit Rate” so they are children of Damage instead of Attack directly. Once you are done, copy those two nodes, and paste them to create duplicates as children of “Inflict Blind” so we have them as children of both “Damage” and “Inflict Blind”

In the Scene view, select the “Physical Ability Power” node, and in the inspector, set the “Level” to 45. This will set our attack to deal 45% damage with the attack.

To the node “Damage” attach the script “DamageAbilityEffect.gd”. For the children of Damage, attach the script “DefaultAbilityEffect.gd” to the node “Ability Effect Target”, and “ATypeHitRate.gd” to the node “Hit Rate”

For the node “Inflict Blind” attach the script “InflictAbilityEffect.gd”. For the children of Inflict Blind, attach the script “DefaultAbilityEffect.gd” to the node “Ability Effect Target”, and “STypeHitRate.gd” to the node “Hit Rate”. In the inspector for the Inflict Blind node under “Status Effect” load the script “BlindStatusEffect.gd” from the folder “Scripts->View Model Component->Status->Effects”, and for “Effect Name”, give it the name “Blind”. We’ll also need to give it a duration. Experiment with different values to see how long the effect lasts. I set it at 50 to test.

Save the scene.

If you haven’t already done so, you can remove the script on the Test node in the Battle scene. Now try running the Battle scene and attacking the units. If you look at the Scene View under the Remote tab, you should be able to see the blind status being added to the units hierarchy if the attack doesn’t miss.

Summary

We made a pretty big step into getting our skill system fully working. There are just a few more pieces we need to add, like magic, which we’ll tackle in the next lesson. But for now, our attacks can do things like deal damage, and even add status effects with them. As well handle getting the various stats for the different types of attacks.

As always, feel free to ask questions below, or check out the code at the repository.

Leave a Reply

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