7thSage again. Sorry for the long delay, but I’m back once again for another lesson. If you haven’t noticed, I’ve begun working on getting video versions of the lessons up on Youtube, which has also distracted me a bit. They follow pretty close to the written version, but I have added a few little bits here and there. This lesson though, I’ve been looking forward to getting finished up for a while, and I really wanted to get it right. I’ve added a couple additional spell/skills to this lesson that I think people will enjoy playing with that will hopefully make the wait worth it. Along with adding several different magic spells, we’ll also be dealing with casting cost and using up some of those MP that our characters have.
Bugs & Refactor
I’ve realized that I made a couple mistakes in my assumptions in a couple of the scripts from the previous lesson. I failed to realize that the checks in “BaseAbilityPower.gd” were trying to check if the signal was coming from the right skill, and I had assumed that we needed to check if they were coming from the right unit. This was causing damage to be calculated incorrectly, adding more to the attack and defense for every skill a character had.
So open up that script, “BaseAbilityPower.gd” and we’ll start with those checks in the functions OnGetBaseAttack(), OnGetBaseDefense(), and OnGetPower(). Replace the lines “if info.attacker != battle.GetParentUnit(self):” with “if sender.get_parent() != self.get_parent():”, and we’ll also need to add “sender:Node” to the function arguments, which we’ll deal with passing along to the function in a moment.
func OnGetBaseAttack(sender:Node, info:Info): if sender.get_parent() != self.get_parent(): return var mod:AddValueModifier = AddValueModifier.new(0, GetBaseAttack()) info.data.append(mod) func OnGetBaseDefense(sender:Node, info:Info): if sender.get_parent() != self.get_parent(): return var mod:AddValueModifier = AddValueModifier.new(0, GetBaseDefense(info.target)) info.data.append(mod) func OnGetPower(sender:Node, info:Info): if sender.get_parent() != self.get_parent(): return var mod:AddValueModifier = AddValueModifier.new(0, GetPower()) info.data.append(mod)
With those changes, we also need to change up the signals we emit in “BaseAbilityEffect.gd”. Here we just need to add the sender to what we emit. In the GetStat() function, replace the emit line with the following:
notifier.emit(self, info)
In “AbilityMenuPanelController.gd”, lets add the sender to the signal definition.
signal GetAttackNotification(sender:Node, info:Info) signal GetDefenseNotification(sender:Node, info:Info) signal GetPowerNotification(sender:Node, info:Info) signal TweakDamageNotification(sender:Node, info:Info)
While we are here, we need to delete the signals CanPerformCheck, FailedNotification, and DidPerformNotification and move them to the script “Ability.gd”.
signal CanPerformCheck(exc:BaseException) signal FailedNotification() signal DidPerformNotification()
In “Ability.gd” we add those lines, and we can remove the references to abilityController. At the top we can remove the variable “abilityController”, which we no longer need to set in the _ready() function. Then in CanPerform() and Perform(), when we emit the signals, we can do so directly without adding “abilityController.” to the beginning.
extends Node
class_name Ability
signal CanPerformCheck(exc:BaseException)
signal FailedNotification()
signal DidPerformNotification()
var battle:BattleController
func _ready():
battle = get_tree().root.get_node("Battle/Battle Controller")
func CanPerform():
var exc:BaseException = BaseException.new(true)
CanPerformCheck.emit(exc)
return exc.toggle
func Perform(targets:Array[Tile]):
if not CanPerform():
FailedNotification.emit()
return
for target in targets:
_Perform(target)
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)
There is also a bug in “SpecifyAbilityArea.gd”, in the function ExpandSearch() we are calculating the distance wrong causing an issue where the center tile is not excluded in certain ranges.
func ExpandSearch(from:Tile, to:Tile): return abs(from.pos.x - to.pos.x) + abs(from.pos.y - to.pos.y) <= horizontal && abs(to.height - tile.height) <= vertical
There is a little bit we need to fix and refactor in the ability area of effect, “LineAbilityArea.gd”. Later in the lesson we’ll be changing the hierarchy on the Hero where the bulk of the skills will be listed under the Ability Catalog node. Because of this, we’ll need to access the unit, and its tile slightly different. In the function GetTilesInArea(), replace the line “unitTile = self.get_node(“../../../”).tile” with
unitTile = board.get_parent().GetParentUnit(self).tile
A little further down in the same function inside the for loop, we need to remove the “+1” in the first if statement, as it should be just “numberSteps” not “numberSteps +1”
if !extendPastTarget && i > numberSteps:
In “AbilityRange.gd”, also because we will be changing the hierarchy a little when we add our Ability Catalog, we need to change how we grab the parent Unit node in getter for the variable “unit”
var unit:Unit:
get:
var battle:BattleController
battle = get_tree().root.get_node("Battle/Battle Controller")
return battle.GetParentUnit(self)
The next a user, Jimmy Z. pointed out that there is an error in the script “Job.gd” in the function “Unemploy()”. Because the Stat Modifier Features are direct children of Job, and not the Unit itself, instead of “var features:Array[Node] = self.get_parent().get_children()” it should be the following.
var features:Array[Node] = self.get_children()
There may be some other lingering bugs, and some other things we may need to refactor later, but for now that should be enough to get us going with implementing the rest of our magic system. This would also be a good time to double check that everything is still working.
Stat Wrappers
We’ll be creating two new stat wrappers in this lesson. Health and Mana. Similar to how we created in Part 9 – Stats
Health
There was some questions earlier about preventing our characters HP from going below 0, or above the max. We’ll be accomplishing that now with this script. With Attack working, and shortly Cure, it we’ll need this going forward.
Whenever the Will Change notification is sent, we’ll use a ClampValueModifier to clamp the Hit Points within legal limits. It could also be possible to extend this a bit to support a minimum value as well in order to support things like story enemies that can’t be killed for whatever reason.
For Max Hit Points, we’ll listen for the Did Change notification. Useful for things like when a character levels up, or equips gear that changes their max. Here if the Max HP drops, we clamp the value, but don’t remove any HP that we may have added, but when Max HP goes up, we also increase the amount of HP a character has by the same amount. You may want to tweak this depending on the behavior you are looking for.
Create a new script named “Health.gd” in the folder “Scripts->View Model Component->Actor”
extends Node
class_name Health
var stats:Stats
var HP:int:
get:
return stats.GetStat(StatTypes.Stat.HP)
set(value):
stats.SetStat(StatTypes.Stat.HP, value)
var MHP:int:
get:
return stats.GetStat(StatTypes.Stat.MHP)
set(value):
stats.SetStat(StatTypes.Stat.MHP, value)
func _ready():
stats = get_node("../Stats")
stats.WillChangeNotification(StatTypes.Stat.HP).connect(OnHPWillChange)
stats.DidChangeNotification(StatTypes.Stat.MHP).connect(OnMHPDidChange)
func _exit_tree():
stats.WillChangeNotification(StatTypes.Stat.HP).disconnect(OnHPWillChange)
stats.DidChangeNotification(StatTypes.Stat.MHP).disconnect(OnMHPDidChange)
func OnHPWillChange(sender:Stats, vce:ValueChangeException):
vce.AddModifier(ClampValueModifier.new(999999, 0, MHP))
func OnMHPDidChange(sender:Stats, oldValue:int):
if MHP > oldValue:
HP = HP + MHP - oldValue
else:
HP = clamp(HP, 0, MHP)
Mana
Later on in the lesson we’ll be adding an actual casting cost to spells, so we’ll also need something to keep track of the Magic Points, and Max Magic Points. The script is almost the same as the Health script, although it will look at MP instead of HP, and we’ll add the ability to recharge a few magic points at the beginning of each turn.
For this, we need to add a listener to the “TurnBeganNotification”, and when called, if the unit’s MP are lower than the Max, we’ll add a percentage back to the user.
Create a new script named “Mana.gd” in the folder “Scripts->View Model Component->Actor”
extends Node
class_name Mana
var stats:Stats
var turnController:TurnOrderController
var unit:Unit
var MP:int:
get:
return stats.GetStat(StatTypes.Stat.MP)
set(value):
stats.SetStat(StatTypes.Stat.MP, value)
var MMP:int:
get:
return stats.GetStat(StatTypes.Stat.MMP)
set(value):
stats.SetStat(StatTypes.Stat.MMP, value)
func _ready():
stats = get_node("../Stats")
stats.WillChangeNotification(StatTypes.Stat.MP).connect(OnHPWillChange)
stats.DidChangeNotification(StatTypes.Stat.MMP).connect(OnMHPDidChange)
unit = get_node("../")
turnController = get_node("../../").turnOrderController
turnController.TurnBeganNotification(unit).connect(OnTurnBegan)
func _exit_tree():
stats.WillChangeNotification(StatTypes.Stat.MP).disconnect(OnHPWillChange)
stats.DidChangeNotification(StatTypes.Stat.MMP).disconnect(OnMHPDidChange)
turnController.TurnBeganNotification(unit).disconnect(OnTurnBegan)
func OnHPWillChange(sender:Stats, vce:ValueChangeException):
vce.AddModifier(ClampValueModifier.new(999999, 0, MMP))
func OnMHPDidChange(sender:Stats, oldValue:int):
if MMP > oldValue:
MP = MP + MMP - oldValue
else:
MP = clamp(MP, 0, MMP)
func OnTurnBegan():
if MP < MMP:
MP = MP + max(floori(MMP * 0.1), 1)
Init Battle State
Now for Health and Mana scripts to work, we need to add them to our characters. Open up the script “InitBattleState.gd” In the function “SpawnTestUnits()”, we’ll add the following lines just before “units.append(unit)” at the end of the for loop.
var health:Health = Health.new() health.name = "Health" unit.add_child(health) var mana:Mana = Mana.new() mana.name = "Mana" unit.add_child(mana)
While we are at it, lets move the Rank bit to the same location. If it is commented out, toggle off the comments and move it up for organization. If you don’t have it, just go ahead and add it. It will be useful for this lesson if our characters have a few more HP and MP. Also while you are with the Rank code, lets add a name to the node with rank.name.
var rank = Rank.new() rank.name = "Rank" unit.add_child(rank) rank.Init(10)
Heal Ability Effect
We’ll add a few more Ability Effects in this lesson, starting with Heal. Healing works very similar to Damage, with the big difference that we don’t want defense to affect the amount. In general characters will want to be healed, so we don’t need to try blocking or reducing it.
In the folder “Scripts->View Model Component->Ability->Effects”, create a new script named “HealAbilityEffect.gd”
extends BaseAbilityEffect
class_name HealAbilityEffect
func Predict(target:Tile):
var attacker:Unit = battle.GetParentUnit(self)
var defender:Unit = target.content
return GetStat(attacker, defender, abilityCont.GetPowerNotification, 0)
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))
# Clamp the amount to a range
value = clamp(value, minDamage, maxDamage)
# 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
Unique Ability Effects
We’ve covered abilities with multiple effects like dealing damage and inflicting Blind to a single unit or group of units, but so far we haven’t added anything that does different things to different kinds of units.
Our first example of this will be the spell Cure. To normal units this will heal a certain amount of health, but to units that are Undead, the opposite will happen and they’ll take damage. To do that though, we’ll need to implement a few things.
Undead
The first component we need is a script to mark the character as Undead. This class will be basically empty. We just need to add the class_name so we can reference it.
Create a new script named “Undead.gd” in the folder “Scripts->View Model Component->Actor”
extends Node class_name Undead
Undead Ability Effect Target
With the Undead script, we can add another Ability Effect Target. This one will check whether a unit does, or does not have the Undead script attached.
We add a toggle so, depending on the spell, we can check if the unit is, or isn’t undead. If the unit either has or doesn’t have an Undead node attached, depending on the toggle, we make one final check to make sure the unit has HP above zero to make sure they are “alive”, whether they are undead or not.
Create a new script in the folder “Scripts->View Model Component->Ability->Effect Target” named “UndeadAbilityEffectTarget.gd”
extends AbilityEffectTarget
@export var toggle:bool
func IsTarget(tile:Tile):
if (tile == null || tile.content == null):
return false
var undead:Array[Node] = tile.content.find_children("*", "Undead", false)
var hasComponent:bool = undead.size() > 0
if(hasComponent != toggle):
return false
var s:Stats = tile.content.get_node("Stats")
return s != null && s.GetStat(StatTypes.Stat.HP) > 0
Revive Ability Effect
Another spell we’ll be adding is Revive. Like cure this will have multiple effects depending on the unit’s status. KO’d units will get revived, and for units with HP above zero, normal units will be healed, and Undead units will take damage. We’ve already touched on effects for Heal and Damage, so that leaves us with Revive. The Heal and Revive Ability Effects are similar, though Heal we’ll only apply to units with HP above zero. The biggest difference in the way Revive heals, is that it restores a percentage of the Max HP, without taking any kind of strength into consideration.
Create a new script in the folder “Scripts->View Model Component->Ability->Effects” named “ReviveAbilityEffect.gd”
extends BaseAbilityEffect
class_name ReviveAbilityEffect
@export var percent:float
func Predict(target:Tile):
var s:Stats = target.content.get_node("Stats")
var maxHP = s.GetStat(StatTypes.Stat.MHP)
return floori(maxHP * percent)
func OnApply(target:Tile):
var s:Stats = target.content.get_node("Stats")
var value:int = Predict(target)
s.SetStat(StatTypes.Stat.HP, value)
return value
Dependent Ability Effects
To showcase a bit more variety, lets add a dependent ability effect. This will only apply if another effect succeeds. For example if we have a weapon with a poison attribute, we might want to only apply poison if the attack hits. For now though, the Effect we’ll be implementing is “Drain”, which will have an effect of healing the attacker based on the amount of damage dealt to the target. Something like a vampiric effect.
When we add this node to a skill, we’ll need to go into the inspector to set the effect we want to base our effect on. We’ll also need to be careful to make sure that the node we choose to base it on is higher in the tree, and also that we are choosing something on the same ability.
Create a new script in the folder “Scripts->View Model Component->Ability->Effects” named “AbsorbDamageAbilityEffect.gd”
extends BaseAbilityEffect
class_name AbsorbDamageAbilityEffect
@export var effect:BaseAbilityEffect
@export var power:int = 100
var amount:int
func _ready():
super()
hitController.HitNotification.connect(OnEffectHit)
hitController.MissedNotification.connect(OnEffectMissed)
func _exit_tree():
hitController.HitNotification.disconnect(OnEffectHit)
hitController.MissedNotification.disconnect(OnEffectMissed)
func Predict(target:Tile):
return 0
func OnApply(target:Tile):
var s:Stats = battle.GetParentUnit(self).get_node("Stats")
var currentHP = s.GetStat(StatTypes.Stat.HP)
s.SetStat(StatTypes.Stat.HP, currentHP + amount)
return amount
func OnEffectHit(args, trackedEffect:BaseAbilityEffect):
if effect == trackedEffect:
amount = args * -1 * power / 100
func OnEffectMissed(trackedEffect:BaseAbilityEffect):
if effect == trackedEffect:
amount = 0
Ability Magic Cost
Next up is to actually add a component that will prevent us from casting spells that we don’t have enough MP for, and also to remove said MP from our points when we do use it. In the Ability script attached to our spell we have a couple signals that we will listen to, CanPerformCheck, and DidPerformNotification. CanPerformCheck we’ll send a flag through an exception with the value flipped if we don’t have enough magic points, and once DidPerformNotification fires, we remove those Magic Points.
Create a script in the folder “Scripts->View Model Component->Ability” named AbilityMagicCost.gd”
extends Node
class_name AbilityMagicCost
@export var amount:int
var ability:Ability
func _ready():
ability = self.get_parent()
ability.CanPerformCheck.connect(OnCanPerformCheck)
ability.DidPerformNotification.connect(OnDidPerformNotification)
func _exit_tree():
ability.CanPerformCheck.disconnect(OnCanPerformCheck)
ability.DidPerformNotification.disconnect(OnDidPerformNotification)
func OnCanPerformCheck(exc:BaseException):
var s:Stats = ability.battle.GetParentUnit(self).get_node("Stats")
var currentMP = s.GetStat(StatTypes.Stat.MP)
if currentMP < amount:
exc.FlipToggle()
func OnDidPerformNotification():
var s:Stats = ability.battle.GetParentUnit(self).get_node("Stats")
var currentMP = s.GetStat(StatTypes.Stat.MP)
s.SetStat(StatTypes.Stat.MP, currentMP - amount)
Ability Menu
The next thing we need is a way to actually select our spells in the game. We have “Attack” hard coded into the menu, which works for that one, but we want it a bit more dynamic for the rest of the skills. Let’s start by adding one more script
Ability Catalog
Create a script named “AbilityCatalog.gd” in the same folder as the previous script, “Scripts->View Model Component->Ability”
We’ll be using the hierarchy of our nodes to fill out our menu here. Direct children of the node this script is attached to will be the categories, such as “White Magic” or “Sagacity Skill” and the direct children of each of those will be the abilities in those categories.
extends Node class_name AbilityCatalog # Assumes that all direct children are categories # and that the direct children of categories # are abilities func CategoryCount(): return self.get_child_count() func GetCategory(index:int)->Node: if (index < 0 || index >= self.get_child_count()): return null var children = self.get_children() return children[index] func AbilityCount(category:Node): if category == null: return 0 return category.get_child_count() func GetAbility(categoryIndex:int, abilityIndex:int): var category:Node = GetCategory(categoryIndex) if (category == null || abilityIndex < 0 || abilityIndex >= category.get_child_count()): return null var children = category.get_children() return children[abilityIndex]
Category Selection State
Open up the script “CategorySelectionState.gd”. The first thing we need to modify is the LoadMenu() function. We’ll continue to specify the “Attack” menu option manually, but the rest we will loop through the ability catalog to get the options.
func LoadMenu():
menuOptions.clear()
menuTitle= "Action"
menuOptions.append("Attack")
var catalog:AbilityCatalog = turn.actor.get_node("Abilities/Ability Catalog")
for i in catalog.CategoryCount():
menuOptions.append(catalog.GetCategory(i).name)
abilityMenuPanelController.Show(menuTitle,menuOptions)
In the Confirm() function, if the “Attack” menu option is chosen, we continue to do as we have, any other option however we will get the selection and pass it on to the next state.
func Confirm(): match( abilityMenuPanelController.selection): 0: Attack() _: SetCategory(abilityMenuPanelController.selection - 1)
Action Selection State
Open up the script “ActionSelectionState.gd”. The menus here will be dynamically generated as well.
At the top of the script, we’ll remove the State variable for commandSelectionState, and replace it with abilityTargetState. We’ll delete the two arrays for white and black magic options, and add a variable for the AbilityCatalog.
extends BaseAbilityMenuState @export var abilityTargetState:State @export var categorySelectionState:State static var category:int var catalog:AbilityCatalog
To load what goes into each menu, we’ll need to make changes to the “LoadMenu() and Confirm()” functions, just like we did in the last script. This time however, we’ll also be locking certain abilities if we can’t use them, such as if we don’t have enough MP.
func LoadMenu():
catalog = turn.actor.get_node("Abilities/Ability Catalog")
var container:Node = catalog.GetCategory(category)
menuTitle = container.name
var count:int = catalog.AbilityCount(container)
menuOptions.clear()
var locks:Array[bool]
locks.resize(count)
for i in count:
var ability:Ability = catalog.GetAbility(category, i)
var cost:AbilityMagicCost = ability.get_node_or_null("Ability Magic Cost")
if cost:
menuOptions.append("{0}: {1}".format([ability.name, cost.amount]))
else:
menuOptions.append(ability.name)
locks[i] = !ability.CanPerform()
abilityMenuPanelController.Show(menuTitle,menuOptions)
for i in count:
abilityMenuPanelController.SetLocked(i, locks[i])
func Confirm():
turn.ability = catalog.GetAbility(category, abilityMenuPanelController.selection)
_owner.stateMachine.ChangeState(abilityTargetState)
We can also remove the function “SetOptions()” as it is no longer needed. With that finished, go into the inspector in the Battle.tscn scene, and under the “Action Selection State” node, set the Ability Target State to its corresponding variable.
In our demo this lesson, you should use up enough after a few attacks, then after a few turns, enough MP should regenerate to where we can use the skills again.
Demo
To create our Ability Catalog and Abilities, we need to head over to our “Hero.tscn” prefab scene. We’ll be adding a number of nodes to build out our catalog.
Before we continue, lets talk about what our nodes will look like. As a child of the node “Abilities”, we currently have a node “Attack”. This will be our only hard coded ability. The nodes under it will remain much the same as before. We’ll create another node under “Abilities” named “Ability Catalog”, with a script attached “AbilityCatalog.gd”. All the children of this node will be category names, and we’ll use those node names to get what is printed in the menu.
The children of each category will be the abilities found within it, and they should each have the script “Ability.gd” attached to them, just like the Attack node. Each ability should have at least an “Ability Range” and “Ability Area” with range and area scripts attached, as well as at least one ability effect node, which will have two nodes of it’s own, “Hit Rate” and “Ability Effect Target” which will filter out targets that can be hit, and what hit percentage the skill has.
In addition each Ability can have a node for Ability Power, and another for Ability Magic Cost. I’ve created a chart showing all the nodes and settings for each to set in the inspector. I’ve color coded each Category:Orange, Ability:Blue, and Ability Effect:Green to make it a little easier to visually follow.
In the image, I have the name of the node first, inside the parenthesis is the script that is attached to that node, and after the dash are all the parameters to set in the inspector. Some nodes, like “Inflict Blind” have a script as a parameter in the inspector, which in that case is the script we will be “inflicting” on the target. Another one to watch out for is the “Absorb” node, where in the inspector we need to link the node “Damage”, but it is important to make sure we select the correct “Damage” node from the same skill, and not one from another skill by mistake.

