7thSage again. Welcome back. In the last lesson we set up the range that our attacks could hit. This time around we’re going to set the attack’s Area of Effect, or in other words, how many tiles the attack hits. Some attacks or abilities will only hit a single target, while others will spread across a large range. An archer may hit a single tile with his arrow, or a mage may cause a massive explosion hitting multiple tiles. The area that their attacks hit is our Area of Effect, while how far away their attacks reach is the Range.
Area of Effect
All abilities will need to have some sort of Area of Effect component, whether its a single tile or multiple. Lets start by creating a new folder in “Scripts->View Model Component->Ability” named “Area of Effect”.
Inside the folder we just created, create a new script named “AbilityArea.gd”
This will be our base class for all the other Area of Effects that we create. Because of this, we’ll need to be sure to give it a class_name. The three variables point to the variables on the AbilityRange object the attack or ability is using. This will let us refer to the range of the attack if we want to use it to do things like limit the distance our attack travels to be the same as the attack’s range.
The last function GetTilesInArea() will be used to return a list of tiles similar to what we did in range. We’ll use this list of tiles to highlight the area on the board, and to determine which tiles to hit if they have valid targets on them.
extends Node class_name AbilityArea var rangeH:int: get: return _GetRange().horizontal var rangeMinH:int: get: return _GetRange().minH var rangeV:int: get: return _GetRange().vertical func _GetRange(): var filtered: Array[Node] = self.get_parent().get_children().filter(func(node): return node is AbilityRange) var range:AbilityRange = filtered[0] return range func GetTilesInArea(board:BoardCreator, pos:Vector2i): pass
Unit Ability Area
Create a new script “UnitAbilityArea.gd” in the folder “Scripts->View model Component->Ability->Area of Effect”
Our first Area of effect. This one is pretty simple, it just returns the single tile as long as the tile is valid.
extends AbilityArea func GetTilesInArea(board:BoardCreator, pos:Vector2i): var retValue:Array[Tile] = [] var tile:Tile = board.GetTile(pos) if tile != null: retValue.append(tile) return retValue
Specify Ability Area
Create a new script “SpecifyAbilityArea.gd” in the folder “Scripts->View model Component->Ability->Area of Effect”
When we want to select the tiles in a larger area, such as black mage’s fire spell, we’ll use this version. When we select our tile, it uses search to expand the range out the specified distance.
extends AbilityArea @export var horizontal:int = 2 @export var vertical:int = 999999 var tile:Tile func GetTilesInArea(board:BoardCreator, pos:Vector2i): tile = board.GetTile(pos) return board.RangeSearch(tile, ExpandSearch, horizontal) func ExpandSearch(from:Tile, to:Tile): return (from.distance + 1) <= horizontal && abs(to.height - tile.height) <= vertical
Full Ability Area
Create a new script “FullAbilityArea.gd” in the folder “Scripts->View model Component->Ability->Area of Effect”
Here we just grab the all the tiles from the range function. This is useful for something like a breath attack where we use a directional range type, such as a cone. Because we essentially selected the area in that attack, we’ll return all the tiles it highlighted.
extends AbilityArea func GetTilesInArea(board:BoardCreator, pos:Vector2i): return _GetRange().GetTilesInRange(board)
Line Ability Area
Create a new script “LineAbilityArea.gd” in the folder “Scripts->View model Component->Ability->Area of Effect”
This one wasn’t in the original tutorial, but I like the idea of it, and it is the reason I added those variables that grabbed details from the range. Here you select a target somewhere within range, and we attack in a straight line everything between the unit and the target. I didn’t add line thickness to this one. I based the line from this article on Red Blob Games. I was also tempted to create a second version that used the Grid Walking variation, but I’ll leave that one up to the reader to implement if they want.
The code here is a bit more complicated than the other ranges, and I added some configuration to limit the range based on the range, and gave the ability to extend the line past the target. Sometimes you have to be careful not to hit the things behind our target.
extends AbilityArea @export var horizontal:int = 6 @export var minH:int = 1 @export var vertical:int = 2 @export var useAbilityRange:bool = false @export var extendPastTarget:bool = false var targetTile:Tile var unitTile:Tile func GetTilesInArea(board:BoardCreator, pos:Vector2i): var retValue:Array[Tile] = [] targetTile = board.GetTile(pos) unitTile = self.get_node("../../../").tile var numberSteps = LongestSide(unitTile.pos, targetTile.pos) var maxSteps = rangeH if useAbilityRange else horizontal var minSteps = rangeMinH if useAbilityRange else minH for i in range(minSteps,maxSteps+1): if !extendPastTarget && i > numberSteps + 1: break var lerpAmount:float = 0 if numberSteps==0 else float(i)/numberSteps var point:Vector2 = lerp(Vector2(unitTile.pos),Vector2(targetTile.pos),lerpAmount) var tile:Tile = board.GetTile(point.round()) if Distance(unitTile.pos,point.round()) > maxSteps : break if ValidTile(tile, lerpAmount): retValue.append(tile) return retValue func LongestSide(p0:Vector2i, p1:Vector2i): var distX = p1.x - p0.x var distY = p1.y - p0.y return max(abs(distX),abs(distY)) func Distance(p0:Vector2i, p1:Vector2i): var distX = p1.x - p0.x var distY = p1.y - p0.y return abs(distX) + abs(distY) func ValidTile(t:Tile, lerpDistance:float): var height:int = round(lerp(unitTile.height, targetTile.height, lerpDistance)) return t != null && abs(t.height - height) <= vertical
For the @export variables, “horizontal”, and “minH” work just like they did in range. “vertical” works a little different though, this time when when we draw our line between two points, we are checking each tile based on the height above or below that line at any given point, which we’ll calculate when we call ValidTile() for the height variable. The bool “useAbilityRange” we’ll set to determine whether we want to override this classes “horizontal” and “minH” values. “extendPastTarget” will stop our loop early once it hits the target. The last two variables are a reference to the unit and the target tile, that we’ll base the points of our line on.
In GetTilesInRange(), after we’ve created our return array, we start by setting the targetTile and unitTile variables to their corresponding tiles. “numberSteps” is the longest x or y distance from the unit to the target. This will be the side that will have one point per value, whereas the short side points be on the same edge. For instance a line may have two points with the same x value, then go over a row, then two more on the same row, and so on. This variable will also be used to determine what a single unit of lerp looks like. We’ll use values outside 0 to 1 range for lerp to extend beyond our target. “maxSteps” and “minSteps” will use variables from either this class or the range class depending whether the bool is selected.
The for loop iterates over points we draw along our line, so there is a mismatch between range and the number of points in the line, however the max our line can extend is if we draw a straight line out to the top or sides. Our first if statement breaks out of the loop if we hit the target, as long as the bool to extend past isn’t set. “lerpAmount” gets the percent along the line each point is. Then to get the actual point, we call lerp to calculate the actual values. Then to convert that to a Tile, we round the point to the nearest value and call GetTile() from the board.
At this point if we are past the max range we end the loop, and if not we try adding the tile to our return.
Effect Target
Different spells could have different effects on different units depending on a variety of things. A Cure spell may heal characters normally, but if it is cast on an undead unit, it might harm them instead. Or a revive may do nothing normally, but a character without HP would be healed. We’ll use this class to decide what targets are valid for any given effect.
Create a new folder under “Scripts->View Model Component->Ability” named “Effect Target”, and inside that folder create a script named “AbilityEffectTarget.gd”. This will be our base class that other effects extend, so it’s important we don’t forget to add the class_name.
extends Node class_name AbilityEffectTarget func IsTarget(tile:Tile): pass
Default Ability Effect Target
Create a new script “DefaultAbilityEffectTarget.gd” in the folder “Scripts->View model Component->Ability->Effect Target”
This is the effect target that we will probably use the most often. It will return true for any targets that have HP greater than 0.
extends AbilityEffectTarget func IsTarget(tile:Tile): if (tile == null || tile.content == null): return false var s:Stats = tile.content.get_node("Stats") return s != null && s.GetStat(StatTypes.Stat.HP) > 0
KO’d Ability Effect Target
Create a new script “KOdAbilityEffectTarget.gd” in the folder “Scripts->View model Component->Ability->Effect Target”
This one does the opposite. It only returns for units that don’t have any HP.
extends AbilityEffectTarget func IsTarget(tile:Tile): if (tile == null || tile.content == null): return false var s:Stats = tile.content.get_node("Stats") return s != null && s.GetStat(StatTypes.Stat.HP) <= 0
Turn
Now that we are actually selecting units, we’ll need to store them to use between multiple battle states. In the script “Turn.gd”, with the other variables at the top of the script, add the line
var targets:Array[Tile]
Battle States
Now that we have a few more things to use, we need a way for our game to access them. Which means we’ll be creating a few more states again, and modifying a couple of the old ones. We’ll add a bit more to flesh out the attack loop, and add a state to select the facing direction at the end of the turn, though be warned that we don’t have any new UI for it yet, so once the attack is done, be sure to confirm the direction, even if the game looks like it isn’t doing anything.
Confirm Ability Target State
Once a unit has chosen a direction for a directional attack, or a tile within range, we go to this state which will use the Ability Area from earlier to select the tiles within the abilities area.
We start by finding the child node of with a script attached that is extended from AbilityArea. Once we have the node, we call GetTilesInRange() to get the list of tiles it finds. From there we pass those to the boardCreator to highlight.
FindTargets() gets a list of all the AbilityEffectTarget objects that are attached to an ability and loops through each tile in the tiles array to determine which tiles have valid targets on them. If any valid targets are found, they are added to the turn.targets array.
Create a new script named “ConfirmAbilityTargetState.gd” in the folder “Scripts->Controller->Battle States”
extends BattleState @export var performAbilityState:State @export var abilityTargetState:State var tiles var aa:AbilityArea var index:int = 0 func Enter(): super() var filtered = turn.ability.get_children().filter(func(node): return node is AbilityArea) aa = filtered[0] tiles = aa.GetTilesInArea(board, pos) board.SelectTiles(tiles) FindTargets() RefreshPrimaryStatPanel(turn.actor.tile.pos) SetTarget(0) func Exit(): super() board.DeSelectTiles(tiles) await statPanelController.HidePrimary() await statPanelController.HideSecondary() func OnMove(e:Vector2i): if(e.x > 0 || e.y < 0): SetTarget(index + 1) else: SetTarget(index - 1) func OnFire(e:int): if(e == 0): if (turn.targets.size() > 0): _owner.stateMachine.ChangeState(performAbilityState) else: _owner.stateMachine.ChangeState(abilityTargetState) func FindTargets(): turn.targets = [] var children:Array[Node] = turn.ability.get_children() var targeters:Array[AbilityEffectTarget] targeters.assign(children.filter(func(node): return node is AbilityEffectTarget)) for tile in tiles: if(IsTarget(tile, targeters)): turn.targets.append(tile) func IsTarget(tile:Tile, list:Array[AbilityEffectTarget]): for item in list: if(item.IsTarget(tile)): return true return false func SetTarget(target:int): index = target if index < 0: index = turn.targets.size() if index >= turn.targets.size(): index = 0 if turn.targets.size() > 0: RefreshSecondaryStatPanel(turn.targets[index].pos) func Zoom(scroll: int): _owner.cameraController.Zoom(scroll) func Orbit(direction: Vector2): _owner.cameraController.Orbit(direction)
Ability Target State
We’ll get to the ConfirmTargetAbilityState from the AbilityTargetState. Instead of just moving on to the next state as if an ability was completed, we’ll be switching to include the new state here. We’ll be modifying the OnFire() function to switch states if the confirm button is selected, and the tile under the selection cursor is valid.
Open up the script “AbilityTargetState.gd” and at the top of the script replace the line with the @export variable commandSelectionState with the following.
@export var confirmAbilityTargetState: State
We’ll also need to modify the OnFire() function to the following. We will be handling locking the menu options in a different state, and also need to point to our new state.
func OnFire(e:int): if(e == 0): if(ar.directionOriented || tiles.has(board.GetTile(pos))): _owner.stateMachine.ChangeState(confirmAbilityTargetState) else: _owner.stateMachine.ChangeState(categorySelectionState)
Perform Ability State
Once we have made our selection in ConfirmAbilityTargetState, we change states to PerformAbilityState, where we actually begin using the ability. For now, we create a small example demonstrating taking 15 HP from the target. As the name suggests, we’ll be removing the TemporaryAttackExample() function later. Although they say nothing is more permanent than a something labeled temporary. In this case however, since we are translating the Unity version, so we can be rest assured that this time, it is indeed temporary. I left some comments from the original tutorial of where some things might be placed if you decide to extend the game with more functionality, such as adding animations.
Create a new script named “PerformAbilityTargetState.gd” in the folder “Scripts->Controller->Battle States”
extends BattleState @export var endFacingState:BattleState @export var commandSelectionState:BattleState func Enter(): super() turn.hasUnitActed = true if(turn.hasUnitMoved): turn.lockMove = true await Animate() func Animate(): #TODO play animations, etc #TODO apply ability effect, etc TemporaryAttackExample() if(turn.hasUnitMoved): _owner.stateMachine.ChangeState(endFacingState) else: _owner.stateMachine.ChangeState(commandSelectionState) func TemporaryAttackExample(): for i in range(0,turn.targets.size()): var obj = turn.targets[i].content var stats:Stats if(obj != null): stats = obj.get_node("Stats") if(stats != null): stats.SetStat(StatTypes.Stat.HP, stats.GetStat(StatTypes.Stat.HP) - 15) if (stats.GetStat(StatTypes.Stat.HP) <= 0): print("KO'd {Unit}!".format({"Unit": obj.name}))
End Facing State
We’ll finish up the turn by selecting which direction our character should face. This is activated when the character selects “wait” from the menu, or finishes their attack. Currently there is no UI indicating the mode, so it may look like nothing is happening, but a pressing a directional key will change the facing direction of the unit, and once you confirm the direction, we will move on to the next unit. In a future lesson we’ll add some arrows over the active unit to show what state you are in and give visual feedback of what to do.
Create a new script named “EndFacingState.gd” in the folder “Scripts->Controller->Battle States”
extends BattleState @export var selectUnitState:BattleState @export var commandSelectionState:BattleState var startDir: Directions.Dirs func Enter(): super() startDir = turn.actor.dir SelectTile(turn.actor.tile.pos) func OnMove(e:Vector2i): var rotatedPoint = _owner.cameraController.AdjustedMovement(e) turn.actor.dir = Directions.ToDir(rotatedPoint) turn.actor.Match() func OnFire(e:int): match e: 0: _owner.stateMachine.ChangeState(selectUnitState) 1: turn.actor.dir = startDir turn.actor.Match() _owner.stateMachine.ChangeState(commandSelectionState) func Zoom(scroll: int): _owner.cameraController.Zoom(scroll) func Orbit(direction: Vector2): _owner.cameraController.Orbit(direction)
Command Selection State
In the Command Selection State, lets set the action under wait to go to our End Facing State instead of moving automatically to the next character. This also gives players one last opportunity to cancel back to the menu if they accidentally picked wait.
Open up “CommandSelectionState.gd” and at the top with the other variables add the line
@export var endFacingState: State
And we’ll modify the wait option( 2: ) to point to our endFacingState
func Confirm(): match(abilityMenuPanelController.selection): 0: _owner.stateMachine.ChangeState(moveTargetState) 1: _owner.stateMachine.ChangeState(categorySelectionState) 2: _owner.stateMachine.ChangeState(endFacingState)
Finishing States
Now that we have all our states worked out lets add our three new states to the state machine. In the Battle scene, create three child nodes under “Battle->Battle Controller->State Machine” named, “Confirm Ability Target State”, “Perform Ability State” and “End Facing State” and attach their respective scripts to the nodes.