Once you have a set of abilities created, whether you created your own, or used this list, its time to test them all out and make sure they work as intended.
- Are the character’s MP going down when using a spell?
- Are spells locked when the character does not have enough MP?
- Do MP Regenerate at the start of a character’s turn?
- Are HP and MP clamped to legal ranges?
- Do the correct effects get added to the unit, such as “Blind” when casting “Inflict Blind”
- Does the correct Ability target filter out the correct units? In the Hero.tscn prefab try adding a node “Undead” with the script “Undead.gd” attached as a child to the main unit node to see how the units healing spells behave differently.

Bonus Skills
Before wrapping up this lesson, I promised some bonus skills. I’ll be adding two new skills, “Shove” and “Earthquake”. Shove will add a knockback feature, and earthquake will be adding terrain deformation. Both of these skills have some edge cases that I have somewhat ignored.
With Knockback for instance, what happens when a character is shoved off the map entirely? Should he return to his starting point, but take damage? Be removed from the map? In this case I chose to just not apply the knockback. The next edge case in knockback is what happens if a unit is pushed down a cliff and there is something at the bottom, such as a character. Should a new location be found to ultimately land? Again, I chose to just ignore the case and skip applying the effect in that case. The final edge case with the knockback is that when a character bumps into another unit, both units are damaged, however I did not include the second character into any prediction function. To add this we’d have to tweak how we go through our ability area/range and I thought that might be a bit much for the moment.
Earthquake does not have as many edge cases. The main one being what happens if the tiles are removed entirely. Will the character fall off the map? Should they die? Do they get relocated to a different tile? I sidestepped these questions by simply preventing earthquake from removing the last height.
To each skill I’ve added some things like fall damage, and some simple animations like our walk movement in previous lessons. These may be better suited in a different location, but it seems to work at least for the time being.
Knockback Ability Effect
In this effect, we’ll grab the targets position, and calculate the knockback location assuming the target will be moving away from the attacker. After that we’ll determine if the new location is at the same height, higher or lower. We’ll take damage if the character bumps into something, or if the character falls off a cliff. In OnApply() we apply the damage to the character, and potentially anyone the unit bumped into. Remember though that the damage of the other party is not added to any predict function, which could potentially impact things like AI later on down the road. As long as the knockback location isn’t null, we’ll call traverse.
In Traverse(), our goal is to decide whether we need the character to be bumped to the next tile, fall, or bounce back to their current tile. In the functions Bump(), Fall() and Bounce() I create tweens to animate our character’s movement, and call Place() after the movement is finished so I don’t have to store the old locations for the tweens. In Bounce() I added a variable to let us fine tune how far in between we let the characters travel before bouncing back, with a default set at halfway to the destination.
Create a new script in “Scripts->View Model Component->Ability->Effects” named “KnockbackAbilityEffect.gd”
extends BaseAbilityEffect
class_name KnockbackAbilityEffect
@export var bounceDamage = 5
@export var fallDamage = 5
var knockbackLocation:Tile
func Predict(target:Tile):
var attacker:Unit = battle.GetParentUnit(self)
var defender:Unit = target.content
var newDirection:Directions.Dirs = Directions.GetDirection(attacker.tile, target)
var newLocation:Vector2i = target.pos + Directions.ToVector(newDirection)
knockbackLocation = battle.board.GetTile(newLocation)
if knockbackLocation == null:
#Skipping edge cases. Does nothing if no tile to push to.
#Do you remove character from battle? Damage as if hitting a wall?
return 0
if knockbackLocation.height == target.height:
#Successful shove. We need to check for unit on other tile
if knockbackLocation.content != null:
return -bounceDamage
return 0
if knockbackLocation.height < target.height:
#Shoving down at least one level, this could be painful. Also need to check for unit
if knockbackLocation.content != null:
#There is a unit at the bottom of the cliff. This adds complexity. I'll leave this to you.
return 0
var fallHeight = target.height - knockbackLocation.height
return -fallHeight * fallDamage
if knockbackLocation.height > target.height:
#We hit a wall. We don't move, but will take damage
return -bounceDamage
return 0
func OnApply(target:Tile):
var defender:Unit = target.content
var value:int = Predict(target)
# 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)
# Apply damage to anyone we bumped into
if knockbackLocation.height == target.height:
if knockbackLocation.content:
var bumpStats:Stats = knockbackLocation.content.get_node("Stats")
var bumpCurrentHP = bumpStats.GetStat(StatTypes.Stat.HP)
bumpStats.SetStat(StatTypes.Stat.HP, bumpCurrentHP - bounceDamage)
if knockbackLocation != null:
Traverse(target)
return value
func Traverse(target:Tile):
var defender:Unit = target.content
if knockbackLocation.height == target.height:
if knockbackLocation.content == null:
defender.Place(knockbackLocation)
await Bump(defender)
else:
await Bounce(defender)
if knockbackLocation.height < target.height && knockbackLocation.content == null:
await Bump(defender)
await Fall(defender)
defender.Place(knockbackLocation)
if knockbackLocation.height > target.height:
await Bounce(defender)
func Bump(defender:Unit):
var tween = create_tween()
tween.tween_property(
defender,
"position",
Vector3(knockbackLocation.center().x, defender.tile.center().y, knockbackLocation.center().z),
.25,
).set_trans(Tween.TRANS_LINEAR)
await tween.finished
func Fall(defender:Unit):
var tween = create_tween()
var fallHeight:int =defender.tile.height - knockbackLocation.height
var duration: float = .1 * fallHeight
tween.tween_property(
defender,
"position",
knockbackLocation.center(),
duration
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
await tween.finished
func Bounce(defender:Unit):
#Using lerp we can fine tune where the character bounces to
var percentTraveled: float = .5
var midpoint:Vector3 = defender.tile.center().lerp(knockbackLocation.center(), percentTraveled)
var tween = create_tween()
tween.tween_property(
defender,
"position",
Vector3(midpoint.x, defender.tile.center().y, midpoint.z),
.25,
).set_trans(Tween.TRANS_LINEAR)
tween.tween_property(
defender,
"position",
defender.tile.center(),
.25,
).set_trans(Tween.TRANS_LINEAR)
await tween.finished
Earthquake Ability Effect
The Earthquake Ability Effect itself is a bit simpler than the knockback, but we’ll need a few other things to go with this one, instead of it all being in this one script.
In Predict(), if there is a unit on the tile, we calculate how far we’ll fall, assuming that our tile does not go below a height of one.
In Apply() we loop though the depth we want to shrink the tile, skipping if the tile is already at one height. When we first created the Battle scene, I kept the full script for the BoardCreator in our main scene with the thought that I would use the _ShrinkSingle() function to deform the terrain here. Although now it looks like it was not necessary, as I’m just calling Shrink() on the tile directly, just like _ShrinkSingle() does. Because we already have the tile, it doesn’t make sense to convert that to a Vector2i for _ShrinkSingle() and then back to a tile in that function.
Traverse() is similar to our knockback, but the only case we care about here is Fall()
Create a new script in “Scripts->View Model Component->Ability->Effects” named “EarthquakeAbilityEffect.gd”
extends BaseAbilityEffect
class_name EarthquakeAbilityEffect
@export var earthquakeDepth: int = 3
@export var fallDamage = 5
var fallDistance: int
func Predict(target:Tile):
if target.content != null:
var originalHeight: int = target.height
var newHeight: int = max(originalHeight - earthquakeDepth, 1)
fallDistance = originalHeight - newHeight
return -fallDamage * fallDistance
else:
return 0
func OnApply(target:Tile):
var value:int = Predict(target)
for i in earthquakeDepth:
if target.height > 1:
target.Shrink()
battle.board._UpdateMarker()
if target.content && fallDistance > 0:
var defender:Unit = target.content
# Apply fall 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)
Traverse(target)
return value
return 0
func Traverse(target:Tile):
var defender:Unit = target.content
await Fall(defender)
func Fall(defender:Unit):
var tween = create_tween()
var duration: float = .1 * fallDistance
tween.tween_property(
defender,
"position",
defender.tile.center(),
duration
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
await tween.finished
Tile Ability Effect Target
The Default Tile Effect Target works for most cases, but for Earthquake, we need to be able to target the tiles themselves, whether there is a unit there or not. So as long as there is a tile present, this will return true.
Create a new script named, “TileAbilityEffectTarget.gd” in the folder “Scripts->View Model Component->Ability->Effect Target”
extends AbilityEffectTarget func IsTarget(tile:Tile): if (tile == null): return false return true
Confirm Ability Target State
Because earthquake targets tiles instead of units, it would clutter up our UI a bit if we had to scroll through each tile we are targeting. To keep things simple, I decided that we’ll filter out any tiles without a unit for the menu. If that is all that we have targeted, we’ll just show the first one instead of all of them.
Open up the script “ConfirmAbilityTargetState.gd”
At the top of the script with the other variables, we’ll add an Array to hold our trimmed list.
var unitTargetList:Array[Tile] = []
In Exit() we’ll clear the list so it doesn’t build up each time we select a target. Add the line right after we call “super()”
unitTargetList.clear()
In the function FindTargets(), after the for loop where we’ve found all the targets, we’ll add the following bit to filter out the list and add the new targets to our filtered Array.
if turn.targets.size() > 0: for target in turn.targets: if target.content != null: unitTargetList.append(target) if unitTargetList.size() == 0: unitTargetList.append(turn.targets[0])
In the function SetTarget() we need to swap out the references of “turn.targets” to “unitTargetList”. There was also a small bug here that prevented the targets from properly looping around in some cases, so we’ll need to add a “-1” to where we set “index =” the first time.
func SetTarget(target:int): index = target if index < 0: index = unitTargetList.size()-1 if index >= unitTargetList.size(): index = 0 if unitTargetList.size() > 0: RefreshSecondaryStatPanel(unitTargetList[index].pos) UpdateHitSuccessIndicator()
Demo 2
And with that, we should have everything we need to set up our new skills. Go back into the Hero.tscn prefab scene, and add Node to our Ability Catalog named “Bonus Skills”. To that, add two child Nodes named “Shove” and “Earthquake” and add the Ability.gd script to both of them.
Here is the full list of nodes we’ll need for each of the abilities.

Like before, give each ability a test. Make sure our target panel is displaying the correct units and displaying the right amount of damage. Also make sure the animations play correctly.
I set the skills up so the only damage was from fall or bump damage, but if you want to have the spell do more damage, you’ll just need to add a Damage Ability Effect like the earlier skills. You’ll also need to add an Ability Power(Magical or Physical). If you add Damage, you may want to move it above Deform Terrain so that it is the one we see in the UI.
Summary
There was a lot going on in this lesson. We did a big refactor at the beginning, where we moved a couple things around, and fixed several bugs. We created a couple stat wrappers to better manage our Health and Mana. We added an Undead effect. Created a skill to Revive, and a vampiric effect to Drain. We’ve got skills that are based on physical power, while others rely on magical power. We’ve also set up skills that target single units, multiple units, and even the ground itself with our bonus skills.
The Ability Menu now loads up categories and skills dynamically, and we’ve created the ability lock skills if the character doesn’t have enough MP to cast them.
In our bonus we created two new skills that I think people will find exciting. Knockback, and Earthquake, letting us push around characters and even drop the ground out right from under them.
Anyway, sorry again for the long delay, I hope the bonus skills will have made the wait at least partially worth it.
If you have any questions, feel free to comment below or check out the repository.