Now, in the inspector we need to assign the respective @export variables to their corresponding states of each of the three new nodes. We also need to fix the assignments in the scripts we modified, “Command Selection State” and “Ability Target State”
Demo
Now before we give things a test, we need to set up our Hero prefab to work with the new components. Open the “Hero.tscn” prefab and under the “Attack” node, add two more child nodes. One named, “Ability Area” and the other “Ability Effect Target”

For “Ability Area” attach one of the following AbilityArea scripts, “FullAbilityArea.gd”, “LineAbilityArea.gd”, SpecifyAbilityArea.gd” or “UnitAbilityArea.gd”
For “Ability Effect Target” attach one of the following AbilityEffectTarget scripts, “DefaultAbilityEffectTarget.gd” or “KodAbilityEffectTarget.gd”
Test different combinations to see how the interact with each other, and try swapping out which AbilityRange is attached to the “Ability Range” node.
However, it should be noted that directional attacks consider their target the character casting, so those abilities make the most sense with FullAbilityArea.
Summary
In this lesson we finished up the selection process for abilities. We can now see what range each ability has, and the area it will damage. With just these few sets of components we have opened up a lot of possibilities to use in various skills in our game.
We also added an end facing direction giving us a bit more control over positioning our units during the battle.
As always, if you have any question or need help, feel free to ask in the comments below, or check out the repository if you’d like to compare your code.